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
@@ -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({