From 327ad22217ff522a06752c46134d2f421f612255 Mon Sep 17 00:00:00 2001 From: Mari Zhevagin Date: Mon, 19 Oct 2026 22:02:04 +0200 Subject: [PATCH 1/3] =?UTF-8?q?gj=C3=B8r=20det=20mulig=20=C3=A5=20lagre=20?= =?UTF-8?q?utkast=20av=20oppskrift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/auth/session.ts | 11 ++ server/src/index.ts | 1 + server/src/schema/resolvers/recipe.ts | 9 +- server/src/schema/resolvers/user.ts | 145 +++++++++++++++++++++++- server/src/schema/typeDefs/user.graphql | 22 ++++ 5 files changed, 184 insertions(+), 4 deletions(-) diff --git a/server/src/auth/session.ts b/server/src/auth/session.ts index 5fc8925..3fb7619 100644 --- a/server/src/auth/session.ts +++ b/server/src/auth/session.ts @@ -1,5 +1,6 @@ import jwt from 'jsonwebtoken'; import type { Request, Response } from 'express'; +import { GraphQLError } from 'graphql'; // navnet på cookien som holder innloggingen const COOKIE_NAME = 'token'; @@ -57,3 +58,13 @@ export function getSession(req: Request): Session | null { return null; } } + +// stopper forspørselen hvis ingen er innloget +export function requireUser(context: Context): string { + if (!context.userId) { + throw new GraphQLError('Du må være logget inn.', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + return context.userId; +} diff --git a/server/src/index.ts b/server/src/index.ts index f9799c0..8594d1c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -19,6 +19,7 @@ const resolvers = { Query: { ...recipeResolvers.Query, ...userResolvers.Query }, Mutation: { ...recipeResolvers.Mutation, ...userResolvers.Mutation }, User: userResolvers.User, + Recipe: userResolvers.Recipe, }; // for å teste med Apollo Sandbox diff --git a/server/src/schema/resolvers/recipe.ts b/server/src/schema/resolvers/recipe.ts index 93e62ae..3872b2a 100644 --- a/server/src/schema/resolvers/recipe.ts +++ b/server/src/schema/resolvers/recipe.ts @@ -34,11 +34,16 @@ export const recipeResolvers = { return { items, totalCount }; }, - recipe: async (_: unknown, args: { id: string }) => { - return prisma.recipe.findUnique({ + recipe: async (_: unknown, args: { id: string }, context: Context) => { + const recipe = await prisma.recipe.findUnique({ where: { id: args.id }, include: { ingredients: true, steps: true, tags: true }, }); + // utkast vises bare for den som har laget det + if (recipe?.status === 'DRAFT' && recipe.authorId !== context.userId) { + return null; + } + return recipe; }, }, Mutation: { diff --git a/server/src/schema/resolvers/user.ts b/server/src/schema/resolvers/user.ts index c67d250..b508a2c 100644 --- a/server/src/schema/resolvers/user.ts +++ b/server/src/schema/resolvers/user.ts @@ -4,11 +4,11 @@ import { prisma } from '../../db/client'; import { clearLoginCookie, setLoginCookie, + requireUser, type Context, } from '../../auth/session'; import { randomUUID } from 'crypto'; -// samme krav som i frontend const MIN_PASSWORD_LENGTH = 6; type Credentials = { username: string; password: string }; @@ -23,6 +23,78 @@ type Preferences = { const LANGUAGES = ['nb', 'en']; const UNIT_SYSTEMS = ['metric', 'imperial']; +type RecipeInput = { + title: string; + description?: string | null; + prepTimeMinutes?: number | null; + servings?: number | null; + category?: string | null; + imageUrl?: string | null; + ingredients?: + { name: string; amount: number; unit: string; order: number }[] | null; + steps?: { order: number; instruction: string }[] | null; + tagNames?: string[] | null; +}; + +// det som hentes sammen med en oppskrift +const RECIPE_INCLUDE = { ingredients: true, steps: true, tags: true }; + +// fyller inn midlertidige verdier for det som mangler i et utkast +function recipeFields(input: RecipeInput) { + return { + title: input.title.trim(), + description: input.description ?? '', + prepTimeMinutes: input.prepTimeMinutes ?? 0, + servings: input.servings ?? 1, + category: input.category ?? '', + imageUrl: input.imageUrl ?? null, + }; +} + +// lager taggene hvis de ikke finnes fra før +function tagsFrom(input: RecipeInput) { + return (input.tagNames ?? []).map((name) => ({ + where: { name }, + create: { name }, + })); +} + +// henter oppskriften og stopper hvis den ikke finnes eller tilhører en annen +async function findOwnRecipe(id: string, userId: string) { + const recipe = await prisma.recipe.findUnique({ where: { id } }); + if (!recipe || recipe.authorId !== userId) { + throw new GraphQLError('Fant ikke oppskriften eller du eier den ikke.', { + extensions: { code: 'FORBIDDEN' }, + }); + } + return recipe; +} + +// bytter ut alt innhold i en oppskrift +async function replaceRecipe( + id: string, + input: RecipeInput, + status: 'DRAFT' | 'PUBLISHED', +) { + const [, updated] = await prisma.$transaction([ + // fjerner de gamle tagger først + prisma.recipe.update({ where: { id }, data: { tags: { set: [] } } }), + prisma.recipe.update({ + where: { id }, + data: { + ...recipeFields(input), + status, + // sletter gamle ingredienser og steg og så lager de nye + ingredients: { deleteMany: {}, create: input.ingredients ?? [] }, + steps: { deleteMany: {}, create: input.steps ?? [] }, + tags: { connectOrCreate: tagsFrom(input) }, + }, + include: RECIPE_INCLUDE, + }), + ]); + return updated; +} + export const userResolvers = { Query: { // innlogget bruker og null hvis ingen er logget inn @@ -34,7 +106,7 @@ export const userResolvers = { myRecipes: async (_: unknown, __: unknown, context: Context) => { if (!context.userId) return []; return prisma.recipe.findMany({ - where: { authorId: context.userId }, + where: { authorId: context.userId, status: 'PUBLISHED' }, orderBy: { createdAt: 'desc' }, include: { ingredients: true, steps: true, tags: true }, }); @@ -51,6 +123,15 @@ export const userResolvers = { }); return favorites.map((favorite) => favorite.recipe); }, + // utkastene til innlogget bruker med det nyeste først + myDrafts: async (_: unknown, __: unknown, context: Context) => { + if (!context.userId) return []; + return prisma.recipe.findMany({ + where: { authorId: context.userId, status: 'DRAFT' }, + orderBy: { createdAt: 'desc' }, + include: RECIPE_INCLUDE, + }); + }, }, Mutation: { @@ -308,6 +389,61 @@ export const userResolvers = { return result.count; }, + // lager nytt utkast uten id og oppdaterer utkastet med id + saveDraft: async ( + _: unknown, + args: { id?: string | null; input: RecipeInput }, + context: Context, + ) => { + const userId = requireUser(context); + + if (!args.input.title.trim()) { + throw new GraphQLError('Utkastet må ha en tittel.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + if (args.id) { + const recipe = await findOwnRecipe(args.id, userId); + // en publisert oppskrift skal ikke bli utkast igjen + if (recipe.status !== 'DRAFT') { + throw new GraphQLError('Oppskriften er allerede publisert.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + return replaceRecipe(args.id, args.input, 'DRAFT'); + } + + return prisma.recipe.create({ + data: { + ...recipeFields(args.input), + status: 'DRAFT', + authorId: userId, + ingredients: { create: args.input.ingredients ?? [] }, + steps: { create: args.input.steps ?? [] }, + tags: { connectOrCreate: tagsFrom(args.input) }, + }, + include: RECIPE_INCLUDE, + }); + }, + + // lagrer endringer på egen oppskrift eller utkast og aå publiserer den + updateRecipe: async ( + _: unknown, + args: { id: string; input: RecipeInput }, + context: Context, + ) => { + const userId = requireUser(context); + await findOwnRecipe(args.id, userId); + + if (!args.input.title.trim()) { + throw new GraphQLError('Oppskriften må ha en tittel.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + return replaceRecipe(args.id, args.input, 'PUBLISHED'); + }, }, User: { @@ -319,4 +455,9 @@ export const userResolvers = { return allergens.map((allergen) => allergen.name); }, }, + Recipe: { + // sant hvis innlogget bruker har laget oppskriften + isMine: (recipe: { authorId: string }, _: unknown, context: Context) => + recipe.authorId === context.userId, + }, }; diff --git a/server/src/schema/typeDefs/user.graphql b/server/src/schema/typeDefs/user.graphql index 96cde7e..4fe936e 100644 --- a/server/src/schema/typeDefs/user.graphql +++ b/server/src/schema/typeDefs/user.graphql @@ -1,3 +1,19 @@ +# krever bare tittel sånn at man kan lagre en havlferdig oppskrift +input DraftRecipeInput { + title: String! + description: String + prepTimeMinutes: Int + servings: Int + category: String + imageUrl: String + ingredients: [RecipeIngredientInput!] + steps: [StepInput!] + tagNames: [String!] +} + +# sier om innlogget bruker har laget oppskriften +extend type Recipe {isMine: Boolean!} + type User { id: ID! username: String! @@ -15,6 +31,8 @@ extend type Query { myRecipes: [Recipe!]! # oppskriftene innloggedet bruker har som favoritter myFavorites: [Recipe!]! + # utkast til innlogget bruker + myDrafts: [Recipe!]! } extend type Mutation { @@ -34,4 +52,8 @@ extend type Mutation { toggleFavorite(recipeId: ID!): Boolean! # sletter en oppskrift brukeren selv har laget deleteMyRecipes(ids: [ID!]!): Int! + # lagrer nytt utkast + saveDraft(id: ID, input: DraftRecipeInput!): Recipe! + # lagrer endring på egen oppskrift og publiserer den + updateRecipe(id: ID!, input: CreateRecipeInput!): Recipe! } From 74c340018eb876e320057f9d6883493327548a0e Mon Sep 17 00:00:00 2001 From: Mari Zhevagin Date: Mon, 19 Oct 2026 22:11:11 +0200 Subject: [PATCH 2/3] =?UTF-8?q?sm=C3=A5fiks=20av=20min=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/DeleteRecipesDialog.tsx | 4 +- client/src/graphql/profile.ts | 13 ++++++ client/src/routes/ProfilePage.tsx | 43 +++++++++++-------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/client/src/components/profile/DeleteRecipesDialog.tsx b/client/src/components/profile/DeleteRecipesDialog.tsx index 7acf6e9..cd35249 100644 --- a/client/src/components/profile/DeleteRecipesDialog.tsx +++ b/client/src/components/profile/DeleteRecipesDialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import * as RadixDialog from '@radix-ui/react-dialog'; import { useMutation } from '@apollo/client/react'; import { Button } from '../ui/Button'; -import { DELETE_MY_RECIPES, MY_RECIPES } from '../../graphql/profile'; +import { DELETE_MY_RECIPES, MY_RECIPES, MY_DRAFTS } from '../../graphql/profile'; type DeleteRecipesDialogProps = { recipes: { id: string; title: string }[]; @@ -16,7 +16,7 @@ export function DeleteRecipesDialog({ }: DeleteRecipesDialogProps) { const [open, setOpen] = useState(false); const [deleteRecipes, { loading, error }] = useMutation(DELETE_MY_RECIPES, { - refetchQueries: [MY_RECIPES], + refetchQueries: [MY_RECIPES, MY_DRAFTS], }); const count = recipes.length; diff --git a/client/src/graphql/profile.ts b/client/src/graphql/profile.ts index f7bbfa6..0f2c56b 100644 --- a/client/src/graphql/profile.ts +++ b/client/src/graphql/profile.ts @@ -14,6 +14,19 @@ export const MY_RECIPES: TypedDocumentNode<{ myRecipes: Recipe[] }> = gql` } `; +// utkastene innlogget bruker har lagret +export const MY_DRAFTS: TypedDocumentNode<{ myDrafts: Recipe[] }> = gql` + query MyDrafts { + myDrafts { + id + imageUrl + title + prepTimeMinutes + servings + } + } +`; + // sletter oppskrifter brukeren selv har laget export const DELETE_MY_RECIPES: TypedDocumentNode< { deleteMyRecipes: number }, diff --git a/client/src/routes/ProfilePage.tsx b/client/src/routes/ProfilePage.tsx index ccaf320..0482558 100644 --- a/client/src/routes/ProfilePage.tsx +++ b/client/src/routes/ProfilePage.tsx @@ -9,34 +9,31 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; import { useCurrentUser } from '../lib/useCurrentUser'; import { useQuery } from '@apollo/client/react'; -import { MY_RECIPES } from '../graphql/profile'; +import { MY_RECIPES, MY_DRAFTS } from '../graphql/profile'; import { RecipeCard } from '../components/recipe/RecipeCard'; import { MY_FAVORITES } from '../graphql/favorites'; import { AnimatePresence, motion } from 'motion/react'; import { fadeUp, quick, glide } from '../lib/motion'; -type Tab = 'preferences' | 'favorites' | 'shared' | 'sharedWithMe'; +type Tab = 'preferences' | 'shared' | 'drafts' | 'favorites'; type RecipeTab = Exclude; -// tekstene for hver oppskriftsfane (tomme til backend er koblet på) +// tekstene for hver oppskriftsfane const RECIPE_TABS: Record = { - favorites: { label: 'Favoritter', empty: 'Du har ingen favoritter ennå.' }, shared: { label: 'Mine oppskrifter', empty: 'Du har ikke laget noen oppskrifter ennå.', }, - sharedWithMe: { - label: 'Delt med meg', - empty: 'Ingen har delt oppskrifter med deg ennå.', - }, + drafts: { label: 'Utkast', empty: 'Du har ingen utkast.' }, + favorites: { label: 'Favoritter', empty: 'Du har ingen favoritter ennå.' }, }; // pillene i mobilmenyen const MOBILE_TABS: { value: Tab; label: string }[] = [ { value: 'preferences', label: 'Preferanser' }, - { value: 'favorites', label: 'Favoritter' }, { value: 'shared', label: 'Mine oppskrifter' }, - { value: 'sharedWithMe', label: 'Delt med meg' }, + { value: 'drafts', label: 'Utkast' }, + { value: 'favorites', label: 'Favoritter' }, ]; // rosa fokusring @@ -59,6 +56,10 @@ export default function ProfilePage() { const { data: favoritesData } = useQuery(MY_FAVORITES, { fetchPolicy: 'cache-and-network', }); + // utkastene til brukeren + const { data: draftsData } = useQuery(MY_DRAFTS, { + fetchPolicy: 'cache-and-network', + }); // RequireAuth sørger for at user finnes if (!user) return null; @@ -66,17 +67,21 @@ export default function ProfilePage() { const isGuest = user.authProvider === 'GUEST'; const myRecipes = myRecipesData?.myRecipes ?? []; - // på desktop vises preferanser alltid til venstre, så høyre side viser favoritter i stedet + // på desktop vises preferanser alltid til venstre så høyre side viser mine oppskrifter i stedet const desktopTab: RecipeTab = - activeTab === 'preferences' ? 'favorites' : activeTab; + activeTab === 'preferences' ? 'shared' : activeTab; const favorites = favoritesData?.myFavorites ?? []; + const drafts = draftsData?.myDrafts ?? []; const tabRecipes = desktopTab === 'shared' ? myRecipes - : desktopTab === 'favorites' - ? favorites - : []; + : desktopTab === 'drafts' + ? drafts + : favorites; + + // egne oppskrifter og utkast kan slettes + const canSelect = desktopTab === 'shared' || desktopTab === 'drafts'; async function handleLogout() { await logout(); @@ -127,14 +132,14 @@ export default function ProfilePage() { med deg.

