MAJOR UPPDATE: "First Ai"

feat: add AI categorization for products and enhance user management

- Integrated AI service for category suggestions in receipt import and product management.
- Added premium subscription feature for users with corresponding API endpoints.
- Implemented admin interface for managing pending product suggestions.
- Enhanced user management to include premium status and corresponding UI updates.
- Updated database schema to support new fields for premium status and product status.
This commit is contained in:
Nils-Johan Gynther
2026-04-19 10:34:21 +02:00
parent 0286ab0991
commit 054a19ed7c
30 changed files with 917 additions and 77 deletions
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+128
View File
@@ -0,0 +1,128 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { FlatCategory } from '../categories/categories.service';
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const MODEL = 'mistral-small-2603';
export type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
async suggestCategory(
productName: string,
categories: FlatCategory[],
): Promise<CategorySuggestion> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler');
}
const categoryList = categories
.map((c) => `[${c.id}] ${c.path}`)
.join('\n');
const systemPrompt = `Du är ett kategoriseringssystem för en livsmedelsapp. Din uppgift är att hitta den mest lämpliga kategorin för en produkt.
Tillgängliga kategorier (format: [id] Sökväg):
${categoryList}
Regler:
1. Välj den mest specifika underkategorin som passar produkten.
2. Om ingen specifik kategori passar, välj en underkategori under "Övrigt" om möjligt.
3. Om ingen underkategori under "Övrigt" passar, välj "Övrigt" (den kategori vars sökväg är exakt "Övrigt").
4. Du MÅSTE alltid returnera ett svar — aldrig null eller tomt.
5. Svara ENDAST med giltig JSON i detta format: { "categoryId": <nummer>, "confidence": "high" | "medium" | "low" }
- "high": uppenbart matchande kategori
- "medium": trolig matchning
- "low": osäker, används fallback (Övrigt eller underkategori till Övrigt)`;
const userPrompt = `Produkt: "${productName}"`;
let raw: string;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 100,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
if (!response.ok) {
const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`);
throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt');
}
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '';
} catch (err) {
if (err instanceof ServiceUnavailableException) throw err;
this.logger.error(`Mistral fetch-fel: ${String(err)}`);
throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten');
}
// Parsa och validera AI-svaret
let parsed: { categoryId: number; confidence: string };
try {
parsed = JSON.parse(raw);
} catch {
this.logger.warn(`AI returnerade ogiltig JSON: ${raw}`);
return this.fallbackToOvrigt(categories);
}
const validId = typeof parsed.categoryId === 'number';
const matchedCategory = validId ? categories.find((c) => c.id === parsed.categoryId) : null;
if (!matchedCategory) {
this.logger.warn(`AI returnerade okänt categoryId ${parsed.categoryId}, använder fallback`);
return this.fallbackToOvrigt(categories);
}
const confidence = ['high', 'medium', 'low'].includes(parsed.confidence)
? (parsed.confidence as 'high' | 'medium' | 'low')
: 'medium';
return {
categoryId: matchedCategory.id,
categoryName: matchedCategory.name,
path: matchedCategory.path,
confidence,
usedFallback: confidence === 'low',
};
}
private fallbackToOvrigt(categories: FlatCategory[]): CategorySuggestion {
const ovrigt = categories.find((c) => c.path === 'Övrigt');
if (!ovrigt) {
// Sista utväg — returnera första kategorin
const first = categories[0];
return { categoryId: first.id, categoryName: first.name, path: first.path, confidence: 'low', usedFallback: true };
}
return {
categoryId: ovrigt.id,
categoryName: ovrigt.name,
path: ovrigt.path,
confidence: 'low',
usedFallback: true,
};
}
}
+2
View File
@@ -14,6 +14,7 @@ import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { UserProductsModule } from './user-products/user-products.module';
import { CategoriesModule } from './categories/categories.module';
import { AiModule } from './ai/ai.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -34,6 +35,7 @@ import { RolesGuard } from './auth/roles.guard';
UsersModule,
UserProductsModule,
CategoriesModule,
AiModule,
],
providers: [
{
+5 -4
View File
@@ -27,7 +27,7 @@ export class AuthService {
passwordHash,
});
return this.issueToken(user.id, user.username, user.role);
return this.issueToken(user.id, user.username, user.role, user.isPremium);
}
async login(dto: LoginDto) {
@@ -37,16 +37,17 @@ export class AuthService {
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) throw new UnauthorizedException('Felaktigt användarnamn eller lösenord');
return this.issueToken(user.id, user.username, user.role);
return this.issueToken(user.id, user.username, user.role, user.isPremium);
}
private issueToken(userId: number, username: string, role: string) {
const payload = { sub: userId, username, role };
private issueToken(userId: number, username: string, role: string, isPremium: boolean) {
const payload = { sub: userId, username, role, isPremium };
return {
accessToken: this.jwtService.sign(payload),
userId,
username,
role,
isPremium,
};
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
async validate(payload: { sub: number; username: string; role: string }) {
return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user' };
async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) {
return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
}
}
@@ -8,6 +8,12 @@ export type CategoryNode = {
children: CategoryNode[];
};
export type FlatCategory = {
id: number;
name: string;
path: string;
};
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
@@ -30,4 +36,15 @@ export class CategoriesService {
});
return roots;
}
async findFlattened(): Promise<FlatCategory[]> {
const all = await this.prisma.category.findMany({ orderBy: { name: 'asc' } });
const nameMap = new Map<number, string>();
all.forEach((c) => nameMap.set(c.id, c.name));
return all.map((c) => ({
id: c.id,
name: c.name,
path: c.parentId ? `${nameMap.get(c.parentId) ?? ''} > ${c.name}` : c.name,
}));
}
}
+70 -1
View File
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -10,6 +11,7 @@ import {
Post,
Put,
Query,
Request,
} from '@nestjs/common';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@@ -20,10 +22,29 @@ import { SetTagsDto } from './dto/set-tags.dto';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
import { BulkUpdateProductsDto } from './dto/bulk-update-products.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;
}
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
constructor(
private readonly productsService: ProductsService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
@Get()
findAll(
@@ -57,11 +78,50 @@ export class ProductsController {
return this.productsService.backfillCanonicalNames();
}
@Roles('admin')
@Get('pending')
findPending() {
return this.productsService.findPending();
}
@Roles('admin')
@Post('ai-categorize-bulk')
@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;
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.productsService.findOne(id);
}
@Get(':id/suggest-category')
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);
}
@Post()
create(@Body() body: CreateProductDto) {
return this.productsService.create(body);
@@ -111,6 +171,15 @@ export class ProductsController {
return this.productsService.remove(id);
}
@Roles('admin')
@Patch(':id/status')
setStatus(
@Param('id', ParseIntPipe) id: number,
@Body() body: SetProductStatusDto,
) {
return this.productsService.setStatus(id, body.status);
}
@Roles('admin')
@Post(':id/restore')
restore(@Param('id', ParseIntPipe) id: number) {
+3
View File
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
@Module({
imports: [AiModule, CategoriesModule],
controllers: [ProductsController],
providers: [ProductsService],
})
+23
View File
@@ -407,4 +407,27 @@ export class ProductsService {
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
return { updated: ids.length };
}
async findUncategorized(): Promise<{ id: number; name: string; canonicalName: string | null }[]> {
return this.prisma.product.findMany({
where: { isActive: true, categoryId: null, status: 'active' },
select: { id: true, name: true, canonicalName: true },
orderBy: { name: 'asc' },
});
}
async findPending() {
return this.prisma.product.findMany({
where: { status: 'pending' },
include: {
categoryRef: { include: { parent: true } },
owner: { select: { id: true, username: true } },
},
orderBy: { createdAt: 'desc' },
});
}
setStatus(id: number, status: string) {
return this.prisma.product.update({ where: { id }, data: { status } });
}
}
@@ -1,3 +1,5 @@
import type { CategorySuggestion } from '../../ai/ai.service';
export interface ParsedReceiptItem {
rawName: string;
quantity: number;
@@ -9,4 +11,6 @@ export interface ParsedReceiptItem {
// ordbaserad match: förslag, kräver bekräftelse
suggestedProductId?: number;
suggestedProductName?: string;
// AI-kategorisuggestion för ej matchade varor (premium)
categorySuggestion?: CategorySuggestion;
}
@@ -1,7 +1,9 @@
import {
Controller,
Post,
Request,
UploadedFile,
UseGuards,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
@@ -9,6 +11,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
const ALLOWED_MIMES = [
'image/jpeg',
@@ -24,6 +27,7 @@ export class ReceiptImportController {
constructor(private readonly receiptImportService: ReceiptImportService) {}
@Post()
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
@@ -32,6 +36,7 @@ export class ReceiptImportController {
)
async parseReceipt(
@UploadedFile() file?: Express.Multer.File,
@Request() req?: any,
): Promise<ParsedReceiptItem[]> {
if (!file?.buffer) {
throw new BadRequestException('Ingen fil skickades med.');
@@ -41,6 +46,7 @@ export class ReceiptImportController {
'Otillåten filtyp. Använd JPEG, PNG, WebP eller PDF.',
);
}
return this.receiptImportService.parseReceipt(file);
const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin';
return this.receiptImportService.parseReceipt(file, isPremium);
}
}
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { ReceiptImportController } from './receipt-import.controller';
import { ReceiptImportService } from './receipt-import.service';
import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, AiModule, CategoriesModule],
controllers: [ReceiptImportController],
providers: [ReceiptImportService],
})
@@ -7,6 +7,8 @@ import {
import * as pdfParse from 'pdf-parse';
import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
@@ -36,9 +38,13 @@ ${text}`;
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
async parseReceipt(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
async parseReceipt(file: Express.Multer.File, isPremium = false): Promise<ParsedReceiptItem[]> {
const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) {
throw new ServiceUnavailableException(
@@ -51,7 +57,12 @@ export class ReceiptImportService {
? await this.parseReceiptFromPdf(file.buffer, apiKey)
: await this.parseReceiptFromImage(file.buffer, file.mimetype, apiKey);
return this.matchProducts(rawItems);
const matched = await this.matchProducts(rawItems);
if (isPremium) {
return this.enrichWithAiCategories(matched);
}
return matched;
}
private async parseReceiptFromImage(
@@ -221,4 +232,29 @@ export class ReceiptImportService {
);
});
}
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName);
if (unmatched.length === 0) return items;
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
try {
categories = await this.categoriesService.findFlattened();
} catch {
return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
}
const enriched = new Map<string, ParsedReceiptItem>();
for (const item of unmatched) {
try {
const suggestion = await this.aiService.suggestCategory(item.rawName, categories);
enriched.set(item.rawName, { ...item, categorySuggestion: suggestion });
} catch {
// Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta
enriched.set(item.rawName, item);
}
}
return items.map((item) => enriched.get(item.rawName) ?? item);
}
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Patch, Post, Delete, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common';
import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
@@ -9,6 +9,11 @@ class SetRoleDto {
role: string;
}
class SetPremiumDto {
@IsBoolean()
isPremium: boolean;
}
class AdminCreateUserDto {
@IsString()
@MinLength(2)
@@ -98,6 +103,16 @@ export class UsersController {
return { id: updated.id, username: updated.username, role: updated.role };
}
@Roles('admin')
@Patch(':id/premium')
async setPremium(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SetPremiumDto,
) {
const updated = await this.usersService.setPremium(id, dto.isPremium);
return { id: updated.id, username: updated.username, isPremium: updated.isPremium };
}
@Roles('admin')
@Post()
async adminCreateUser(
+5 -1
View File
@@ -25,7 +25,7 @@ export class UsersService {
findAll() {
return this.prisma.user.findMany({
select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, createdAt: true },
select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, isPremium: true, createdAt: true },
orderBy: { username: 'asc' },
});
}
@@ -34,6 +34,10 @@ export class UsersService {
return this.prisma.user.update({ where: { id }, data: { role } });
}
setPremium(id: number, isPremium: boolean) {
return this.prisma.user.update({ where: { id }, data: { isPremium } });
}
async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
const existing = await this.prisma.user.findFirst({
where: { OR: [{ username: data.username }, { email: data.email }] },