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:
@@ -16,6 +16,7 @@ model User {
|
||||
passwordHash String
|
||||
role String @default("user")
|
||||
isPremium Boolean @default(false)
|
||||
canShareRecipes Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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'}`,
|
||||
|
||||
@@ -14,6 +14,11 @@ class SetPremiumDto {
|
||||
isPremium: boolean;
|
||||
}
|
||||
|
||||
class SetRecipeSharingDto {
|
||||
@IsBoolean()
|
||||
canShareRecipes: boolean;
|
||||
}
|
||||
|
||||
class AdminCreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@@ -113,6 +118,16 @@ export class UsersController {
|
||||
return { id: updated.id, username: updated.username, isPremium: updated.isPremium };
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Patch(':id/recipe-sharing')
|
||||
async setRecipeSharing(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: SetRecipeSharingDto,
|
||||
) {
|
||||
const updated = await this.usersService.setRecipeSharing(id, dto.canShareRecipes);
|
||||
return { id: updated.id, username: updated.username, canShareRecipes: updated.canShareRecipes };
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
async adminCreateUser(
|
||||
|
||||
@@ -25,7 +25,17 @@ export class UsersService {
|
||||
|
||||
findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, isPremium: true, createdAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isPremium: true,
|
||||
canShareRecipes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
}
|
||||
@@ -38,6 +48,10 @@ export class UsersService {
|
||||
return this.prisma.user.update({ where: { id }, data: { isPremium } });
|
||||
}
|
||||
|
||||
setRecipeSharing(id: number, canShareRecipes: boolean) {
|
||||
return this.prisma.user.update({ where: { id }, data: { canShareRecipes } });
|
||||
}
|
||||
|
||||
async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: { OR: [{ username: data.username }, { email: data.email }] },
|
||||
|
||||
Reference in New Issue
Block a user