feat(migration): enforce ownerId requirement in Product table

- Removed all products without an owner to maintain data integrity.
- Updated ownerId column to be non-nullable.
- Modified foreign key constraint for ownerId to use ON DELETE CASCADE.
This commit is contained in:
Nils-Johan Gynther
2026-05-02 19:05:33 +02:00
parent ec24f49836
commit 4e568b4d2e
7 changed files with 652 additions and 1108 deletions
@@ -49,6 +49,7 @@ export class ReceiptImportController {
);
}
const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin';
return this.receiptImportService.parseReceipt(file, isPremium);
const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
return this.receiptImportService.parseReceipt(file, isPremium, userId);
}
}
@@ -55,12 +55,12 @@ export class ReceiptImportService {
private readonly categoriesService: CategoriesService,
) {}
async parseReceipt(file: Express.Multer.File, isPremium = false): Promise<ParsedReceiptItem[]> {
async parseReceipt(file: Express.Multer.File, isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer
const rawItems = await this.parseReceiptViaImporter(file);
// Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app)
const matched = await this.matchProducts(rawItems);
const matched = await this.matchProducts(rawItems, userId);
// Steg 3: AI-kategorisering för premium-användare
if (isPremium) {
@@ -110,15 +110,21 @@ export class ReceiptImportService {
private async matchProducts(
items: ParsedReceiptItem[],
userId?: number,
): Promise<ParsedReceiptItem[]> {
// Hämta alias och produkter parallellt
// Hämta alias och produkter parallellt — filtrera på userId om angivet
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId
? { product: { ownerId: userId } }
: {};
const [aliases, products] = await Promise.all([
this.prisma.receiptAlias.findMany({
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true } } },
where: aliasFilter,
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } } } },
}),
this.prisma.product.findMany({
where: { isActive: true },
select: { id: true, name: true, canonicalName: true },
where: productFilter,
select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } },
}),
]);
@@ -129,29 +135,34 @@ export class ReceiptImportService {
// 1. Alias-match (säker, användaren behöver inte bekräfta)
const alias = aliases.find((a) => a.receiptName === raw);
if (alias) {
const cat = alias.product.categoryRef;
return {
...item,
matchedProductId: alias.product.id,
matchedProductName: alias.product.canonicalName ?? alias.product.name,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'high' as const, usedFallback: false } } : {}),
};
}
// 2. Ordbaserad matchning (förslag, kräver bekräftelse)
const suggestion = this.findWordMatch(raw, products);
if (!suggestion) {
return { ...item };
}
const cat = suggestion.categoryRef;
return {
...item,
suggestedProductId: suggestion?.id,
suggestedProductName: suggestion
? (suggestion.canonicalName ?? suggestion.name)
: undefined,
suggestedProductId: suggestion.id,
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'medium' as const, usedFallback: false } } : {}),
};
});
}
private findWordMatch(
raw: string,
products: { id: number; name: string; canonicalName: string | null }[],
): { id: number; name: string; canonicalName: string | null } | undefined {
products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null }[],
): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null } | undefined {
// Dela upp kvittonamnet i ord (min 3 tecken)
const rawWords = tokenize(raw);
if (rawWords.length === 0) return undefined;
@@ -229,7 +240,8 @@ export class ReceiptImportService {
}
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName);
// Kör regler/AI för alla items som saknar categorySuggestion och har ett rawName
const unmatched = items.filter((i) => !i.categorySuggestion && i.rawName);
if (unmatched.length === 0) return items;
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;