feat: implement AI categorization for products and add premium access guard
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user