feat(flyer): add flyer session and selection system
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 2m49s
Test Suite / flutter-quality (push) Successful in 2m0s

- Add FlyerSession, FlyerItem, and FlyerSelection models to Prisma schema
- Implement session persistence with weekly key generation in FlyerImportService
- Add FlyerSelectionModule to AppModule
- Extend FlyerImportResponse with sessionId and flyerItemId fields
- Create new flyer-selection module directory structure
- Add migration for flyer session and selection tables

BREAKING CHANGE: Flyer import now persists data to FlyerSession and FlyerItem tables
This commit is contained in:
Nils-Johan Gynther
2026-05-18 19:02:32 +02:00
parent a31aff7c35
commit 24a96c3da1
11 changed files with 619 additions and 9 deletions
+2
View File
@@ -19,6 +19,7 @@ import { AiModule } from './ai/ai.module';
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 { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -50,6 +51,7 @@ import { RolesGuard } from './auth/roles.guard';
RealtimeModule,
HelpTextsModule,
FlyerImportModule,
FlyerSelectionModule,
],
providers: [
{
@@ -1,6 +1,7 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
category: string | null;
@@ -19,6 +20,7 @@ export type FlyerImportItem = {
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
items: FlyerImportItem[];
@@ -4,6 +4,7 @@ import {
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import {
@@ -79,6 +80,7 @@ export class FlyerImportService {
const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById);
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
@@ -97,14 +99,74 @@ export class FlyerImportService {
};
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
private async persistSessionWithItems(
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
): 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 },
});
const savedItems: FlyerImportItem[] = [];
for (const item of items) {
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,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: item.parseReasons,
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: item.matchReasons,
},
select: { id: true },
});
savedItems.push({ ...item, flyerItemId: created.id });
}
return { sessionId: session.id, items: savedItems };
}
private toWeekKey(date: Date): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
private matchItem(
item: FlyerParseItem,
products: ProductLite[],
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class CreateFlyerSelectionDto {
@Type(() => Number)
@IsInt()
@Min(1)
itemId!: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
plannedQuantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
plannedUnit?: string;
@IsOptional()
@IsString()
@IsIn(['low', 'normal', 'high'])
priority?: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
@@ -0,0 +1,22 @@
export type FlyerSelectionResponse = {
id: number;
sessionId: number;
itemId: number;
userId: number;
plannedQuantity: number | null;
plannedUnit: string | null;
priority: string;
note: string | null;
status: string;
createdAt: string;
updatedAt: string;
item: {
id: number;
rawName: string;
normalizedName: string;
price: number | null;
priceUnit: string | null;
matchedProductId: number | null;
matchedProductName: string | null;
};
};
@@ -0,0 +1,37 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class UpdateFlyerSelectionDto {
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
plannedQuantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
plannedUnit?: string;
@IsOptional()
@IsString()
@IsIn(['low', 'normal', 'high'])
priority?: string;
@IsOptional()
@IsString()
@IsIn(['planned', 'bought', 'skipped', 'archived'])
status?: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Delete,
HttpCode,
Param,
ParseIntPipe,
Patch,
Post,
Request,
Get,
BadRequestException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import { FlyerSelectionService } from './flyer-selection.service';
@Controller('flyer-sessions/:sessionId/selections')
export class FlyerSelectionController {
constructor(private readonly flyerSelectionService: FlyerSelectionService) {}
@Get()
async list(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.listBySession(sessionId, userId);
}
@Post()
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async create(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: CreateFlyerSelectionDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.create(sessionId, userId, dto);
}
@Patch(':selectionId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async update(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('selectionId', ParseIntPipe) selectionId: number,
@Body() dto: UpdateFlyerSelectionDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.update(sessionId, selectionId, userId, dto);
}
@Delete(':selectionId')
@HttpCode(204)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async remove(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('selectionId', ParseIntPipe) selectionId: number,
@Request() req?: any,
): Promise<void> {
const userId = this.getUserId(req);
await this.flyerSelectionService.remove(sessionId, selectionId, userId);
}
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 BadRequestException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FlyerSelectionController } from './flyer-selection.controller';
import { FlyerSelectionService } from './flyer-selection.service';
@Module({
imports: [PrismaModule],
controllers: [FlyerSelectionController],
providers: [FlyerSelectionService],
})
export class FlyerSelectionModule {}
@@ -0,0 +1,198 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
@Injectable()
export class FlyerSelectionService {
constructor(private readonly prisma: PrismaService) {}
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
await this.assertSessionOwnership(sessionId, userId);
const rows = await this.prisma.flyerSelection.findMany({
where: { sessionId, userId },
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toResponse(row));
}
async create(
sessionId: number,
userId: number,
dto: CreateFlyerSelectionDto,
): Promise<FlyerSelectionResponse> {
await this.assertSessionOwnership(sessionId, userId);
const item = await this.prisma.flyerItem.findUnique({
where: { id: dto.itemId },
select: { id: true, sessionId: true },
});
if (!item || item.sessionId !== sessionId) {
throw new BadRequestException('Vald flyer-rad tillhör inte sessionen.');
}
const created = await this.prisma.flyerSelection.upsert({
where: {
sessionId_itemId: {
sessionId,
itemId: dto.itemId,
},
},
update: {
plannedQuantity:
dto.plannedQuantity == null ? undefined : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit,
priority: dto.priority ?? 'normal',
note: dto.note,
},
create: {
sessionId,
itemId: dto.itemId,
userId,
plannedQuantity:
dto.plannedQuantity == null ? null : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit ?? null,
priority: dto.priority ?? 'normal',
note: dto.note ?? null,
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
});
return this.toResponse(created);
}
async update(
sessionId: number,
selectionId: number,
userId: number,
dto: UpdateFlyerSelectionDto,
): Promise<FlyerSelectionResponse> {
await this.assertSessionOwnership(sessionId, userId);
const existing = await this.prisma.flyerSelection.findUnique({
where: { id: selectionId },
select: { id: true, sessionId: true, userId: true },
});
if (!existing || existing.sessionId !== sessionId) {
throw new NotFoundException('FlyerSelection hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna selection.');
}
const updated = await this.prisma.flyerSelection.update({
where: { id: selectionId },
data: {
plannedQuantity:
dto.plannedQuantity == null ? undefined : new Prisma.Decimal(dto.plannedQuantity),
plannedUnit: dto.plannedUnit,
priority: dto.priority,
status: dto.status,
note: dto.note,
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
});
return this.toResponse(updated);
}
async remove(sessionId: number, selectionId: number, userId: number): Promise<void> {
await this.assertSessionOwnership(sessionId, userId);
const existing = await this.prisma.flyerSelection.findUnique({
where: { id: selectionId },
select: { id: true, sessionId: true, userId: true },
});
if (!existing || existing.sessionId !== sessionId) {
throw new NotFoundException('FlyerSelection hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna selection.');
}
await this.prisma.flyerSelection.delete({ where: { id: selectionId } });
}
private async assertSessionOwnership(sessionId: number, userId: number): Promise<void> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: { id: true, userId: true },
});
if (!session) {
throw new NotFoundException('FlyerSession hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
}
private toResponse(row: any): FlyerSelectionResponse {
return {
id: row.id,
sessionId: row.sessionId,
itemId: row.itemId,
userId: row.userId,
plannedQuantity: row.plannedQuantity == null ? null : Number(row.plannedQuantity),
plannedUnit: row.plannedUnit ?? null,
priority: row.priority,
note: row.note ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
item: {
id: row.item.id,
rawName: row.item.rawName,
normalizedName: row.item.normalizedName,
price: row.item.price == null ? null : Number(row.item.price),
priceUnit: row.item.priceUnit ?? null,
matchedProductId: row.item.matchedProductId ?? null,
matchedProductName: row.item.matchedProductName ?? null,
},
};
}
}