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}