Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { Ingredient, ReservedIngredientTags } from './Ingredient.js';
import { ownerExists, tagsExist, unique, uniqueInAdminsAndUser } from './validation.js';

const quantityRegex = /^((\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*)(-(\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*))?)$/;
const ReservedRecipeTags = { Ingredient: 'ingredient' } as const;
const ReservedRecipeTags = {
Ingredient: 'ingredient',
VeganOptionAvailable: 'vegan option available',
} as const;
export const ReservedTags = { ...ReservedRecipeTags, ...ReservedIngredientTags } as const;
type ReservedTags = (typeof ReservedTags)[keyof typeof ReservedTags];

Expand Down Expand Up @@ -165,6 +168,8 @@ export interface Recipe extends Document {
archived: boolean;
createdAt: Date;
lastModified: Date;
veganVersion?: Types.ObjectId;
originalRecipe?: Types.ObjectId;
}
const recipeSchema = new Schema<Recipe>({
title: {
Expand Down Expand Up @@ -254,6 +259,16 @@ const recipeSchema = new Schema<Recipe>({
archived: { type: Boolean, default: false },
createdAt: { type: Date, required: true },
lastModified: { type: Date, required: true },
veganVersion: {
type: Schema.Types.ObjectId,
ref: 'Recipe',
default: null,
},
originalRecipe: {
type: Schema.Types.ObjectId,
ref: 'Recipe',
default: null,
},
});

recipeSchema.index({ 'ingredientSubsections.ingredients.ingredient': 1 });
Expand Down Expand Up @@ -282,6 +297,9 @@ recipeSchema.pre('save', async function () {
calculatedTags.push(ReservedIngredientTags[tag]);
}
}
if (this.veganVersion != null) {
calculatedTags.push(ReservedRecipeTags.VeganOptionAvailable);
}
this.calculatedTags = calculatedTags;
});

Expand Down
128 changes: 126 additions & 2 deletions api/src/schema/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ import { PrepMethodTC } from '../models/PrepMethod.js';
import { Ingredient, IngredientTC } from '../models/Ingredient.js';
import { createOneResolver, updateByIdResolver } from './utils.js';
import { validateItemNotInRecipe } from '../middleware/validation.js';
import { RecipeModifyTC, generateRecipeIdentifier } from '../models/Recipe.js';
import { Recipe, RecipeCreateTC, RecipeIngredientTC, RecipeTC } from '../models/Recipe.js';
import { copyImageForRecipe } from '../utils/image.js';
import {
Recipe,
RecipeCreateTC,
RecipeIngredientTC,
RecipeModifyTC,
RecipeTC,
generateRecipeIdentifier,
} from '../models/Recipe.js';

const IngredientOrRecipeTC = schemaComposer.createUnionTC({
name: 'IngredientOrRecipe',
Expand Down Expand Up @@ -169,6 +176,18 @@ RecipeTC.addFields({
},
});

RecipeTC.addRelation('veganVersion', {
resolver: () => RecipeTC.mongooseResolvers.findById(),
prepareArgs: { _id: (source: Recipe) => source.veganVersion },
projection: { veganVersion: true },
});

RecipeTC.addRelation('originalRecipe', {
resolver: () => RecipeTC.mongooseResolvers.findById(),
prepareArgs: { _id: (source: Recipe) => source.originalRecipe },
projection: { originalRecipe: true },
});

RecipeModifyTC.addResolver({
name: 'archiveById',
description: 'Archive a recipe by its ID',
Expand Down Expand Up @@ -249,6 +268,111 @@ export const RecipeMutation = {
rp.args.record.lastModified = new Date();
return next(rp);
}),
recipeRemoveById: RecipeModifyTC.getResolver('removeById')
.wrapResolve((next) => async (rp) => {
// delete all images associated with the recipe
const images = await ImageTC.mongooseResolvers.findMany().resolve({
args: { filter: { recipe: rp.args._id } },
});
await ImageTC.getResolver('imageRemoveMany').resolve({
args: { ids: images.map((o) => o._id) },
context: rp.context,
});
// delete all rating associated with the recipe
await RatingTC.mongooseResolvers.removeMany().resolve({
args: { filter: { recipe: rp.args._id } },
});
const result = await next(rp);
// Clean up vegan version back-references
const record = result?.record;
if (record?.originalRecipe) {
// This was a vegan copy — unset veganVersion on the original
await Recipe.findByIdAndUpdate(record.originalRecipe, {
$unset: { veganVersion: 1 },
});
// Trigger save on original so calculatedTags lose 'vegan option available'
const original = await Recipe.findById(record.originalRecipe);
if (original) await original.save();
}
if (record?.veganVersion) {
// The original is being deleted — orphan the vegan copy
await Recipe.findByIdAndUpdate(record.veganVersion, {
$unset: { originalRecipe: 1 },
});
}
return result;
})
.wrapResolve((next) => async (rp) => {
await validateItemNotInRecipe(rp.args._id, 'recipe');
return next(rp);
}),
recipeMakeVegan: schemaComposer
.createResolver({
name: 'recipeMakeVegan',
type: RecipeTC.mongooseResolvers.createOne().getType(),
args: { originalId: 'MongoID!' },
resolve: async ({ args, context }) => {
const { originalId } = args;
const original = await Recipe.findById(originalId);
if (!original) throw new Error('Original recipe not found');

// Build the new recipe document (clone all fields)
const veganRecipe = new Recipe({
title: original.title,
pluralTitle: original.pluralTitle,
subTitle: original.subTitle,
ingredientSubsections: original.ingredientSubsections,
instructionSubsections: original.instructionSubsections,
tags: original.tags,
notes: original.notes,
source: original.source,
numServings: original.numServings,
isIngredient: original.isIngredient,
owner: context.getUser(),
originalRecipe: original._id,
titleIdentifier: generateRecipeIdentifier(original.title),
createdAt: new Date(),
lastModified: new Date(),
});
await veganRecipe.save();

// Copy images
const images = await ImageTC.mongooseResolvers.findMany().resolve({
args: { filter: { recipe: original._id } },
});
if (images && images.length > 0) {
await Promise.all(
images.map((img) => copyImageForRecipe(img, veganRecipe._id))
);
}

// Link original → vegan
await Recipe.findByIdAndUpdate(originalId, {
veganVersion: veganRecipe._id,
});

// Trigger pre-save on original to recompute calculatedTags
const updatedOriginal = await Recipe.findById(originalId);
if (updatedOriginal) await updatedOriginal.save();

return { record: veganRecipe, recordId: veganRecipe._id };
},
})
.wrapResolve((next) => async (rp) => {
// Validate the original recipe exists and belongs to the user
const original = await Recipe.findById(rp.args.originalId);
if (!original) throw new Error('Original recipe not found');
if (String(original.owner) !== String(rp.context.getUser())) {
throw new Error('Not authorized to create a vegan version of this recipe');
}
if (original.veganVersion) {
throw new Error('This recipe already has a vegan version');
}
if (original.originalRecipe) {
throw new Error('Cannot create a vegan version of a vegan copy');
}
return next(rp);
}),
recipeArchiveById: RecipeModifyTC.getResolver('archiveById').wrapResolve(
(next) => async (rp) => {
await validateItemNotInRecipe(rp.args._id, 'recipe');
Expand Down
1 change: 1 addition & 0 deletions api/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const isAuthenticatedMutations = composeResolvers(
{
Mutation: {
recipeCreateOne: RecipeMutation.recipeCreateOne,
recipeMakeVegan: RecipeMutation.recipeMakeVegan,
ratingCreateOne: RatingMutation.ratingCreateOne,
sizeCreateOne: SizeMutation.sizeCreateOne,
unitCreateOne: UnitMutation.unitCreateOne,
Expand Down
29 changes: 29 additions & 0 deletions api/src/utils/image.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import fs from 'fs';
import path from 'path';
import { promises as fsp } from 'fs';
import { ReadStream, createReadStream } from 'fs';

import sharp from 'sharp';
import { Sharp } from 'sharp';
import { nanoid } from 'nanoid';
import { Types } from 'mongoose';

import { IMAGE_DIR } from '../constants.js';
import { Image, Image as ImageType } from '../models/Image.js';

interface ImageLoader {
stream: Sharp | ReadStream;
Expand Down Expand Up @@ -91,3 +97,26 @@ function getContentType(fileName: string): string {
return 'application/octet-stream';
}
}

/**
* Copies an image file on disk and creates a new Image document for newRecipeId.
* Returns the saved Image document.
*/
export async function copyImageForRecipe(
sourceImage: ImageType,
newRecipeId: Types.ObjectId
): Promise<ImageType> {
const srcFile = path.join(IMAGE_DIR, path.basename(sourceImage.origUrl));
const ext = path.extname(srcFile);
const newFilename = `${nanoid()}${ext}`;
const destFile = path.join(IMAGE_DIR, newFilename);

await fsp.copyFile(srcFile, destFile);

const newImage = new Image({
origUrl: path.join('uploads/images', newFilename),
recipe: newRecipeId,
note: sourceImage.note,
});
return newImage.save();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useShallow } from 'zustand/shallow';
import { Checkbox, Group, Text } from '@mantine/core';

import { useRecipeStore } from '@recipe/stores';

export function CreateVeganVersionCheckbox() {
const { createVeganVersion, setCreateVeganVersion } = useRecipeStore(
useShallow((state) => ({
createVeganVersion: state.createVeganVersion,
setCreateVeganVersion: state.setCreateVeganVersion,
}))
);

return (
<Group>
<Checkbox
checked={createVeganVersion}
onChange={(e) => setCreateVeganVersion(e.currentTarget.checked)}
aria-label='Create vegan version of this recipe'
/>
<Text fw={500} c={createVeganVersion ? undefined : 'dimmed'}>
Create vegan version
</Text>
</Group>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import { Flex, Spacer } from '@chakra-ui/react';

import { EditableSource } from './EditableSource';
import { AsIngredientCheckbox } from './AsIngredientCheckbox';
import { CreateVeganVersionCheckbox } from './CreateVeganVersionCheckbox';
import { EditableInstructionSubsections } from './EditableInstructionSubsections';

export function EditableInstructionsTab() {
interface Props {
isVeganCopy?: boolean;
}
export function EditableInstructionsTab({ isVeganCopy }: Props) {
return (
<Flex direction='column' justifyContent='space-between' height='100%'>
<EditableInstructionSubsections />
<Spacer />
<Flex direction={{ base: 'column', md: 'row' }} justifyContent='space-between'>
<AsIngredientCheckbox />
{!isVeganCopy && <CreateVeganVersionCheckbox />}
<EditableSource />
</Flex>
</Flex>
Expand Down
7 changes: 4 additions & 3 deletions client/src/features/editing/components/EditableRecipe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ interface Props {
addRating: (rating: number) => void;
handleSubmitMutation: (recipe: CreateOneRecipeCreateInput) => void;
submitButtonProps: SubmitButtonProps;
isVeganCopy?: boolean;
}
export function EditableRecipe(props: Props) {
const { rating, addRating, handleSubmitMutation, submitButtonProps } = props;
const { rating, addRating, handleSubmitMutation, submitButtonProps, isVeganCopy } = props;
const { isVerified } = useUser();

const isMobile = useBreakpointValue({ base: true, md: false });
Expand Down Expand Up @@ -68,7 +69,7 @@ export function EditableRecipe(props: Props) {
alignItems='center'
display='flex'
>
<EditableTitle />
<EditableTitle isReadOnly={isVeganCopy} />
</GridItem>
<GridItem
area='tags'
Expand Down Expand Up @@ -102,7 +103,7 @@ export function EditableRecipe(props: Props) {
/>
</GridItem>
<GridItem boxShadow='lg' padding='6' area='instructions' minH='420px'>
<EditableInstructionsTab />
<EditableInstructionsTab isVeganCopy={isVeganCopy} />
</GridItem>
<GridItem
boxShadow='lg'
Expand Down
6 changes: 5 additions & 1 deletion client/src/features/editing/components/EditableTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useShallow } from 'zustand/shallow';
import { useRecipeStore } from '@recipe/stores';
import { CentredTextArea } from '@recipe/common/components';

export function EditableTitle() {
interface Props {
isReadOnly?: boolean;
}
export function EditableTitle({ isReadOnly }: Props) {
const { title, setTitle } = useRecipeStore(
useShallow((state) => ({
title: state.title,
Expand All @@ -19,6 +22,7 @@ export function EditableTitle() {
placeholderColor='gray.400'
aria-label='Enter recipe title'
fontWeight={600}
isReadOnly={isReadOnly}
/>
);
}
1 change: 1 addition & 0 deletions client/src/features/editing/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { updateRecipeCache } from './utils/update';
export { EditableRecipe } from './components/EditableRecipe';
export { ConfirmArchiveModal } from './components/ConfirmArchiveModal';
export { CreateVeganVersionCheckbox } from './components/CreateVeganVersionCheckbox';
32 changes: 32 additions & 0 deletions client/src/features/viewing/components/ModifyButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GoArchive } from 'react-icons/go';
import { EditIcon } from '@chakra-ui/icons';
import { useMutation } from '@apollo/client';
import { RiInboxUnarchiveLine } from 'react-icons/ri';
import { ActionIcon, Tooltip as MantineTooltip } from '@mantine/core';
import { Box, Flex, IconButton, Spacer, Tooltip } from '@chakra-ui/react';

import { PATH } from '@recipe/constants';
Expand Down Expand Up @@ -81,6 +82,37 @@ export function ModifyButtons(props: Props) {
</Tooltip>
</Box>
</Box>
{recipe.veganVersion && (
<Box zIndex={1}>
<Box position='absolute'>
<MantineTooltip
label={`View vegan version of ${recipe.title}`}
openDelay={500}
>
<ActionIcon
variant='filled'
color='teal'
radius='xl'
aria-label={`View vegan version of ${recipe.title}`}
component={Link}
to={`${PATH.ROOT}/view/recipe/${recipe.veganVersion.titleIdentifier}`}
style={{
opacity: isHovering ? 1 : 0,
transform: `translate(-50%, -50%) scale(${isHovering ? 1 : 0})`,
transition: 'opacity 0.3s, transform 0.3s',
}}
>
<svg viewBox='0 0 24 24' width='16' height='16'>
<path
d='M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66L7 19c4-1 7-3 9-7 0 3-1 5-3 7l1 2c2-4 4-8 4-14'
fill='currentColor'
/>
</svg>
</ActionIcon>
</MantineTooltip>
</Box>
</Box>
)}
<Spacer />
<Box zIndex={1}>
<Box position='absolute'>
Expand Down
Loading