feat(flyer): add flyer session and selection system
- 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:
+85
@@ -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;
|
||||||
@@ -27,10 +27,12 @@ model User {
|
|||||||
ownedProducts Product[]
|
ownedProducts Product[]
|
||||||
inventoryItems InventoryItem[]
|
inventoryItems InventoryItem[]
|
||||||
pantryItems PantryItem[]
|
pantryItems PantryItem[]
|
||||||
mealPlanEntries MealPlanEntry[]
|
mealPlanEntries MealPlanEntry[]
|
||||||
receiptAliases ReceiptAlias[]
|
receiptAliases ReceiptAlias[]
|
||||||
unitMappings UnitMapping[]
|
unitMappings UnitMapping[]
|
||||||
}
|
flyerSessions FlyerSession[]
|
||||||
|
flyerSelections FlyerSelection[]
|
||||||
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@@ -264,7 +266,7 @@ model UnitMapping {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model HelpText {
|
model HelpText {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String
|
key String
|
||||||
scope String @default("default")
|
scope String @default("default")
|
||||||
@@ -274,6 +276,75 @@ model HelpText {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([key, scope])
|
@@unique([key, scope])
|
||||||
@@index([key, isActive])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { AiModule } from './ai/ai.module';
|
|||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
import { HelpTextsModule } from './help-texts/help-texts.module';
|
import { HelpTextsModule } from './help-texts/help-texts.module';
|
||||||
import { FlyerImportModule } from './flyer-import/flyer-import.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 { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/roles.guard';
|
import { RolesGuard } from './auth/roles.guard';
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ import { RolesGuard } from './auth/roles.guard';
|
|||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
HelpTextsModule,
|
HelpTextsModule,
|
||||||
FlyerImportModule,
|
FlyerImportModule,
|
||||||
|
FlyerSelectionModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||||
|
|
||||||
export type FlyerImportItem = {
|
export type FlyerImportItem = {
|
||||||
|
flyerItemId: number | null;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
@@ -19,6 +20,7 @@ export type FlyerImportItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FlyerImportResponse = {
|
export type FlyerImportResponse = {
|
||||||
|
sessionId: number | null;
|
||||||
retailer: 'willys';
|
retailer: 'willys';
|
||||||
parserVersion: 'v1';
|
parserVersion: 'v1';
|
||||||
items: FlyerImportItem[];
|
items: FlyerImportItem[];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +80,7 @@ export class FlyerImportService {
|
|||||||
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
||||||
const match = this.matchItem(item, products, aliasToProduct, productById);
|
const match = this.matchItem(item, products, aliasToProduct, productById);
|
||||||
return {
|
return {
|
||||||
|
flyerItemId: null,
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
normalizedName: item.normalizedName,
|
normalizedName: item.normalizedName,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
@@ -97,14 +99,74 @@ export class FlyerImportService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
sessionId: persistedItems.sessionId,
|
||||||
retailer: parsed.retailer,
|
retailer: parsed.retailer,
|
||||||
parserVersion: parsed.parserVersion,
|
parserVersion: parsed.parserVersion,
|
||||||
items,
|
items: persistedItems.items,
|
||||||
warnings: parsed.warnings,
|
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(
|
private matchItem(
|
||||||
item: FlyerParseItem,
|
item: FlyerParseItem,
|
||||||
products: ProductLite[],
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user