feat(recipes): add recipe visibility and sharing features

- Implemented functionality to set recipe visibility (public/private) with appropriate checks for user permissions.
- Added ability to share recipes with other users, including validation for existing users and permissions.
- Introduced new DTOs for setting visibility and sharing recipes.
- Updated RecipesController and RecipesService to handle new endpoints for visibility and sharing.
- Enhanced inventory preview to consider user permissions and shared recipes.
- Updated front-end to support new sharing and visibility features, including UI changes for recipe detail and admin user management.
This commit is contained in:
Nils-Johan Gynther
2026-05-02 09:19:59 +02:00
parent f67bf8baef
commit 41ae7d4d06
17 changed files with 742 additions and 124 deletions
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetRecipeVisibilityDto {
@IsBoolean()
isPublic!: boolean;
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class ShareRecipeDto {
@IsString()
@MinLength(2)
username!: string;
}
+37 -4
View File
@@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch, Request } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Patch } from '@nestjs/common';
import { IsString } from 'class-validator';
import { RecipesService } from './recipes.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { ShareRecipeDto } from './dto/share-recipe.dto';
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
class UpdateImageDto {
@IsString()
@@ -25,8 +27,11 @@ export class RecipesController {
}
@Get(':id/inventory-preview')
getInventoryPreview(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.getInventoryPreview(id);
getInventoryPreview(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.getInventoryPreview(id, user.userId);
}
@Get(':id')
@@ -67,7 +72,35 @@ export class RecipesController {
async updateImage(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateImageDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.updateImage(id, dto.sourceUrl);
return this.recipesService.updateImage(id, dto.sourceUrl, user.userId);
}
@Patch(':id/visibility')
async setVisibility(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SetRecipeVisibilityDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.setVisibility(id, user.userId, dto.isPublic);
}
@Post(':id/share')
async shareRecipe(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ShareRecipeDto,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.shareWithUser(id, user.userId, dto.username.trim());
}
@Delete(':id/share/:username')
async unshareRecipe(
@Param('id', ParseIntPipe) id: number,
@Param('username') username: string,
@CurrentUser() user: { userId: number },
) {
return this.recipesService.unshareWithUser(id, user.userId, username.trim());
}
}
+108 -6
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@@ -31,9 +31,16 @@ export class RecipesService {
constructor(private readonly prisma: PrismaService) {}
async getInventoryPreview(id: number) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
async getInventoryPreview(id: number, userId: number) {
const recipe = await this.prisma.recipe.findFirst({
where: {
id,
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: {
ingredients: {
include: {
@@ -287,21 +294,116 @@ export class RecipesService {
}
}
async updateImage(id: number, sourceUrl: string) {
async updateImage(id: number, sourceUrl: string, userId: number) {
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!existingRecipe) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
if (existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const imageUrl = await downloadAndOptimizeImage(sourceUrl, IMAGE_DEST_DIR);
return this.prisma.recipe.update({
where: { id },
data: { imageUrl },
include: { ingredients: { include: { product: { include: { nutrition: true } } } } },
include: {
ingredients: { include: { product: { include: { nutrition: true } } } },
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
});
}
async setVisibility(id: number, userId: number, isPublic: boolean) {
const existingRecipe = await this.prisma.recipe.findUnique({ where: { id } });
if (!existingRecipe || existingRecipe.ownerId !== userId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
if (isPublic) {
const owner = await this.prisma.user.findUnique({
where: { id: userId },
select: { canShareRecipes: true },
});
if (!owner?.canShareRecipes) {
throw new ForbiddenException('Du har inte behörighet att dela recept.');
}
}
return this.prisma.recipe.update({
where: { id },
data: { isPublic },
include: {
ingredients: { include: { product: { include: { nutrition: true } } } },
owner: { select: { id: true, username: true } },
shares: { select: { userId: true } },
},
});
}
async shareWithUser(id: number, ownerId: number, username: string) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
select: { id: true, ownerId: true },
});
if (!recipe || recipe.ownerId !== ownerId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const owner = await this.prisma.user.findUnique({
where: { id: ownerId },
select: { canShareRecipes: true },
});
if (!owner?.canShareRecipes) {
throw new ForbiddenException('Du har inte behörighet att dela recept.');
}
const targetUser = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (!targetUser) {
throw new NotFoundException(`User ${username} not found`);
}
if (targetUser.id === ownerId) {
return this.findOne(id, ownerId);
}
await this.prisma.recipeShare.upsert({
where: { recipeId_userId: { recipeId: id, userId: targetUser.id } },
create: { recipeId: id, userId: targetUser.id },
update: {},
});
return this.findOne(id, ownerId);
}
async unshareWithUser(id: number, ownerId: number, username: string) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
select: { id: true, ownerId: true },
});
if (!recipe || recipe.ownerId !== ownerId) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const targetUser = await this.prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (!targetUser) {
throw new NotFoundException(`User ${username} not found`);
}
await this.prisma.recipeShare.deleteMany({
where: { recipeId: id, userId: targetUser.id },
});
return this.findOne(id, ownerId);
}
async create(createRecipeDto: CreateRecipeDto, userId: number) {
this.logger.log(
`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`,