feat(flyer-import): add session management and retrieval endpoints
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 2m15s
Test Suite / flutter-quality (push) Failing after 1m25s

- Add new API endpoints for retrieving flyer import sessions:
  - GET /flyer-import/sessions/latest - Retrieve latest session for user
  - GET /flyer-import/sessions/:sessionId - Retrieve specific session
- Implement session persistence and restoration in Flutter UI
- Add toJson() methods to FlyerImportItem and FlyerImportResult for serialization
- Add new FlyerImportSession domain model for local session management
- Add unit test file for FlyerImportService
- Update FlyerImportController with new endpoints and user ID extraction
- Update FlyerImportService with session retrieval logic and response mapping
- Update API paths in Flutter client
- Add session restoration on widget init in FlyerImportTab
This commit is contained in:
Nils-Johan Gynther
2026-05-19 21:55:55 +02:00
parent 8b8f8b7b6f
commit 6cd5b80adb
10 changed files with 649 additions and 30 deletions
+135 -14
View File
@@ -2,8 +2,9 @@ import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
@@ -51,7 +52,7 @@ type ProductLite = {
};
@Injectable()
export class FlyerImportService {
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
constructor(
@@ -61,7 +62,7 @@ export class FlyerImportService {
private readonly normalizer: FlyerNormalizerService,
) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaInternal(file);
const [products, aliases] = await Promise.all([
@@ -123,14 +124,47 @@ export class FlyerImportService {
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
async getSession(sessionId: number, userId: number): Promise<FlyerImportResponse> {
const session = await this.prisma.flyerSession.findFirst({
where: { id: sessionId, userId },
include: { items: { orderBy: { id: 'asc' } } },
});
if (!session) {
throw new NotFoundException('Flyer-session hittades inte.');
}
return this.toFlyerImportResponseFromSession(session);
}
async getLatestSession(userId: number): Promise<FlyerImportResponse> {
const latest = await this.prisma.flyerSession.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
include: { items: { orderBy: { id: 'asc' } } },
});
if (!latest) {
return {
sessionId: null,
retailer: 'willys',
parserVersion: 'v1',
items: [],
warnings: [],
};
}
return this.toFlyerImportResponseFromSession(latest);
}
private async persistSessionWithItems(
userId: number,
@@ -377,7 +411,7 @@ export class FlyerImportService {
return allowed.has(cleaned) ? cleaned : cleaned;
}
private async parseViaInternal(file: Express.Multer.File): Promise<FlyerParseResponse> {
private async parseViaInternal(file: Express.Multer.File): Promise<FlyerParseResponse> {
try {
this.logger.debug(`Parsing flyer file: ${file.originalname}`);
@@ -431,5 +465,92 @@ export class FlyerImportService {
`Fel vid tolkning av flyer: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
private toFlyerImportItem(item: {
id: number;
rawName: string;
normalizedName: string;
categoryHint: string | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
comparisonUnit: string | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: string | null;
matchConfidence: number | null;
matchReasons: Prisma.JsonValue | null;
}): FlyerImportItem {
const toStringArray = (value: Prisma.JsonValue | null): string[] => {
if (!Array.isArray(value)) return [];
return value.map((entry) => String(entry));
};
const normalizedMatchVia =
item.matchedVia === 'alias' || item.matchedVia === 'exact' || item.matchedVia === 'token'
? item.matchedVia
: 'none';
const offerLimitText = this.extractOfferLimitText(item.offerText);
const offerSignals = this.extractOfferSignals(item.offerText);
return {
flyerItemId: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.categoryHint,
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,
comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit,
offerText: item.offerText,
isOffer:
item.price != null
|| item.comparisonPrice != null
|| !!item.offerText?.trim()
|| offerSignals.hasCampaignPattern,
offerLimitText,
parseConfidence: item.parseConfidence,
parseReasons: toStringArray(item.parseReasons),
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: normalizedMatchVia,
matchConfidence: item.matchConfidence ?? 0,
matchReasons: toStringArray(item.matchReasons),
};
}
private toFlyerImportResponseFromSession(session: {
id: number;
items: Array<{
id: number;
rawName: string;
normalizedName: string;
categoryHint: string | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
comparisonUnit: string | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: string | null;
matchConfidence: number | null;
matchReasons: Prisma.JsonValue | null;
}>;
}): FlyerImportResponse {
return {
sessionId: session.id,
retailer: 'willys',
parserVersion: 'v1',
items: session.items.map((item) => this.toFlyerImportItem(item)),
warnings: [],
};
}
}