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:
@@ -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