feat(shopping-list): add shopping list feature with flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s

This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
This commit is contained in:
Nils-Johan Gynther
2026-05-20 09:07:30 +02:00
parent 996f0d774b
commit a1a2c33427
37 changed files with 1843 additions and 102 deletions
+2
View File
@@ -20,6 +20,7 @@ import { RealtimeModule } from './realtime/realtime.module';
import { HelpTextsModule } from './help-texts/help-texts.module';
import { FlyerImportModule } from './flyer-import/flyer-import.module';
import { FlyerSelectionModule } from './flyer-selection/flyer-selection.module';
import { ShoppingListModule } from './shopping-list/shopping-list.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -52,6 +53,7 @@ import { RolesGuard } from './auth/roles.guard';
HelpTextsModule,
FlyerImportModule,
FlyerSelectionModule,
ShoppingListModule,
],
providers: [
{
@@ -1,10 +1,11 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
category: string | null;
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
category: string | null;
categoryId: number | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
@@ -21,10 +22,14 @@ export type FlyerImportItem = {
matchReasons: string[];
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
items: FlyerImportItem[];
warnings: string[];
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
sourceAvailable: boolean;
sourceFileName: string | null;
sourceMimeType: string | null;
sourceFileSize: number | null;
items: FlyerImportItem[];
warnings: string[];
};
@@ -0,0 +1,20 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateFlyerItemDto {
@IsOptional()
@IsString()
@MaxLength(191)
rawName?: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
})
@IsInt()
@Min(1)
categoryId?: number | null;
}
@@ -1,12 +1,16 @@
import {
Body,
BadRequestException,
Controller,
Get,
Header,
HttpCode,
Patch,
Param,
ParseIntPipe,
Post,
Request,
StreamableFile,
UnauthorizedException,
UploadedFile,
UseInterceptors,
@@ -15,6 +19,7 @@ import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { FlyerImportResponse } from './dto/flyer-import.response';
import { UpdateFlyerItemDto } from './dto/update-flyer-item.dto';
import { FlyerImportService } from './flyer-import.service';
const ALLOWED_MIMES = [
@@ -72,6 +77,35 @@ export class FlyerImportController {
return this.flyerImportService.getSession(sessionId, userId);
}
@Get('sessions/:sessionId/source')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@Header('Cache-Control', 'private, max-age=300')
async getSessionSource(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<StreamableFile> {
const userId = this.getUserId(req);
const source = await this.flyerImportService.getSessionSource(sessionId, userId);
return new StreamableFile(source.data, {
disposition: `inline; filename="${source.fileName.replace(/"/g, '')}"`,
type: source.mimeType,
length: source.contentLength,
});
}
@Patch('sessions/:sessionId/items/:itemId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 60 } })
async updateSessionItem(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('itemId', ParseIntPipe) itemId: number,
@Request() req: any,
@Body() dto: UpdateFlyerItemDto,
) {
const userId = this.getUserId(req);
return this.flyerImportService.updateSessionItem(sessionId, itemId, userId, dto);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
@@ -1,10 +1,18 @@
import { NotFoundException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { FlyerImportService } from './flyer-import.service';
describe('FlyerImportService', () => {
const prismaMock = {
flyerSession: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
flyerItem: {
findUnique: jest.fn(),
update: jest.fn(),
},
category: {
findUnique: jest.fn(),
},
};
@@ -28,7 +36,27 @@ describe('FlyerImportService', () => {
await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException);
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { id: 123, userId: 1 },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
@@ -64,6 +92,7 @@ describe('FlyerImportService', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0].flyerItemId).toBe(99);
expect(result.items[0].matchedVia).toBe('exact');
expect(result.sourceAvailable).toBe(false);
});
});
@@ -79,8 +108,89 @@ describe('FlyerImportService', () => {
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { userId: 1 },
orderBy: { createdAt: 'desc' },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
});
describe('updateSessionItem', () => {
it('updates rawName and category path', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({ id: 7, userId: 1 });
prismaMock.flyerItem.findUnique.mockResolvedValue({
id: 12,
sessionId: 7,
rawName: 'Tomat',
});
prismaMock.category.findUnique.mockResolvedValue({
id: 3,
name: 'Tomater',
parent: { name: 'Grönsaker', parent: { name: 'Mat', parent: null } },
});
prismaMock.flyerItem.update.mockResolvedValue({
id: 12,
rawName: 'Cocktailtomater',
normalizedName: 'cocktailtomater',
categoryHint: 'Mat > Grönsaker > Tomater',
categoryId: 3,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
offerText: null,
parseConfidence: 1,
parseReasons: [],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
categoryRef: { name: 'Tomater', parent: { name: 'Grönsaker', parent: { name: 'Mat' } } },
});
const service = createService();
const result = await service.updateSessionItem(7, 12, 1, {
rawName: 'Cocktailtomater',
categoryId: 3,
});
expect(result.rawName).toBe('Cocktailtomater');
expect(result.categoryId).toBe(3);
expect(result.category).toBe('Mat > Grönsaker > Tomater');
});
});
describe('getSessionSource', () => {
it('throws when session belongs to another user', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
userId: 99,
sourceFileName: 'flyer.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 10,
sourceData: Buffer.from('abc'),
});
const service = createService();
await expect(service.getSessionSource(1, 1)).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
+264 -24
View File
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -105,6 +106,7 @@ export class FlyerImportService {
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
categoryId: null,
price,
priceUnit,
comparisonPrice,
@@ -122,21 +124,151 @@ export class FlyerImportService {
};
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
sourceAvailable: true,
sourceFileName: file.originalname ?? null,
sourceMimeType: file.mimetype ?? null,
sourceFileSize: file.size ?? null,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
async getSessionSource(sessionId: number, userId: number): Promise<{
fileName: string;
mimeType: string;
contentLength: number;
data: Buffer;
}> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: {
userId: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceData: true,
},
});
if (!session) {
throw new NotFoundException('Flyer-session hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
if (!session.sourceData || !session.sourceFileName || !session.sourceMimeType) {
throw new NotFoundException('Källfil saknas för denna flyer-session.');
}
const data = Buffer.from(session.sourceData);
return {
fileName: session.sourceFileName,
mimeType: session.sourceMimeType,
contentLength: session.sourceFileSize ?? data.length,
data,
};
}
async updateSessionItem(
sessionId: number,
itemId: number,
userId: number,
payload: { rawName?: string; categoryId?: number | null },
): Promise<FlyerImportItem> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: { id: true, userId: true },
});
if (!session) {
throw new NotFoundException('Flyer-session hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
const item = await this.prisma.flyerItem.findUnique({
where: { id: itemId },
select: { id: true, sessionId: true, rawName: true },
});
if (!item || item.sessionId !== sessionId) {
throw new NotFoundException('Flyer-rad hittades inte i sessionen.');
}
const updateData: Prisma.FlyerItemUncheckedUpdateInput = {};
if (typeof payload.rawName === 'string') {
const trimmed = payload.rawName.trim();
if (!trimmed) {
throw new BadRequestException('Namn får inte vara tomt.');
}
updateData.rawName = trimmed;
updateData.normalizedName = normalizeName(trimmed) || normalizeName(item.rawName);
}
if (payload.categoryId !== undefined) {
if (payload.categoryId === null) {
updateData.categoryId = null;
updateData.categoryHint = null;
} else {
const path = await this.resolveCategoryPath(payload.categoryId);
updateData.categoryId = payload.categoryId;
updateData.categoryHint = path;
}
}
if (Object.keys(updateData).length === 0) {
throw new BadRequestException('Inga giltiga fält att uppdatera.');
}
const updated = await this.prisma.flyerItem.update({
where: { id: itemId },
data: updateData,
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
});
return this.toFlyerImportItem(updated as any);
}
async getSession(sessionId: number, userId: number): Promise<FlyerImportResponse> {
const session = await this.prisma.flyerSession.findFirst({
where: { id: sessionId, userId },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
if (!session) {
@@ -150,37 +282,67 @@ export class FlyerImportService {
const latest = await this.prisma.flyerSession.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
if (!latest) {
return {
sessionId: null,
retailer: 'willys',
parserVersion: 'v1',
items: [],
warnings: [],
};
return {
sessionId: null,
retailer: 'willys',
parserVersion: 'v1',
sourceAvailable: false,
sourceFileName: null,
sourceMimeType: null,
sourceFileSize: null,
items: [],
warnings: [],
};
}
return this.toFlyerImportResponseFromSession(latest);
}
private async persistSessionWithItems(
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
private async persistSessionWithItems(
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
file: Express.Multer.File,
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
const weekKey = this.toWeekKey(new Date());
const session = await this.prisma.flyerSession.create({
data: {
userId,
retailer,
weekKey,
status: 'draft',
},
select: { id: true },
weekKey,
status: 'draft',
sourceFileName: file.originalname ?? null,
sourceMimeType: file.mimetype ?? null,
sourceFileSize: file.size ?? file.buffer.length,
sourceStorageKey: this.buildSourceStorageKey(userId, weekKey),
sourceData: Buffer.from(file.buffer),
},
select: { id: true },
});
const savedItems: FlyerImportItem[] = [];
@@ -188,10 +350,11 @@ export class FlyerImportService {
const created = await this.prisma.flyerItem.create({
data: {
sessionId: session.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
categoryHint: item.category,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
rawName: item.rawName,
normalizedName: item.normalizedName,
categoryHint: item.category,
categoryId: item.categoryId,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
@@ -472,6 +635,16 @@ export class FlyerImportService {
rawName: string;
normalizedName: string;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
name: string;
parent?: {
name: string;
parent?: {
name: string;
} | null;
} | null;
} | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
@@ -495,6 +668,8 @@ export class FlyerImportService {
? item.matchedVia
: 'none';
const categoryPath = this.buildCategoryPath(item.categoryRef) ?? item.categoryHint;
const offerLimitText = this.extractOfferLimitText(item.offerText);
const offerSignals = this.extractOfferSignals(item.offerText);
@@ -502,7 +677,8 @@ export class FlyerImportService {
flyerItemId: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.categoryHint,
category: categoryPath,
categoryId: item.categoryId,
price: item.price != null ? item.price.toNumber() : offerSignals.price,
priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit,
comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice,
@@ -524,13 +700,44 @@ export class FlyerImportService {
};
}
private buildCategoryPath(categoryRef?: {
name: string;
parent?: {
name: string;
parent?: { name: string } | null;
} | null;
} | null): string | null {
if (!categoryRef) return null;
const names: string[] = [];
let current: { name: string; parent?: any } | null = categoryRef;
while (current) {
names.unshift(current.name);
current = current.parent ?? null;
}
return names.length > 0 ? names.join(' > ') : null;
}
private toFlyerImportResponseFromSession(session: {
id: number;
sourceFileName?: string | null;
sourceMimeType?: string | null;
sourceFileSize?: number | null;
sourceStorageKey?: string | null;
items: Array<{
id: number;
rawName: string;
normalizedName: string;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
name: string;
parent?: {
name: string;
parent?: {
name: string;
} | null;
} | null;
} | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
@@ -549,8 +756,41 @@ export class FlyerImportService {
sessionId: session.id,
retailer: 'willys',
parserVersion: 'v1',
sourceAvailable: !!session.sourceStorageKey,
sourceFileName: session.sourceFileName ?? null,
sourceMimeType: session.sourceMimeType ?? null,
sourceFileSize: session.sourceFileSize ?? null,
items: session.items.map((item) => this.toFlyerImportItem(item)),
warnings: [],
};
}
private async resolveCategoryPath(categoryId: number): Promise<string> {
const category = await this.prisma.category.findUnique({
where: { id: categoryId },
include: {
parent: {
include: {
parent: true,
},
},
},
});
if (!category) {
throw new BadRequestException(`Kategori med id ${categoryId} hittades inte.`);
}
const names: string[] = [];
let current: { name: string; parent: any } | null = category as any;
while (current) {
names.unshift(current.name);
current = current.parent;
}
return names.join(' > ');
}
private buildSourceStorageKey(userId: number, weekKey: string): string {
return `flyer/${userId}/${weekKey}/${Date.now()}`;
}
}
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsOptional, Min } from 'class-validator';
export class PlanToShoppingListDto {
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(1, { each: true })
itemIds?: number[];
}
@@ -17,6 +17,7 @@ import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.d
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import { FlyerSelectionService } from './flyer-selection.service';
import { PlanToShoppingListDto } from './dto/plan-to-shopping-list.dto';
@Controller('flyer-sessions/:sessionId/selections')
export class FlyerSelectionController {
@@ -80,6 +81,18 @@ export class FlyerSelectionController {
await this.flyerSelectionService.remove(sessionId, selectionId, userId);
}
@Post('plan-to-shopping-list')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async planToShoppingList(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: PlanToShoppingListDto,
@Request() req?: any,
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
const userId = this.getUserId(req);
return this.flyerSelectionService.planToShoppingList(sessionId, userId, dto.itemIds);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ShoppingListModule } from '../shopping-list/shopping-list.module';
import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service';
import { FlyerSelectionController } from './flyer-selection.controller';
import { FlyerSelectionSyncController } from './flyer-selection-sync.controller';
import { FlyerSelectionService } from './flyer-selection.service';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ShoppingListModule],
controllers: [FlyerSelectionController, FlyerSelectionSyncController],
providers: [FlyerSelectionService, FlyerSelectionMatcherService],
exports: [FlyerSelectionService],
@@ -18,12 +18,14 @@ import {
CandidateSelection,
FlyerSelectionMatcherService,
} from './flyer-selection-matcher.service';
import { ShoppingListService } from '../shopping-list/shopping-list.service';
@Injectable()
export class FlyerSelectionService {
constructor(
private readonly prisma: PrismaService,
private readonly matcher: FlyerSelectionMatcherService,
private readonly shoppingListService: ShoppingListService,
) {}
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
@@ -295,6 +297,15 @@ export class FlyerSelectionService {
return rows.map((row) => this.toResponse(row));
}
async planToShoppingList(
sessionId: number,
userId: number,
itemIds?: number[],
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
await this.assertSessionOwnership(sessionId, userId);
return this.shoppingListService.upsertFromFlyerSelections(sessionId, userId, itemIds);
}
async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise<ReceiptMatchPreviewResponse> {
const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey);
const rows = this.matcher.matchRows(dto.items, candidates);
+7 -1
View File
@@ -176,6 +176,12 @@ export class ProductsController {
return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId);
}
@Post('mine/backfill-categories')
@HttpCode(200)
backfillCategoriesMine(@Request() req: { user: { id: number } }) {
return this.productsService.backfillCategoriesMine(req.user.id);
}
@Roles('admin')
@Post('merge')
merge(@Body() body: MergeProductsDto) {
@@ -267,4 +273,4 @@ export class ProductsController {
bulkUpdate(@Body() body: BulkUpdateProductsDto) {
return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId });
}
}
}
+64 -1
View File
@@ -664,4 +664,67 @@ export class ProductsService {
select: { id: true, categoryId: true },
});
}
}
async backfillCategoriesMine(userId: number): Promise<{ updated: number; fallbackToOvrigt: number }> {
const [categories, products] = await Promise.all([
this.categoriesService.findFlattened(),
this.prisma.product.findMany({
where: {
ownerId: userId,
isActive: true,
categoryId: null,
},
select: {
id: true,
name: true,
canonicalName: true,
},
}),
]);
if (products.length === 0) {
return { updated: 0, fallbackToOvrigt: 0 };
}
const fallback =
categories.find((category) => category.path.toLowerCase().endsWith(' > övrigt'))
?? categories.find((category) => category.name.toLowerCase() === 'övrigt')
?? categories[0];
let updated = 0;
let fallbackToOvrigt = 0;
for (const product of products) {
let targetCategoryId = fallback?.id;
let usedFallback = true;
try {
const suggestion = await this.aiService.suggestCategory(
product.canonicalName ?? product.name,
categories,
);
if (suggestion?.categoryId) {
targetCategoryId = suggestion.categoryId;
usedFallback = suggestion.usedFallback === true;
}
} catch {
usedFallback = true;
}
if (!targetCategoryId) {
continue;
}
await this.prisma.product.update({
where: { id: product.id },
data: { categoryId: targetCategoryId },
});
updated += 1;
if (usedFallback) {
fallbackToOvrigt += 1;
}
}
return { updated, fallbackToOvrigt };
}
}
@@ -380,16 +380,24 @@ export class ReceiptImportService {
});
productId = created.id;
}
} else if (item.productId) {
// Använd befintlig produkt
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new Error(`Produkten med ID ${item.productId} hittades inte.`);
}
productId = product.id;
} else {
} else if (item.productId) {
// Använd befintlig produkt
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new Error(`Produkten med ID ${item.productId} hittades inte.`);
}
if (item.categoryId != null && product.categoryId !== item.categoryId) {
await tx.product.update({
where: { id: product.id },
data: { categoryId: item.categoryId },
});
}
productId = product.id;
} else {
throw new Error('Antingen productId eller createProductName måste anges.');
}
@@ -0,0 +1,14 @@
export type ShoppingListItemResponse = {
id: number;
userId: number;
name: string;
productId: number | null;
categoryId: number | null;
quantity: number | null;
unit: string | null;
source: string;
status: string;
checkedAt: string | null;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateShoppingListItemStatusDto {
@IsBoolean()
checked!: boolean;
}
@@ -0,0 +1,49 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Request,
UnauthorizedException,
Body,
} from '@nestjs/common';
import { ShoppingListService } from './shopping-list.service';
import { UpdateShoppingListItemStatusDto } from './dto/update-shopping-list-item-status.dto';
import { ShoppingListItemResponse } from './dto/shopping-list-item.response';
@Controller('shopping-list')
export class ShoppingListController {
constructor(private readonly shoppingListService: ShoppingListService) {}
@Get('items')
async listOpen(@Request() req?: any): Promise<ShoppingListItemResponse[]> {
const userId = this.getUserId(req);
return this.shoppingListService.listOpen(userId);
}
@Patch('items/:itemId/status')
async updateStatus(
@Param('itemId', ParseIntPipe) itemId: number,
@Body() dto: UpdateShoppingListItemStatusDto,
@Request() req?: any,
): Promise<ShoppingListItemResponse> {
const userId = this.getUserId(req);
return this.shoppingListService.updateCheckedStatus(userId, itemId, dto.checked);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ShoppingListController } from './shopping-list.controller';
import { ShoppingListService } from './shopping-list.service';
@Module({
imports: [PrismaModule],
controllers: [ShoppingListController],
providers: [ShoppingListService],
exports: [ShoppingListService],
})
export class ShoppingListModule {}
@@ -0,0 +1,81 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ShoppingListService } from '../shopping-list/shopping-list.service';
describe('ShoppingListService', () => {
const prismaMock = {
shoppingListItem: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
flyerSelection: {
findMany: jest.fn(),
},
$transaction: jest.fn(),
};
const createService = () => new ShoppingListService(prismaMock as any);
beforeEach(() => {
jest.clearAllMocks();
});
it('throws when updating another users shopping item', async () => {
prismaMock.shoppingListItem.findUnique.mockResolvedValue({ id: 1, userId: 99 });
const service = createService();
await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws when shopping item is missing', async () => {
prismaMock.shoppingListItem.findUnique.mockResolvedValue(null);
const service = createService();
await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(NotFoundException);
});
it('deduplicates by productId+unit when planning from flyer selections', async () => {
prismaMock.flyerSelection.findMany.mockResolvedValue([
{
id: 10,
plannedQuantity: new Prisma.Decimal(1),
plannedUnit: 'kg',
item: {
id: 100,
rawName: 'Tomat',
matchedProductId: 7,
categoryId: 22,
priceUnit: 'kg',
},
},
{
id: 11,
plannedQuantity: new Prisma.Decimal(2),
plannedUnit: 'kg',
item: {
id: 101,
rawName: 'Tomat',
matchedProductId: 7,
categoryId: 22,
priceUnit: 'kg',
},
},
]);
prismaMock.$transaction.mockImplementation(async (cb: any) => cb(prismaMock));
prismaMock.shoppingListItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 999 });
const service = createService();
const result = await service.upsertFromFlyerSelections(1, 1, [100, 101]);
expect(result.created).toBe(1);
expect(result.updated).toBe(1);
expect(prismaMock.shoppingListItem.create).toHaveBeenCalledTimes(1);
expect(prismaMock.shoppingListItem.update).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,162 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ShoppingListItemResponse } from './dto/shopping-list-item.response';
@Injectable()
export class ShoppingListService {
constructor(private readonly prisma: PrismaService) {}
async listOpen(userId: number): Promise<ShoppingListItemResponse[]> {
const rows = await this.prisma.shoppingListItem.findMany({
where: { userId, status: 'open' },
orderBy: [{ createdAt: 'desc' }],
});
return rows.map((row) => this.toResponse(row));
}
async updateCheckedStatus(
userId: number,
itemId: number,
checked: boolean,
): Promise<ShoppingListItemResponse> {
const existing = await this.prisma.shoppingListItem.findUnique({ where: { id: itemId } });
if (!existing) {
throw new NotFoundException('Inköpsrad hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna inköpsrad.');
}
const updated = await this.prisma.shoppingListItem.update({
where: { id: itemId },
data: {
status: checked ? 'checked' : 'open',
checkedAt: checked ? new Date() : null,
},
});
return this.toResponse(updated);
}
async upsertFromFlyerSelections(
sessionId: number,
userId: number,
itemIds?: number[],
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
const selections = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
userId,
status: 'planned',
...(itemIds && itemIds.length > 0 ? { itemId: { in: itemIds } } : {}),
},
include: {
item: {
select: {
id: true,
rawName: true,
categoryId: true,
matchedProductId: true,
priceUnit: true,
},
},
},
});
if (selections.length === 0) {
return { created: 0, updated: 0, processedSelectionIds: [] };
}
let created = 0;
let updated = 0;
await this.prisma.$transaction(async (tx) => {
for (const selection of selections) {
const quantity = selection.plannedQuantity ?? new Prisma.Decimal(1);
const unit = selection.plannedUnit ?? selection.item.priceUnit ?? 'st';
const normalizedUnit = unit.trim() || 'st';
const productId = selection.item.matchedProductId ?? null;
let existing: { id: number } | null = null;
if (productId != null) {
existing = await tx.shoppingListItem.findFirst({
where: {
userId,
status: 'open',
productId,
unit: normalizedUnit,
},
select: { id: true },
});
}
if (existing) {
await tx.shoppingListItem.update({
where: { id: existing.id },
data: {
quantity: {
increment: quantity,
},
name: selection.item.rawName,
categoryId: selection.item.categoryId ?? undefined,
source: 'flyer',
},
});
updated += 1;
continue;
}
await tx.shoppingListItem.create({
data: {
userId,
name: selection.item.rawName,
productId,
categoryId: selection.item.categoryId,
quantity,
unit: normalizedUnit,
source: 'flyer',
status: 'open',
},
});
created += 1;
}
});
return {
created,
updated,
processedSelectionIds: selections.map((selection) => selection.id),
};
}
private toResponse(row: {
id: number;
userId: number;
name: string;
productId: number | null;
categoryId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
source: string;
status: string;
checkedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): ShoppingListItemResponse {
return {
id: row.id,
userId: row.userId,
name: row.name,
productId: row.productId,
categoryId: row.categoryId,
quantity: row.quantity == null ? null : Number(row.quantity),
unit: row.unit,
source: row.source,
status: row.status,
checkedAt: row.checkedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}