- {/* TODO: hent ekte tall fra backend */} -
    + {/* TODO: ha en fane for delte oppskrifter hvis det blir aktuelt*/} +
      {[ { count: myRecipes.length, label: 'mine oppskrifter' }, + { count: drafts.length, label: 'utkast' }, { count: favorites.length, label: 'favoritter' }, - { count: 0, label: 'delte' }, - { count: 0, label: 'delt med deg' }, ].map((stat) => ( +
    • {stat.count} From faef0a8c35b36b1cc36f0107084f6e0b00c996b6 Mon Sep 17 00:00:00 2001 From: Mari Zhevagin Date: Mon, 19 Oct 2026 22:28:33 +0200 Subject: [PATCH 3/3] =?UTF-8?q?utkast=20har=20en=20plass=20p=C3=A5=20min?= =?UTF-8?q?=20side=20n=C3=A5=20og=20sm=C3=A5=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/profile/DeleteRecipesDialog.tsx | 6 +++++- client/src/routes/LoginPage.tsx | 6 ++---- client/src/routes/ProfilePage.tsx | 14 ++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/client/src/components/profile/DeleteRecipesDialog.tsx b/client/src/components/profile/DeleteRecipesDialog.tsx index cd35249..574f879 100644 --- a/client/src/components/profile/DeleteRecipesDialog.tsx +++ b/client/src/components/profile/DeleteRecipesDialog.tsx @@ -2,7 +2,11 @@ import { useState } from 'react'; import * as RadixDialog from '@radix-ui/react-dialog'; import { useMutation } from '@apollo/client/react'; import { Button } from '../ui/Button'; -import { DELETE_MY_RECIPES, MY_RECIPES, MY_DRAFTS } from '../../graphql/profile'; +import { + DELETE_MY_RECIPES, + MY_RECIPES, + MY_DRAFTS, +} from '../../graphql/profile'; type DeleteRecipesDialogProps = { recipes: { id: string; title: string }[]; diff --git a/client/src/routes/LoginPage.tsx b/client/src/routes/LoginPage.tsx index 8b82391..6805cdb 100644 --- a/client/src/routes/LoginPage.tsx +++ b/client/src/routes/LoginPage.tsx @@ -13,9 +13,7 @@ export default function LoginPage() { const { continueAsGuest } = useCurrentUser(); // Midlertidig. token skal sendes til backend senere - function handleGoogleSuccess(response: CredentialResponse) { - console.log('Google-token:', response.credential); - } + function handleGoogleSuccess(_response: CredentialResponse) {} // logger inn som gjest og går til forside async function handleGuest() { @@ -57,7 +55,7 @@ export default function LoginPage() {
      console.log('Google innlogging feilet')} + onError={() => {}} text="signin_with" size="large" width={googleButtonWidth} diff --git a/client/src/routes/ProfilePage.tsx b/client/src/routes/ProfilePage.tsx index 0482558..047d777 100644 --- a/client/src/routes/ProfilePage.tsx +++ b/client/src/routes/ProfilePage.tsx @@ -56,7 +56,7 @@ export default function ProfilePage() { const { data: favoritesData } = useQuery(MY_FAVORITES, { fetchPolicy: 'cache-and-network', }); - // utkastene til brukeren + // utkastene til brukeren const { data: draftsData } = useQuery(MY_DRAFTS, { fetchPolicy: 'cache-and-network', }); @@ -67,7 +67,7 @@ export default function ProfilePage() { const isGuest = user.authProvider === 'GUEST'; const myRecipes = myRecipesData?.myRecipes ?? []; - // på desktop vises preferanser alltid til venstre så høyre side viser mine oppskrifter i stedet + // på desktop vises preferanser alltid til venstre så høyre side viser mine oppskrifter i stedet const desktopTab: RecipeTab = activeTab === 'preferences' ? 'shared' : activeTab; @@ -88,8 +88,7 @@ export default function ProfilePage() { navigate('/login'); } - // valg vises bare under mine oppskrifter - const isSelecting = selecting && desktopTab === 'shared'; + const isSelecting = selecting && canSelect; // slår valg av eller på og tømmer det som var valgt function toggleSelecting() { @@ -133,13 +132,12 @@ export default function ProfilePage() {

      {/* TODO: ha en fane for delte oppskrifter hvis det blir aktuelt*/} -
        +
          {[ { count: myRecipes.length, label: 'mine oppskrifter' }, { count: drafts.length, label: 'utkast' }, { count: favorites.length, label: 'favoritter' }, ].map((stat) => ( -
        • {stat.count} @@ -275,7 +273,7 @@ export default function ProfilePage() {
      {/* søppelbøtte som slår på valg av egne oppskrifter */} - {desktopTab === 'shared' && myRecipes.length > 0 && ( + {canSelect && tabRecipes.length > 0 && (
      {/* teksten og slett knappen vokser ut fra søppelbøtta */} @@ -292,7 +290,7 @@ export default function ProfilePage() { {selectedIds.length} valgt + recipes={tabRecipes.filter((recipe) => selectedIds.includes(recipe.id), )} onDeleted={toggleSelecting}