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
@@ -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);
}
}