feat: make pantry items and meal plan entries user-scoped; update related services and controllers

This commit is contained in:
Nils-Johan Gynther
2026-04-22 18:38:04 +02:00
parent 44b4e7ad73
commit 4482129fca
6 changed files with 123 additions and 35 deletions
@@ -0,0 +1,25 @@
-- PantryItem: make user-scoped
ALTER TABLE `PantryItem` ADD COLUMN `userId` INTEGER NULL;
UPDATE `PantryItem`
SET `userId` = (SELECT `id` FROM `User` ORDER BY `id` ASC LIMIT 1)
WHERE `userId` IS NULL;
ALTER TABLE `PantryItem` DROP INDEX `PantryItem_productId_key`;
ALTER TABLE `PantryItem` MODIFY `userId` INTEGER NOT NULL;
ALTER TABLE `PantryItem` ADD INDEX `PantryItem_userId_idx`(`userId`);
ALTER TABLE `PantryItem` ADD UNIQUE INDEX `PantryItem_userId_productId_key`(`userId`, `productId`);
ALTER TABLE `PantryItem` ADD CONSTRAINT `PantryItem_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- MealPlanEntry: make user-scoped
ALTER TABLE `MealPlanEntry` ADD COLUMN `userId` INTEGER NULL;
UPDATE `MealPlanEntry`
SET `userId` = (SELECT `id` FROM `User` ORDER BY `id` ASC LIMIT 1)
WHERE `userId` IS NULL;
ALTER TABLE `MealPlanEntry` DROP INDEX `MealPlanEntry_date_key`;
ALTER TABLE `MealPlanEntry` MODIFY `userId` INTEGER NOT NULL;
ALTER TABLE `MealPlanEntry` ADD INDEX `MealPlanEntry_userId_idx`(`userId`);
ALTER TABLE `MealPlanEntry` ADD UNIQUE INDEX `MealPlanEntry_userId_date_key`(`userId`, `date`);
ALTER TABLE `MealPlanEntry` ADD CONSTRAINT `MealPlanEntry_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+12 -2
View File
@@ -23,6 +23,8 @@ model User {
ownedRecipes Recipe[] @relation("RecipeOwner") ownedRecipes Recipe[] @relation("RecipeOwner")
sharedRecipes RecipeShare[] sharedRecipes RecipeShare[]
ownedProducts Product[] ownedProducts Product[]
pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[]
} }
model Product { model Product {
@@ -160,10 +162,15 @@ model RecipeIngredient {
model PantryItem { model PantryItem {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
productId Int @unique userId Int
productId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([userId, productId])
@@index([userId])
} }
model ReceiptAlias { model ReceiptAlias {
@@ -176,14 +183,17 @@ model ReceiptAlias {
model MealPlanEntry { model MealPlanEntry {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int
date DateTime @db.Date date DateTime @db.Date
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
recipeId Int recipeId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
servings Int? servings Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([date]) @@unique([userId, date]) # Bara ett recept per dag och användare
@@index([userId])
@@index([date]) @@index([date])
} }
+29 -10
View File
@@ -1,33 +1,52 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { MealPlanService } from './meal-plan.service'; import { MealPlanService } from './meal-plan.service';
import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto'; import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@Controller('meal-plan') @Controller('meal-plan')
export class MealPlanController { export class MealPlanController {
constructor(private readonly mealPlanService: MealPlanService) {} constructor(private readonly mealPlanService: MealPlanService) {}
@Get() @Get()
findByRange(@Query('from') from: string, @Query('to') to: string) { findByRange(
return this.mealPlanService.findByRange(from, to); @CurrentUser() user: { userId: number },
@Query('from') from: string,
@Query('to') to: string,
) {
return this.mealPlanService.findByRange(user.userId, from, to);
} }
@Get('shopping-list') @Get('shopping-list')
shoppingList(@Query('from') from: string, @Query('to') to: string) { shoppingList(
return this.mealPlanService.shoppingList(from, to); @CurrentUser() user: { userId: number },
@Query('from') from: string,
@Query('to') to: string,
) {
return this.mealPlanService.shoppingList(user.userId, from, to);
} }
@Get('inventory-compare') @Get('inventory-compare')
inventoryCompare(@Query('from') from: string, @Query('to') to: string) { inventoryCompare(
return this.mealPlanService.inventoryCompare(from, to); @CurrentUser() user: { userId: number },
@Query('from') from: string,
@Query('to') to: string,
) {
return this.mealPlanService.inventoryCompare(user.userId, from, to);
} }
@Post() @Post()
upsert(@Body() dto: CreateMealPlanEntryDto) { upsert(
return this.mealPlanService.upsert(dto); @CurrentUser() user: { userId: number },
@Body() dto: CreateMealPlanEntryDto,
) {
return this.mealPlanService.upsert(user.userId, dto);
} }
@Delete(':date') @Delete(':date')
removeByDate(@Param('date') date: string) { removeByDate(
return this.mealPlanService.removeByDate(date); @CurrentUser() user: { userId: number },
@Param('date') date: string,
) {
return this.mealPlanService.removeByDate(user.userId, date);
} }
} }
+30 -11
View File
@@ -22,9 +22,10 @@ export class MealPlanService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** Hämta matplan för ett datumintervall (default: nuvarande vecka) */ /** Hämta matplan för ett datumintervall (default: nuvarande vecka) */
async findByRange(from: string, to: string) { async findByRange(userId: number, from: string, to: string) {
return this.prisma.mealPlanEntry.findMany({ return this.prisma.mealPlanEntry.findMany({
where: { where: {
userId,
date: { gte: new Date(from), lte: new Date(to) }, date: { gte: new Date(from), lte: new Date(to) },
}, },
include: { recipe: { select: recipeSelect } }, include: { recipe: { select: recipeSelect } },
@@ -33,28 +34,43 @@ export class MealPlanService {
} }
/** Sätt recept för ett datum (upsert — ett recept per dag) */ /** Sätt recept för ett datum (upsert — ett recept per dag) */
async upsert(dto: CreateMealPlanEntryDto) { async upsert(userId: number, dto: CreateMealPlanEntryDto) {
const date = new Date(dto.date); const date = new Date(dto.date);
return this.prisma.mealPlanEntry.upsert({ return this.prisma.mealPlanEntry.upsert({
where: { date }, where: {
create: { date, recipeId: dto.recipeId, servings: dto.servings ?? null }, userId_date: {
userId,
date,
},
},
create: {
userId,
date,
recipeId: dto.recipeId,
servings: dto.servings ?? null,
},
update: { recipeId: dto.recipeId, servings: dto.servings ?? null }, update: { recipeId: dto.recipeId, servings: dto.servings ?? null },
include: { recipe: { select: recipeSelect } }, include: { recipe: { select: recipeSelect } },
}); });
} }
/** Ta bort matplanspost för ett datum */ /** Ta bort matplanspost för ett datum */
async removeByDate(date: string) { async removeByDate(userId: number, date: string) {
const entry = await this.prisma.mealPlanEntry.findUnique({ const entry = await this.prisma.mealPlanEntry.findUnique({
where: { date: new Date(date) }, where: {
userId_date: {
userId,
date: new Date(date),
},
},
}); });
if (!entry) throw new NotFoundException('Ingen matplanspost för detta datum'); if (!entry) throw new NotFoundException('Ingen matplanspost för detta datum');
return this.prisma.mealPlanEntry.delete({ where: { id: entry.id } }); return this.prisma.mealPlanEntry.delete({ where: { id: entry.id } });
} }
/** Samlad ingredienslista för ett datumintervall */ /** Samlad ingredienslista för ett datumintervall */
async shoppingList(from: string, to: string) { async shoppingList(userId: number, from: string, to: string) {
const entries = await this.findByRange(from, to); const entries = await this.findByRange(userId, from, to);
// Summera ingredienser per produkt+enhet (skalat per portionsantal) // Summera ingredienser per produkt+enhet (skalat per portionsantal)
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>(); const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
@@ -83,11 +99,14 @@ export class MealPlanService {
} }
/** Jämför veckans ingrediensbehov mot inventariet */ /** Jämför veckans ingrediensbehov mot inventariet */
async inventoryCompare(from: string, to: string) { async inventoryCompare(userId: number, from: string, to: string) {
const entries = await this.findByRange(from, to); const entries = await this.findByRange(userId, from, to);
// Hämta pantry-produkter — dessa anses alltid tillgängliga // Hämta pantry-produkter — dessa anses alltid tillgängliga
const pantryItems = await this.prisma.pantryItem.findMany({ select: { productId: true } }); const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId)); const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
// Aggregera ingredienser per produkt+enhet (skalat per portionsantal) // Aggregera ingredienser per produkt+enhet (skalat per portionsantal)
+13 -6
View File
@@ -1,23 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { PantryService } from './pantry.service'; import { PantryService } from './pantry.service';
import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@Controller('pantry') @Controller('pantry')
export class PantryController { export class PantryController {
constructor(private readonly pantryService: PantryService) {} constructor(private readonly pantryService: PantryService) {}
@Get() @Get()
findAll() { findAll(@CurrentUser() user: { userId: number }) {
return this.pantryService.findAll(); return this.pantryService.findAll(user.userId);
} }
@Post() @Post()
create(@Body() body: CreatePantryItemDto) { create(
return this.pantryService.create(body); @CurrentUser() user: { userId: number },
@Body() body: CreatePantryItemDto,
) {
return this.pantryService.create(user.userId, body);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(
return this.pantryService.remove(id); @CurrentUser() user: { userId: number },
@Param('id', ParseIntPipe) id: number,
) {
return this.pantryService.remove(user.userId, id);
} }
} }
+14 -6
View File
@@ -6,8 +6,9 @@ import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
export class PantryService { export class PantryService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
findAll() { findAll(userId: number) {
return this.prisma.pantryItem.findMany({ return this.prisma.pantryItem.findMany({
where: { userId },
include: { include: {
product: true, product: true,
}, },
@@ -17,9 +18,14 @@ export class PantryService {
}); });
} }
async create(data: CreatePantryItemDto) { async create(userId: number, data: CreatePantryItemDto) {
const existing = await this.prisma.pantryItem.findUnique({ const existing = await this.prisma.pantryItem.findUnique({
where: { productId: data.productId }, where: {
userId_productId: {
userId,
productId: data.productId,
},
},
}); });
if (existing) { if (existing) {
@@ -27,13 +33,15 @@ export class PantryService {
} }
return this.prisma.pantryItem.create({ return this.prisma.pantryItem.create({
data: { productId: data.productId }, data: { userId, productId: data.productId },
include: { product: true }, include: { product: true },
}); });
} }
async remove(id: number) { async remove(userId: number, id: number) {
const item = await this.prisma.pantryItem.findUnique({ where: { id } }); const item = await this.prisma.pantryItem.findFirst({
where: { id, userId },
});
if (!item) { if (!item) {
throw new NotFoundException(`PantryItem med id ${id} hittades inte`); throw new NotFoundException(`PantryItem med id ${id} hittades inte`);