feat: implement AI categorization for products and add premium access guard

This commit is contained in:
Nils-Johan Gynther
2026-04-21 13:55:12 +02:00
parent 83722123d2
commit 864c84d2e5
5 changed files with 73 additions and 40 deletions
@@ -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.');
}
}
@@ -0,0 +1,8 @@
import { IsArray, IsInt, IsOptional } from 'class-validator';
export class AiCategorizeBulkDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
productIds?: number[];
}
@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';
export class SetProductStatusDto {
@IsIn(['active', 'rejected'])
status: string;
}
+15 -39
View File
@@ -2,7 +2,6 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Param, Param,
@@ -12,6 +11,7 @@ import {
Put, Put,
Query, Query,
Request, Request,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/public.decorator'; 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 { SetTagsDto } from './dto/set-tags.dto';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
import { BulkUpdateProductsDto } from './dto/bulk-update-products.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 { Roles } from '../auth/decorators/roles.decorator';
import { AiService } from '../ai/ai.service'; import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service'; import { CategoriesService } from '../categories/categories.service';
import { IsArray, IsIn, IsInt, IsOptional } from 'class-validator'; import { PremiumOrAdminGuard } from '../auth/premium-or-admin.guard';
class AiCategorizeBulkDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
productIds?: number[];
}
class SetProductStatusDto {
@IsIn(['active', 'rejected'])
status: string;
}
@Controller('products') @Controller('products')
export class ProductsController { export class ProductsController {
@@ -94,37 +84,28 @@ export class ProductsController {
@Post('ai-categorize-bulk') @Post('ai-categorize-bulk')
@Throttle({ default: { ttl: 60_000, limit: 5 } }) @Throttle({ default: { ttl: 60_000, limit: 5 } })
@HttpCode(200) @HttpCode(200)
async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) { aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) {
const categories = await this.categoriesService.findFlattened(); return this.productsService.aiCategorizeBulk(body.productIds);
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;
} }
@Roles('admin')
@Get('deleted')
findDeleted() {
return this.productsService.findDeleted();
}
// Tillgänglig för alla inloggade användare
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.productsService.findOne(id); return this.productsService.findOne(id);
} }
@UseGuards(PremiumOrAdminGuard)
@Get(':id/suggest-category') @Get(':id/suggest-category')
@Throttle({ default: { ttl: 60_000, limit: 20 } }) @Throttle({ default: { ttl: 60_000, limit: 20 } })
async suggestCategory( async suggestCategory(
@Param('id', ParseIntPipe) id: number, @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 product = await this.productsService.findOne(id);
const categories = await this.categoriesService.findFlattened(); const categories = await this.categoriesService.findFlattened();
return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories); return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories);
@@ -136,6 +117,7 @@ export class ProductsController {
return this.productsService.create(body); return this.productsService.create(body);
} }
// Tillgänglig för alla inloggade användare — req.user.id injiceras av JWT-guard
@Post('pending') @Post('pending')
createPending( createPending(
@Body() body: CreateProductDto, @Body() body: CreateProductDto,
@@ -186,12 +168,6 @@ export class ProductsController {
return this.productsService.update(id, body); return this.productsService.update(id, body);
} }
@Roles('admin')
@Get('deleted')
findDeleted() {
return this.productsService.findDeleted();
}
@Roles('admin') @Roles('admin')
@Delete(':id/permanent') @Delete(':id/permanent')
permanentDelete(@Param('id', ParseIntPipe) id: number) { permanentDelete(@Param('id', ParseIntPipe) id: number) {
+26 -1
View File
@@ -4,10 +4,16 @@ import { normalizeName } from '../common/utils/normalize-name';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { UpdateProductDto } from './dto/update-product.dto';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
@Injectable() @Injectable()
export class ProductsService { 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 }) { async findAll(filters?: { tag?: string; subcategory?: string }) {
return this.prisma.product.findMany({ 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() { async findPending() {
return this.prisma.product.findMany({ return this.prisma.product.findMany({
where: { status: 'pending' }, where: { status: 'pending' },