diff --git a/backend/prisma/migrations/20260518190000_add_flyer_session_item_selection/migration.sql b/backend/prisma/migrations/20260518190000_add_flyer_session_item_selection/migration.sql new file mode 100644 index 00000000..0cfaaf21 --- /dev/null +++ b/backend/prisma/migrations/20260518190000_add_flyer_session_item_selection/migration.sql @@ -0,0 +1,85 @@ +CREATE TABLE `FlyerSession` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `retailer` VARCHAR(191) NOT NULL, + `weekKey` VARCHAR(191) NOT NULL, + `status` VARCHAR(191) NOT NULL DEFAULT 'draft', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + `expiresAt` DATETIME(3) NULL, + + INDEX `FlyerSession_userId_idx`(`userId`), + INDEX `FlyerSession_weekKey_idx`(`weekKey`), + INDEX `FlyerSession_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `FlyerItem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `sessionId` INTEGER NOT NULL, + `rawName` VARCHAR(191) NOT NULL, + `normalizedName` VARCHAR(191) NOT NULL, + `categoryHint` VARCHAR(191) NULL, + `price` DECIMAL(10, 2) NULL, + `priceUnit` VARCHAR(191) NULL, + `comparisonPrice` DECIMAL(10, 2) NULL, + `comparisonUnit` VARCHAR(191) NULL, + `offerText` VARCHAR(191) NULL, + `parseConfidence` DOUBLE NOT NULL, + `parseReasons` JSON NULL, + `matchedProductId` INTEGER NULL, + `matchedProductName` VARCHAR(191) NULL, + `matchedVia` VARCHAR(191) NULL, + `matchConfidence` DOUBLE NULL, + `matchReasons` JSON NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `FlyerItem_sessionId_idx`(`sessionId`), + INDEX `FlyerItem_normalizedName_idx`(`normalizedName`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `FlyerSelection` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `sessionId` INTEGER NOT NULL, + `itemId` INTEGER NOT NULL, + `userId` INTEGER NOT NULL, + `plannedQuantity` DECIMAL(10, 2) NULL, + `plannedUnit` VARCHAR(191) NULL, + `priority` VARCHAR(191) NOT NULL DEFAULT 'normal', + `note` VARCHAR(191) NULL, + `status` VARCHAR(191) NOT NULL DEFAULT 'planned', + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `FlyerSelection_sessionId_itemId_key`(`sessionId`, `itemId`), + INDEX `FlyerSelection_sessionId_idx`(`sessionId`), + INDEX `FlyerSelection_userId_status_idx`(`userId`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `FlyerSession` + ADD CONSTRAINT `FlyerSession_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `FlyerItem` + ADD CONSTRAINT `FlyerItem_sessionId_fkey` + FOREIGN KEY (`sessionId`) REFERENCES `FlyerSession`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `FlyerSelection` + ADD CONSTRAINT `FlyerSelection_sessionId_fkey` + FOREIGN KEY (`sessionId`) REFERENCES `FlyerSession`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `FlyerSelection` + ADD CONSTRAINT `FlyerSelection_itemId_fkey` + FOREIGN KEY (`itemId`) REFERENCES `FlyerItem`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `FlyerSelection` + ADD CONSTRAINT `FlyerSelection_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1928c89b..c15a0774 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,10 +27,12 @@ model User { ownedProducts Product[] inventoryItems InventoryItem[] pantryItems PantryItem[] - mealPlanEntries MealPlanEntry[] - receiptAliases ReceiptAlias[] - unitMappings UnitMapping[] -} + mealPlanEntries MealPlanEntry[] + receiptAliases ReceiptAlias[] + unitMappings UnitMapping[] + flyerSessions FlyerSession[] + flyerSelections FlyerSelection[] +} model Product { id Int @id @default(autoincrement()) @@ -264,7 +266,7 @@ model UnitMapping { @@index([userId]) } -model HelpText { +model HelpText { id Int @id @default(autoincrement()) key String scope String @default("default") @@ -274,6 +276,75 @@ model HelpText { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([key, scope]) - @@index([key, isActive]) -} + @@unique([key, scope]) + @@index([key, isActive]) +} + +model FlyerSession { + id Int @id @default(autoincrement()) + userId Int + retailer String + weekKey String + status String @default("draft") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items FlyerItem[] + selections FlyerSelection[] + + @@index([userId]) + @@index([weekKey]) + @@index([status]) +} + +model FlyerItem { + id Int @id @default(autoincrement()) + sessionId Int + rawName String + normalizedName String + categoryHint String? + price Decimal? @db.Decimal(10, 2) + priceUnit String? + comparisonPrice Decimal? @db.Decimal(10, 2) + comparisonUnit String? + offerText String? + parseConfidence Float + parseReasons Json? + matchedProductId Int? + matchedProductName String? + matchedVia String? + matchConfidence Float? + matchReasons Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + selections FlyerSelection[] + + @@index([sessionId]) + @@index([normalizedName]) +} + +model FlyerSelection { + id Int @id @default(autoincrement()) + sessionId Int + itemId Int + userId Int + plannedQuantity Decimal? @db.Decimal(10, 2) + plannedUnit String? + priority String @default("normal") + note String? + status String @default("planned") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + item FlyerItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([sessionId, itemId]) + @@index([sessionId]) + @@index([userId, status]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 55d10cd6..01047384 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ { diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 808d3fc5..116afcb2 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -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[]; diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 7bcb2083..aa624bf4 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -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[], diff --git a/backend/src/flyer-selection/dto/create-flyer-selection.dto.ts b/backend/src/flyer-selection/dto/create-flyer-selection.dto.ts new file mode 100644 index 00000000..c658765c --- /dev/null +++ b/backend/src/flyer-selection/dto/create-flyer-selection.dto.ts @@ -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; +} diff --git a/backend/src/flyer-selection/dto/flyer-selection.response.ts b/backend/src/flyer-selection/dto/flyer-selection.response.ts new file mode 100644 index 00000000..850dc2d1 --- /dev/null +++ b/backend/src/flyer-selection/dto/flyer-selection.response.ts @@ -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; + }; +}; diff --git a/backend/src/flyer-selection/dto/update-flyer-selection.dto.ts b/backend/src/flyer-selection/dto/update-flyer-selection.dto.ts new file mode 100644 index 00000000..42991235 --- /dev/null +++ b/backend/src/flyer-selection/dto/update-flyer-selection.dto.ts @@ -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; +} diff --git a/backend/src/flyer-selection/flyer-selection.controller.ts b/backend/src/flyer-selection/flyer-selection.controller.ts new file mode 100644 index 00000000..78bc7eb2 --- /dev/null +++ b/backend/src/flyer-selection/flyer-selection.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/flyer-selection/flyer-selection.module.ts b/backend/src/flyer-selection/flyer-selection.module.ts new file mode 100644 index 00000000..f03c3d09 --- /dev/null +++ b/backend/src/flyer-selection/flyer-selection.module.ts @@ -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 {} diff --git a/backend/src/flyer-selection/flyer-selection.service.ts b/backend/src/flyer-selection/flyer-selection.service.ts new file mode 100644 index 00000000..a5881848 --- /dev/null +++ b/backend/src/flyer-selection/flyer-selection.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }, + }; + } +}