feat: implement user-scoped receipt aliases with global fallback; enhance alias management in admin panel
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 19:43:13 +02:00
parent d73ea5ef7c
commit 64b06435cf
15 changed files with 751 additions and 36 deletions
@@ -0,0 +1,25 @@
-- Make receipt aliases user-scoped with optional admin-managed global fallback.
ALTER TABLE `ReceiptAlias`
ADD COLUMN `ownerId` INT NULL,
ADD COLUMN `isGlobal` BOOLEAN NOT NULL DEFAULT false;
-- Existing aliases become global aliases.
UPDATE `ReceiptAlias`
SET `isGlobal` = true
WHERE `isGlobal` = false;
-- Replace previous global unique key on receiptName.
ALTER TABLE `ReceiptAlias`
DROP INDEX `ReceiptAlias_receiptName_key`;
-- Add scoped uniqueness and lookup indexes.
ALTER TABLE `ReceiptAlias`
ADD UNIQUE INDEX `ReceiptAlias_receiptName_ownerId_isGlobal_key`(`receiptName`, `ownerId`, `isGlobal`),
ADD INDEX `ReceiptAlias_ownerId_idx`(`ownerId`),
ADD INDEX `ReceiptAlias_isGlobal_idx`(`isGlobal`);
-- Link aliases to owner when user-scoped.
ALTER TABLE `ReceiptAlias`
ADD CONSTRAINT `ReceiptAlias_ownerId_fkey`
FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
+9 -1
View File
@@ -26,6 +26,7 @@ model User {
ownedProducts Product[]
pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
}
model Product {
@@ -175,10 +176,17 @@ model PantryItem {
model ReceiptAlias {
id Int @id @default(autoincrement())
receiptName String @unique // normaliserat kvittonamn (lowercase, trim)
receiptName String // normaliserat kvittonamn (lowercase, trim)
ownerId Int?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
isGlobal Boolean @default(false)
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([receiptName, ownerId, isGlobal])
@@index([ownerId])
@@index([isGlobal])
}
model MealPlanEntry {
@@ -1,4 +1,4 @@
import { IsInt, IsString, MinLength } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateReceiptAliasDto {
@IsString()
@@ -7,4 +7,8 @@ export class CreateReceiptAliasDto {
@IsInt()
productId!: number;
@IsOptional()
@IsBoolean()
isGlobal?: boolean;
}
@@ -1,25 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@Roles('admin')
@Controller('receipt-aliases')
export class ReceiptAliasController {
constructor(private readonly receiptAliasService: ReceiptAliasService) {}
@Get()
findAll() {
return this.receiptAliasService.findAll();
findAll(@CurrentUser() user: { userId: number; role: string }) {
return this.receiptAliasService.findAllForUser(user.userId, user.role);
}
@Post()
upsert(@Body() dto: CreateReceiptAliasDto) {
return this.receiptAliasService.upsert(dto);
upsert(
@Body() dto: CreateReceiptAliasDto,
@CurrentUser() user: { userId: number; role: string },
) {
return this.receiptAliasService.upsert(dto, user.userId, user.role);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.receiptAliasService.remove(id);
remove(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number; role: string },
) {
return this.receiptAliasService.remove(id, user.userId, user.role);
}
}
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
@@ -6,23 +6,88 @@ import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
export class ReceiptAliasService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
findAllForUser(userId: number, role: string) {
const where = role === 'admin'
? undefined
: {
OR: [
{ ownerId: userId, isGlobal: false },
{ isGlobal: true },
],
};
return this.prisma.receiptAlias.findMany({
where,
include: { product: { select: { id: true, name: true, canonicalName: true } } },
orderBy: { receiptName: 'asc' },
});
}
async upsert(dto: CreateReceiptAliasDto) {
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
const normalized = dto.receiptName.toLowerCase().trim();
return this.prisma.receiptAlias.upsert({
where: { receiptName: normalized },
create: { receiptName: normalized, productId: dto.productId },
update: { productId: dto.productId },
const wantsGlobal = dto.isGlobal === true;
if (wantsGlobal && role !== 'admin') {
throw new ForbiddenException('Endast admin kan skapa globala alias');
}
if (wantsGlobal) {
const existing = await this.prisma.receiptAlias.findFirst({
where: { receiptName: normalized, isGlobal: true },
});
if (existing) {
return this.prisma.receiptAlias.update({
where: { id: existing.id },
data: { productId: dto.productId },
});
}
return this.prisma.receiptAlias.create({
data: {
receiptName: normalized,
productId: dto.productId,
isGlobal: true,
ownerId: null,
},
});
}
const existing = await this.prisma.receiptAlias.findFirst({
where: { receiptName: normalized, ownerId: userId, isGlobal: false },
});
if (existing) {
return this.prisma.receiptAlias.update({
where: { id: existing.id },
data: { productId: dto.productId },
});
}
return this.prisma.receiptAlias.create({
data: {
receiptName: normalized,
productId: dto.productId,
ownerId: userId,
isGlobal: false,
},
});
}
remove(id: number) {
async remove(id: number, userId: number, role: string) {
const alias = await this.prisma.receiptAlias.findUnique({ where: { id } });
if (!alias) {
return this.prisma.receiptAlias.delete({ where: { id } });
}
const canDelete =
role === 'admin' ||
(alias.ownerId === userId && alias.isGlobal === false);
if (!canDelete) {
throw new ForbiddenException('Du har inte behörighet att ta bort aliaset');
}
return this.prisma.receiptAlias.delete({ where: { id } });
}
}
@@ -56,6 +56,12 @@ describe('ReceiptImportService test matrix', () => {
categoriesServiceMock as any,
);
beforeEach(() => {
jest.clearAllMocks();
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.product.findMany.mockResolvedValue([]);
});
describe('ignore patterns', () => {
it.each([
'Willys Plus:Bröd',
@@ -96,4 +102,125 @@ describe('ReceiptImportService test matrix', () => {
expect(suggestion?.path).toBe(expectedPath);
});
});
describe('alias fallback och prioritet', () => {
it('prioriterar user-alias före global alias för samma receiptName', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'mjolk 1l',
productId: 501,
product: {
id: 501,
name: 'Mjolk user',
canonicalName: 'Mjolk user',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
{
receiptName: 'mjolk 1l',
productId: 999,
product: {
id: 999,
name: 'Mjolk global',
canonicalName: 'Mjolk global',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
]);
prismaMock.product.findMany.mockResolvedValue([]);
const result = await (service as any).matchProducts(
[{ rawName: 'MJOLK 1L' }],
77,
);
expect(prismaMock.receiptAlias.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ ownerId: 77, isGlobal: false },
{ isGlobal: true },
],
},
}),
);
expect(result[0].matchedProductId).toBe(501);
expect(result[0].matchedProductName).toBe('Mjolk user');
});
it('använder global alias när user-alias saknas', async () => {
prismaMock.receiptAlias.findMany.mockResolvedValue([
{
receiptName: 'snickers',
productId: 222,
product: {
id: 222,
name: 'Snickers',
canonicalName: 'Snickers',
categoryId: 53,
categoryRef: { id: 53, name: 'Choklad' },
},
},
]);
prismaMock.product.findMany.mockResolvedValue([]);
const result = await (service as any).matchProducts(
[{ rawName: 'SNICKERS' }],
88,
);
expect(result[0].matchedProductId).toBe(222);
expect(result[0].matchedProductName).toBe('Snickers');
});
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
const aliases: any[] = [];
prismaMock.receiptAlias.findMany.mockImplementation(async () => aliases);
prismaMock.product.findMany.mockResolvedValue([
{
id: 700,
name: 'Arla Mjolk 1l',
canonicalName: 'Mjolk',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
]);
const first = await (service as any).matchProducts(
[{ rawName: 'ARLA MJOLK 1L' }],
42,
);
expect(first[0].matchedProductId).toBeUndefined();
expect(first[0].suggestedProductId).toBe(700);
// Simulerar att användaren manuellt korrigerar och alias lärs in.
aliases.push({
receiptName: 'arla mjolk 1l',
productId: 700,
product: {
id: 700,
name: 'Arla Mjolk 1l',
canonicalName: 'Mjolk',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
});
const second = await (service as any).matchProducts(
[{ rawName: 'ARLA MJOLK 1L' }],
42,
);
expect(second[0].matchedProductId).toBe(700);
expect(second[0].matchedProductName).toBe('Mjolk');
expect(second[0].suggestedProductId).toBeUndefined();
});
});
});
@@ -208,11 +208,20 @@ export class ReceiptImportService {
// 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 } }
: {};
? {
OR: [
{ ownerId: userId, isGlobal: false },
{ isGlobal: true },
],
}
: { isGlobal: true };
const [aliases, products] = await Promise.all([
this.prisma.receiptAlias.findMany({
where: aliasFilter,
orderBy: [
{ isGlobal: 'asc' },
{ id: 'asc' },
],
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } } } },
}),
this.prisma.product.findMany({