diff --git a/apps/kitchen_mate/alembic/versions/b1c2d3e4f5a6_add_users_table.py b/apps/kitchen_mate/alembic/versions/b1c2d3e4f5a6_add_users_table.py new file mode 100644 index 0000000..8cf4cde --- /dev/null +++ b/apps/kitchen_mate/alembic/versions/b1c2d3e4f5a6_add_users_table.py @@ -0,0 +1,34 @@ +"""Add users table for persisting user records from JWT claims + +Revision ID: b1c2d3e4f5a6 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-18 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b1c2d3e4f5a6" +down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("email", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index("idx_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_index("idx_users_email", table_name="users") + op.drop_table("users") diff --git a/apps/kitchen_mate/alembic/versions/c2d3e4f5a6b7_add_recipe_shares_table.py b/apps/kitchen_mate/alembic/versions/c2d3e4f5a6b7_add_recipe_shares_table.py new file mode 100644 index 0000000..801cd9d --- /dev/null +++ b/apps/kitchen_mate/alembic/versions/c2d3e4f5a6b7_add_recipe_shares_table.py @@ -0,0 +1,40 @@ +"""Add recipe_shares table for public link sharing + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-18 00:00:01.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c2d3e4f5a6b7" +down_revision: Union[str, Sequence[str], None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "recipe_shares", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("user_recipe_id", sa.String(36), nullable=False), + sa.Column("share_token", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_recipe_id"], ["user_recipes.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("share_token"), + ) + op.create_index("idx_recipe_shares_share_token", "recipe_shares", ["share_token"]) + op.create_index( + "idx_recipe_shares_user_recipe_id", "recipe_shares", ["user_recipe_id"] + ) + + +def downgrade() -> None: + op.drop_index("idx_recipe_shares_user_recipe_id", table_name="recipe_shares") + op.drop_index("idx_recipe_shares_share_token", table_name="recipe_shares") + op.drop_table("recipe_shares") diff --git a/apps/kitchen_mate/alembic/versions/d3e4f5a6b7c8_add_kitchen_tables.py b/apps/kitchen_mate/alembic/versions/d3e4f5a6b7c8_add_kitchen_tables.py new file mode 100644 index 0000000..7f81937 --- /dev/null +++ b/apps/kitchen_mate/alembic/versions/d3e4f5a6b7c8_add_kitchen_tables.py @@ -0,0 +1,95 @@ +"""Add kitchen tables for group recipe sharing + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-04-18 00:00:02.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, Sequence[str], None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "kitchens", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("created_by", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_kitchens_created_by", "kitchens", ["created_by"]) + + op.create_table( + "kitchen_members", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("kitchen_id", sa.String(36), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("joined_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["kitchen_id"], ["kitchens.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_kitchen_member", "kitchen_members", ["kitchen_id", "user_id"], unique=True + ) + op.create_index("idx_kitchen_members_user_id", "kitchen_members", ["user_id"]) + + op.create_table( + "kitchen_invites", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("kitchen_id", sa.String(36), nullable=False), + sa.Column("invited_email", sa.Text(), nullable=False), + sa.Column("invited_by", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["kitchen_id"], ["kitchens.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_kitchen_invite", "kitchen_invites", ["kitchen_id", "invited_email"], unique=True + ) + op.create_index("idx_kitchen_invites_email", "kitchen_invites", ["invited_email"]) + + op.create_table( + "kitchen_recipes", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("kitchen_id", sa.String(36), nullable=False), + sa.Column("user_recipe_id", sa.String(36), nullable=False), + sa.Column("shared_by", sa.Text(), nullable=False), + sa.Column("shared_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["kitchen_id"], ["kitchens.id"]), + sa.ForeignKeyConstraint(["user_recipe_id"], ["user_recipes.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_kitchen_recipe", + "kitchen_recipes", + ["kitchen_id", "user_recipe_id"], + unique=True, + ) + op.create_index("idx_kitchen_recipes_kitchen_id", "kitchen_recipes", ["kitchen_id"]) + + +def downgrade() -> None: + op.drop_index("idx_kitchen_recipes_kitchen_id", table_name="kitchen_recipes") + op.drop_index("uq_kitchen_recipe", table_name="kitchen_recipes") + op.drop_table("kitchen_recipes") + + op.drop_index("idx_kitchen_invites_email", table_name="kitchen_invites") + op.drop_index("uq_kitchen_invite", table_name="kitchen_invites") + op.drop_table("kitchen_invites") + + op.drop_index("idx_kitchen_members_user_id", table_name="kitchen_members") + op.drop_index("uq_kitchen_member", table_name="kitchen_members") + op.drop_table("kitchen_members") + + op.drop_index("idx_kitchens_created_by", table_name="kitchens") + op.drop_table("kitchens") diff --git a/apps/kitchen_mate/frontend/src/App.tsx b/apps/kitchen_mate/frontend/src/App.tsx index 821ecdb..739e111 100644 --- a/apps/kitchen_mate/frontend/src/App.tsx +++ b/apps/kitchen_mate/frontend/src/App.tsx @@ -8,6 +8,9 @@ import { AddFromUrlPage } from "./components/AddFromUrlPage"; import { AddFromUploadPage } from "./components/AddFromUploadPage"; import { AddManualPage } from "./components/AddManualPage"; import { SavedRecipeView } from "./components/SavedRecipeView"; +import { SharedRecipePage } from "./components/SharedRecipePage"; +import { KitchensPage } from "./components/KitchensPage"; +import { KitchenDetailPage } from "./components/KitchenDetailPage"; import { AddRecipeDropdown } from "./components/AddRecipeDropdown"; import { ViewModeToggle } from "./components/ViewModeToggle"; import { useRequireAuth } from "./hooks/useRequireAuth"; @@ -202,6 +205,9 @@ function AppContent() { } /> } /> } /> + } /> + } /> + } /> {/* Redirect old /clip route to new /add/url */} } /> diff --git a/apps/kitchen_mate/frontend/src/api/kitchens.ts b/apps/kitchen_mate/frontend/src/api/kitchens.ts new file mode 100644 index 0000000..15b7fd5 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/api/kitchens.ts @@ -0,0 +1,112 @@ +import type { + AddMemberResponse, + KitchenDetail, + KitchenRecipe, + KitchenSummary, + ListKitchenRecipesResponse, +} from "../types/kitchen"; + +export class KitchenError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = "KitchenError"; + this.statusCode = statusCode; + } +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new KitchenError(body.detail ?? res.statusText, res.status); + } + return res.json() as Promise; +} + +export async function listKitchens(): Promise { + const res = await fetch("/api/kitchens", { credentials: "include" }); + return handleResponse(res); +} + +export async function getKitchen(kitchenId: string): Promise { + const res = await fetch(`/api/kitchens/${encodeURIComponent(kitchenId)}`, { + credentials: "include", + }); + return handleResponse(res); +} + +export async function createKitchen(name: string): Promise { + const res = await fetch("/api/kitchens", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + return handleResponse(res); +} + +export async function addMember( + kitchenId: string, + email: string +): Promise { + const res = await fetch(`/api/kitchens/${encodeURIComponent(kitchenId)}/members`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + return handleResponse(res); +} + +export async function removeMember(kitchenId: string, userId: string): Promise { + const res = await fetch( + `/api/kitchens/${encodeURIComponent(kitchenId)}/members/${encodeURIComponent(userId)}`, + { method: "DELETE", credentials: "include" } + ); + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new KitchenError(body.detail ?? res.statusText, res.status); + } +} + +export async function shareRecipeToKitchen( + kitchenId: string, + userRecipeId: string +): Promise { + const res = await fetch(`/api/kitchens/${encodeURIComponent(kitchenId)}/recipes`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_recipe_id: userRecipeId }), + }); + return handleResponse(res); +} + +export async function listKitchenRecipes( + kitchenId: string, + cursor?: string, + limit = 50 +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + const res = await fetch( + `/api/kitchens/${encodeURIComponent(kitchenId)}/recipes?${params}`, + { credentials: "include" } + ); + return handleResponse(res); +} + +export async function removeKitchenRecipe( + kitchenId: string, + kitchenRecipeId: string +): Promise { + const res = await fetch( + `/api/kitchens/${encodeURIComponent(kitchenId)}/recipes/${encodeURIComponent(kitchenRecipeId)}`, + { method: "DELETE", credentials: "include" } + ); + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new KitchenError(body.detail ?? res.statusText, res.status); + } +} diff --git a/apps/kitchen_mate/frontend/src/api/sharing.ts b/apps/kitchen_mate/frontend/src/api/sharing.ts new file mode 100644 index 0000000..efa1c90 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/api/sharing.ts @@ -0,0 +1,55 @@ +import type { + CreateShareResponse, + SaveSharedRecipeResponse, + SharedRecipeResponse, +} from "../types/sharing"; + +export class ShareError extends Error { + statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.name = "ShareError"; + this.statusCode = statusCode; + } +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new ShareError(body.detail ?? res.statusText, res.status); + } + return res.json() as Promise; +} + +export async function createShare(recipeId: string): Promise { + const res = await fetch(`/api/me/recipes/${encodeURIComponent(recipeId)}/share`, { + method: "POST", + credentials: "include", + }); + return handleResponse(res); +} + +export async function revokeShare(recipeId: string): Promise { + const res = await fetch(`/api/me/recipes/${encodeURIComponent(recipeId)}/share`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok && res.status !== 204) { + const body = await res.json().catch(() => ({ detail: res.statusText })); + throw new ShareError(body.detail ?? res.statusText, res.status); + } +} + +export async function getSharedRecipe(token: string): Promise { + const res = await fetch(`/api/shared/${encodeURIComponent(token)}`); + return handleResponse(res); +} + +export async function saveSharedRecipe(token: string): Promise { + const res = await fetch(`/api/shared/${encodeURIComponent(token)}/save`, { + method: "POST", + credentials: "include", + }); + return handleResponse(res); +} diff --git a/apps/kitchen_mate/frontend/src/components/AddRecipeDropdown.tsx b/apps/kitchen_mate/frontend/src/components/AddRecipeDropdown.tsx index e85bd0b..f589fdd 100644 --- a/apps/kitchen_mate/frontend/src/components/AddRecipeDropdown.tsx +++ b/apps/kitchen_mate/frontend/src/components/AddRecipeDropdown.tsx @@ -15,7 +15,7 @@ const menuItems = [ export function AddRecipeDropdown({ variant = "button" }: AddRecipeDropdownProps) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isAuthorized, isPro } = useRequireAuth(); + const { isAuthorized, isPro, loading } = useRequireAuth(); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -62,7 +62,7 @@ export function AddRecipeDropdown({ variant = "button" }: AddRecipeDropdownProps > {menuItems.map((item, index) => { const needsAuth = item.requiresAuth && !isAuthorized; - const needsUpgrade = item.requiresPro && !isPro; + const needsUpgrade = item.requiresPro && !isPro && !loading; const isDisabled = needsAuth || needsUpgrade; const borderClass = index !== menuItems.length - 1 ? "border-b border-gray-100" : ""; diff --git a/apps/kitchen_mate/frontend/src/components/Header.tsx b/apps/kitchen_mate/frontend/src/components/Header.tsx index 5f1e982..b7fc778 100644 --- a/apps/kitchen_mate/frontend/src/components/Header.tsx +++ b/apps/kitchen_mate/frontend/src/components/Header.tsx @@ -3,6 +3,7 @@ import { useAuthContext } from "../hooks/useAuthContext"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { UserDropdown } from "./UserDropdown"; import { AddRecipeDropdown } from "./AddRecipeDropdown"; +import { KitchensDropdown } from "./KitchensDropdown"; interface HeaderProps { onSignInClick: () => void; @@ -61,17 +62,19 @@ export function Header({ onSignInClick }: HeaderProps) { to="/" className="text-sm font-medium text-brown-medium hover:text-coral transition-colors" > - My Recipes + Recipes ) : ( - My Recipes + Recipes )} + {isAuthEnabled && isAuthorized && } + diff --git a/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx b/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx new file mode 100644 index 0000000..6da1b16 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/KitchenDetailPage.tsx @@ -0,0 +1,203 @@ +import { useState, useEffect } from "react"; +import { useParams, Link } from "react-router-dom"; +import { + getKitchen, + listKitchenRecipes, + addMember, + removeMember, + removeKitchenRecipe, + KitchenError, +} from "../api/kitchens"; +import { useAuthContext } from "../hooks/useAuthContext"; +import { LoadingSpinner } from "./LoadingSpinner"; +import { KitchenRecipeListItem } from "./KitchenRecipeListItem"; +import type { KitchenDetail, KitchenRecipe } from "../types/kitchen"; + +export function KitchenDetailPage() { + const { id } = useParams<{ id: string }>(); + const { user } = useAuthContext(); + + const [kitchen, setKitchen] = useState(null); + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Member management + const [inviteEmail, setInviteEmail] = useState(""); + const [inviting, setInviting] = useState(false); + const [inviteMessage, setInviteMessage] = useState(null); + const [inviteError, setInviteError] = useState(null); + const [removingMember, setRemovingMember] = useState(null); + + // Recipe management + const [removingRecipe, setRemovingRecipe] = useState(null); + + useEffect(() => { + if (!id) return; + Promise.all([getKitchen(id), listKitchenRecipes(id)]) + .then(([k, r]) => { + setKitchen(k); + setRecipes(r.recipes); + }) + .catch((err) => setError(err instanceof KitchenError ? err.message : "Failed to load kitchen")) + .finally(() => setLoading(false)); + }, [id]); + + const isAdmin = kitchen?.members.find((m) => m.user_id === user?.id)?.role === "admin"; + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!id || !inviteEmail.trim()) return; + setInviting(true); + setInviteError(null); + setInviteMessage(null); + try { + const result = await addMember(id, inviteEmail.trim()); + setInviteMessage(result.message); + setInviteEmail(""); + if (result.added) { + const updated = await getKitchen(id); + if (updated) setKitchen(updated); + } + } catch (err) { + setInviteError(err instanceof KitchenError ? err.message : "Failed to add member"); + } finally { + setInviting(false); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!id) return; + setRemovingMember(userId); + try { + await removeMember(id, userId); + setKitchen((prev) => + prev ? { ...prev, members: prev.members.filter((m) => m.user_id !== userId) } : prev + ); + } catch (err) { + setError(err instanceof KitchenError ? err.message : "Failed to remove member"); + } finally { + setRemovingMember(null); + } + }; + + const handleRemoveRecipe = async (kitchenRecipeId: string) => { + if (!id) return; + setRemovingRecipe(kitchenRecipeId); + try { + await removeKitchenRecipe(id, kitchenRecipeId); + setRecipes((prev) => prev.filter((r) => r.id !== kitchenRecipeId)); + } catch (err) { + setError(err instanceof KitchenError ? err.message : "Failed to remove recipe"); + } finally { + setRemovingRecipe(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error && !kitchen) { + return ( +
+

{error}

+ Back to Kitchens +
+ ); + } + + if (!kitchen) return null; + + return ( +
+
+ + ← Kitchens + +
+ +

{kitchen.name}

+ + {error &&

{error}

} + + {/* Members section */} +
+

Members

+
    + {kitchen.members.map((member) => ( +
  • +
    + {member.email ?? member.user_id} + {member.role === "admin" && ( + + admin + + )} +
    + {isAdmin && member.user_id !== user?.id && ( + + )} +
  • + ))} +
+ + {isAdmin && ( +
+ setInviteEmail(e.target.value)} + placeholder="Add member by email" + className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-coral" + /> + +
+ )} + {inviteMessage &&

{inviteMessage}

} + {inviteError &&

{inviteError}

} +
+ + {/* Recipes section */} +
+

+ Shared Recipes ({recipes.length}) +

+ + {recipes.length === 0 ? ( +

+ No recipes shared yet. Share a recipe from your collection using the kitchen icon on any recipe. +

+ ) : ( +
+ {recipes.map((recipe) => ( + handleRemoveRecipe(recipe.id)} + removing={removingRecipe === recipe.id} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/apps/kitchen_mate/frontend/src/components/KitchenRecipeListItem.tsx b/apps/kitchen_mate/frontend/src/components/KitchenRecipeListItem.tsx new file mode 100644 index 0000000..3895c1c --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/KitchenRecipeListItem.tsx @@ -0,0 +1,88 @@ +import { Link } from "react-router-dom"; +import { formatTagForDisplay } from "../utils/tags"; +import type { KitchenRecipe } from "../types/kitchen"; + +interface KitchenRecipeListItemProps { + recipe: KitchenRecipe; + kitchenName: string; + onRemove?: () => void; + removing?: boolean; +} + +export function KitchenRecipeListItem({ recipe, kitchenName, onRemove, removing }: KitchenRecipeListItemProps) { + const tags = recipe.tags ?? []; + + return ( +
+ +
+ {recipe.image_url ? ( + {recipe.title} + ) : ( +
+ + + +
+ )} + + + {kitchenName} + + + {onRemove && ( + + )} +
+ + +
+ +

+ {recipe.title} +

+ + +
+ {tags.slice(0, 3).map((tag, index) => ( + + {formatTagForDisplay(tag)} + + ))} + {tags.length > 3 && ( + +{tags.length - 3} + )} +
+ +

+ {new Date(recipe.shared_at).toLocaleDateString()} +

+
+
+ ); +} diff --git a/apps/kitchen_mate/frontend/src/components/KitchensDropdown.tsx b/apps/kitchen_mate/frontend/src/components/KitchensDropdown.tsx new file mode 100644 index 0000000..4931bd9 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/KitchensDropdown.tsx @@ -0,0 +1,88 @@ +import { useState, useRef, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { listKitchens, KitchenError } from "../api/kitchens"; +import type { KitchenSummary } from "../types/kitchen"; + +export function KitchensDropdown() { + const [isOpen, setIsOpen] = useState(false); + const [kitchens, setKitchens] = useState([]); + const dropdownRef = useRef(null); + + useEffect(() => { + listKitchens().catch((err) => { + if (!(err instanceof KitchenError)) console.error(err); + }).then((result) => { + if (result) setKitchens(result); + }); + }, []); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + + {isOpen && ( +
+ {kitchens.length > 0 ? ( + <> + {kitchens.map((kitchen) => ( + setIsOpen(false)} + className="block px-4 py-2.5 text-sm text-brown-dark hover:bg-gray-50 hover:text-coral transition-colors" + > + {kitchen.name} + + ))} +
+ setIsOpen(false)} + className="block px-4 py-2 text-xs text-brown-medium hover:text-coral transition-colors" + > + Manage Kitchens + +
+ + ) : ( + <> +

No kitchens yet.

+
+ setIsOpen(false)} + className="block px-4 py-2 text-xs text-brown-medium hover:text-coral transition-colors" + > + Manage Kitchens + +
+ + )} +
+ )} +
+ ); +} diff --git a/apps/kitchen_mate/frontend/src/components/KitchensPage.tsx b/apps/kitchen_mate/frontend/src/components/KitchensPage.tsx new file mode 100644 index 0000000..3dc53a8 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/KitchensPage.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { listKitchens, createKitchen, KitchenError } from "../api/kitchens"; +import { useAuthContext } from "../hooks/useAuthContext"; +import { useRequireAuth } from "../hooks/useRequireAuth"; +import { LoadingSpinner } from "./LoadingSpinner"; +import type { KitchenSummary } from "../types/kitchen"; + +export function KitchensPage() { + const { isAuthEnabled } = useAuthContext(); + const { isAuthorized } = useRequireAuth(); + const [kitchens, setKitchens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + + useEffect(() => { + if (!isAuthEnabled || !isAuthorized) { + setLoading(false); + return; + } + listKitchens() + .then(setKitchens) + .catch((err) => setError(err instanceof KitchenError ? err.message : "Failed to load kitchens")) + .finally(() => setLoading(false)); + }, [isAuthEnabled, isAuthorized]); + + if (!isAuthEnabled) { + return ( +
+

Kitchens are only available in multi-user mode.

+
+ ); + } + + if (!isAuthorized) { + return ( +
+

Sign in to use Kitchens.

+
+ ); + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + setCreating(true); + try { + const kitchen = await createKitchen(newName.trim()); + setKitchens((prev) => [kitchen, ...prev]); + setNewName(""); + setShowCreate(false); + } catch (err) { + setError(err instanceof KitchenError ? err.message : "Failed to create kitchen"); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

My Kitchens

+ +
+ + {error &&

{error}

} + + {showCreate && ( +
+

New Kitchen

+
+ setNewName(e.target.value)} + placeholder="Kitchen name" + maxLength={100} + className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-coral" + autoFocus + /> + + +
+
+ )} + + {kitchens.length === 0 ? ( +
+

You don't belong to any kitchens yet.

+ {!showCreate && ( + + )} +
+ ) : ( +
+ {kitchens.map((kitchen) => ( + +
+

{kitchen.name}

+ + {kitchen.member_count} member{kitchen.member_count !== 1 ? "s" : ""} + +
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/kitchen_mate/frontend/src/components/SavedRecipeView.tsx b/apps/kitchen_mate/frontend/src/components/SavedRecipeView.tsx index 0c7604e..41725c8 100644 --- a/apps/kitchen_mate/frontend/src/components/SavedRecipeView.tsx +++ b/apps/kitchen_mate/frontend/src/components/SavedRecipeView.tsx @@ -12,6 +12,8 @@ import { import { RecipeEditor } from "./RecipeEditor"; import { ExportDropdown } from "./ExportDropdown"; import { LoadingSpinner } from "./LoadingSpinner"; +import { ShareButton } from "./ShareButton"; +import { ShareToKitchenButton } from "./ShareToKitchenButton"; export function SavedRecipeView() { const { id } = useParams<{ id: string }>(); @@ -282,6 +284,8 @@ export function SavedRecipeView() {
+ {id && } + {id && } + + {isOpen && ( +
e.target === e.currentTarget && setIsOpen(false)} + > +
+
+

Share Recipe

+ +
+ + {loading && ( +

Generating link...

+ )} + + {error && ( +

{error}

+ )} + + {!loading && share && ( + <> +

+ Anyone with this link can view the recipe without signing in. +

+
+ + +
+ + + )} +
+
+ )} + + ); +} diff --git a/apps/kitchen_mate/frontend/src/components/ShareToKitchenButton.tsx b/apps/kitchen_mate/frontend/src/components/ShareToKitchenButton.tsx new file mode 100644 index 0000000..ceb0638 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/ShareToKitchenButton.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { listKitchens, shareRecipeToKitchen, KitchenError } from "../api/kitchens"; +import { useAuthContext } from "../hooks/useAuthContext"; +import type { KitchenSummary } from "../types/kitchen"; + +interface ShareToKitchenButtonProps { + recipeId: string; +} + +export function ShareToKitchenButton({ recipeId }: ShareToKitchenButtonProps) { + const { isAuthEnabled } = useAuthContext(); + const [isOpen, setIsOpen] = useState(false); + const [kitchens, setKitchens] = useState([]); + const [loading, setLoading] = useState(false); + const [sharing, setSharing] = useState(null); + const [error, setError] = useState(null); + const [sharedKitchens, setSharedKitchens] = useState>(new Set()); + + if (!isAuthEnabled) return null; + + const handleOpen = async () => { + setIsOpen(true); + setLoading(true); + setError(null); + try { + const result = await listKitchens(); + setKitchens(result); + } catch (err) { + setError(err instanceof KitchenError ? err.message : "Failed to load kitchens"); + } finally { + setLoading(false); + } + }; + + const handleShare = async (kitchen: KitchenSummary) => { + setSharing(kitchen.id); + setError(null); + try { + await shareRecipeToKitchen(kitchen.id, recipeId); + setSharedKitchens((prev) => new Set(prev).add(kitchen.id)); + } catch (err) { + const msg = err instanceof KitchenError ? err.message : "Failed to share"; + setError(msg); + } finally { + setSharing(null); + } + }; + + return ( + <> + + + {isOpen && ( +
e.target === e.currentTarget && setIsOpen(false)} + > +
+
+

Share to Kitchen

+ +
+ + {error &&

{error}

} + + {loading &&

Loading kitchens...

} + + {!loading && kitchens.length === 0 && ( +

+ You don't belong to any kitchens yet.{" "} + Create one. +

+ )} + + {!loading && kitchens.length > 0 && ( +
    + {kitchens.map((kitchen) => { + const isShared = sharedKitchens.has(kitchen.id); + return ( +
  • +
    +

    {kitchen.name}

    +

    {kitchen.member_count} member{kitchen.member_count !== 1 ? "s" : ""}

    +
    + +
  • + ); + })} +
+ )} +
+
+ )} + + ); +} diff --git a/apps/kitchen_mate/frontend/src/components/SharedRecipePage.tsx b/apps/kitchen_mate/frontend/src/components/SharedRecipePage.tsx new file mode 100644 index 0000000..6c01f0c --- /dev/null +++ b/apps/kitchen_mate/frontend/src/components/SharedRecipePage.tsx @@ -0,0 +1,170 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { getSharedRecipe, saveSharedRecipe, ShareError } from "../api/sharing"; +import { LoadingSpinner } from "./LoadingSpinner"; +import { useAuthContext } from "../hooks/useAuthContext"; +import type { SharedRecipeResponse } from "../types/sharing"; +import type { Recipe } from "../types/recipe"; + +function formatTime(minutes: number): string { + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +function RecipeDisplay({ recipe }: { recipe: Recipe }) { + return ( + <> + {recipe.image && ( + {recipe.title} + )} +
+

{recipe.title}

+ + {recipe.metadata && ( +
+ {recipe.metadata.author && {recipe.metadata.author}} + {recipe.metadata.servings && {recipe.metadata.servings} servings} + {recipe.metadata.prep_time && ( + Prep: {formatTime(recipe.metadata.prep_time)} + )} + {recipe.metadata.cook_time && ( + Cook: {formatTime(recipe.metadata.cook_time)} + )} + {recipe.metadata.total_time && ( + Total: {formatTime(recipe.metadata.total_time)} + )} +
+ )} + + {recipe.source_url && ( + + )} + +
+

Ingredients

+
    + {recipe.ingredients.map((ingredient, index) => ( +
  • + + {ingredient.display_text || ingredient.name} +
  • + ))} +
+
+ +
+

Instructions

+
    + {recipe.instructions.map((instruction, index) => ( +
  1. + + {index + 1} + + {instruction} +
  2. + ))} +
+
+
+ + ); +} + +export function SharedRecipePage() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const { user } = useAuthContext(); + + const [sharedRecipe, setSharedRecipe] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + if (!token) return; + getSharedRecipe(token) + .then(setSharedRecipe) + .catch((err) => { + setError(err instanceof ShareError ? err.message : "Failed to load recipe"); + }) + .finally(() => setLoading(false)); + }, [token]); + + const handleAddToCollection = async () => { + if (!token) return; + setSaving(true); + setSaveError(null); + try { + const result = await saveSharedRecipe(token); + navigate(`/recipes/${result.user_recipe_id}`); + } catch (err) { + setSaveError(err instanceof ShareError ? err.message : "Failed to save recipe"); + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+ + Go home + +
+ ); + } + + if (!sharedRecipe) return null; + + return ( +
+
+ Shared recipe + {user && ( +
+ + {saveError &&

{saveError}

} +
+ )} + {!user && ( +

+ Sign in to add to your collection +

+ )} +
+
+ +
+
+ ); +} diff --git a/apps/kitchen_mate/frontend/src/hooks/useRequireAuth.ts b/apps/kitchen_mate/frontend/src/hooks/useRequireAuth.ts index 48ab545..28a0677 100644 --- a/apps/kitchen_mate/frontend/src/hooks/useRequireAuth.ts +++ b/apps/kitchen_mate/frontend/src/hooks/useRequireAuth.ts @@ -8,6 +8,8 @@ interface RequireAuthResult { user: User; /** Whether the user has Pro tier access */ isPro: boolean; + /** Whether auth state is still being resolved */ + loading: boolean; } /** @@ -30,7 +32,7 @@ interface RequireAuthResult { * ``` */ export function useRequireAuth(): RequireAuthResult { - const { user, isAuthEnabled } = useAuthContext(); + const { user, loading, isAuthEnabled } = useAuthContext(); // Single-tenant mode: always authorized with default user and Pro access if (!isAuthEnabled) { @@ -38,6 +40,7 @@ export function useRequireAuth(): RequireAuthResult { isAuthorized: true, user: DEFAULT_USER, isPro: true, + loading: false, }; } @@ -47,5 +50,6 @@ export function useRequireAuth(): RequireAuthResult { isAuthorized: user !== null, user: currentUser, isPro: currentUser.tier === "pro", + loading, }; } diff --git a/apps/kitchen_mate/frontend/src/types/kitchen.ts b/apps/kitchen_mate/frontend/src/types/kitchen.ts new file mode 100644 index 0000000..7e7111d --- /dev/null +++ b/apps/kitchen_mate/frontend/src/types/kitchen.ts @@ -0,0 +1,46 @@ +export interface KitchenMember { + user_id: string; + email: string | null; + role: string; + joined_at: string; +} + +export interface KitchenSummary { + id: string; + name: string; + created_by: string; + member_count: number; + created_at: string; + updated_at: string; +} + +export interface KitchenDetail { + id: string; + name: string; + created_by: string; + members: KitchenMember[]; + created_at: string; + updated_at: string; +} + +export interface KitchenRecipe { + id: string; + kitchen_id: string; + user_recipe_id: string; + shared_by: string; + shared_at: string; + title: string; + image_url: string | null; + tags: string[] | null; +} + +export interface ListKitchenRecipesResponse { + recipes: KitchenRecipe[]; + next_cursor: string | null; + has_more: boolean; +} + +export interface AddMemberResponse { + added: boolean; + message: string; +} diff --git a/apps/kitchen_mate/frontend/src/types/sharing.ts b/apps/kitchen_mate/frontend/src/types/sharing.ts new file mode 100644 index 0000000..62ff6e3 --- /dev/null +++ b/apps/kitchen_mate/frontend/src/types/sharing.ts @@ -0,0 +1,19 @@ +import type { Recipe } from "./recipe"; + +export interface CreateShareResponse { + share_token: string; + share_url: string; + created_at: string; + expires_at: string | null; +} + +export interface SharedRecipeResponse { + title: string; + recipe: Recipe; + shared_at: string; +} + +export interface SaveSharedRecipeResponse { + user_recipe_id: string; + is_new: boolean; +} diff --git a/apps/kitchen_mate/src/kitchen_mate/config.py b/apps/kitchen_mate/src/kitchen_mate/config.py index 72e121c..6aee9b7 100644 --- a/apps/kitchen_mate/src/kitchen_mate/config.py +++ b/apps/kitchen_mate/src/kitchen_mate/config.py @@ -79,6 +79,9 @@ class Settings(BaseSettings): # CORS configuration cors_origins: str = "http://localhost:5173" + # Base URL for constructing shareable links + app_base_url: str = "http://localhost:5173" + # Database configuration cache_db_path: str = "kitchenmate.db" cache_enabled: bool = True diff --git a/apps/kitchen_mate/src/kitchen_mate/database/__init__.py b/apps/kitchen_mate/src/kitchen_mate/database/__init__.py index 5235ede..aaf5b7b 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/__init__.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/__init__.py @@ -11,22 +11,41 @@ get_session_factory, init_database, ) -from kitchen_mate.database.models import Base, RecipeModel, UserRecipeModel +from kitchen_mate.database.models import ( + Base, + KitchenInviteModel, + KitchenMemberModel, + KitchenModel, + KitchenRecipeModel, + RecipeModel, + RecipeShareModel, + UserModel, + UserRecipeModel, +) from kitchen_mate.database.repositories import ( CachedRecipe, + DbUser, + RecipeShare, UserRecipe, UserRecipeSummary, + create_or_get_share, delete_user_recipe, get_cached_recipe, + get_share_by_token, + get_share_for_user_recipe, + get_user_by_email, get_user_recipe, + get_user_recipe_by_id_no_auth, get_user_recipe_with_lineage, get_user_recipes, hash_content, + revoke_share, save_user_recipe, store_recipe, update_recipe, update_recipe_thumbnail_key, update_user_recipe, + upsert_user, ) __all__ = [ @@ -41,10 +60,18 @@ "Base", "RecipeModel", "UserRecipeModel", + "UserModel", + "RecipeShareModel", + "KitchenModel", + "KitchenMemberModel", + "KitchenInviteModel", + "KitchenRecipeModel", # Schemas "CachedRecipe", "UserRecipe", "UserRecipeSummary", + "DbUser", + "RecipeShare", # Repository functions "get_cached_recipe", "store_recipe", @@ -57,4 +84,11 @@ "update_user_recipe", "update_recipe_thumbnail_key", "delete_user_recipe", + "upsert_user", + "get_user_by_email", + "create_or_get_share", + "get_share_by_token", + "get_share_for_user_recipe", + "revoke_share", + "get_user_recipe_by_id_no_auth", ] diff --git a/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py new file mode 100644 index 0000000..95e6da6 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/database/kitchen_repositories.py @@ -0,0 +1,469 @@ +"""Async repository functions for kitchen (group) operations.""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime + +from pydantic import BaseModel +from sqlalchemy import func, select + +from kitchen_mate.database.engine import get_session +from kitchen_mate.database.models import ( + KitchenInviteModel, + KitchenMemberModel, + KitchenModel, + KitchenRecipeModel, + UserModel, + UserRecipeModel, +) +from kitchen_mate.database.repositories import get_user_by_email + + +# ============================================================================= +# Pydantic schemas +# ============================================================================= + + +class Kitchen(BaseModel): + id: str + name: str + created_by: str + created_at: datetime + updated_at: datetime + + +class KitchenMember(BaseModel): + id: str + kitchen_id: str + user_id: str + email: str | None + role: str + joined_at: datetime + + +class KitchenSummary(BaseModel): + id: str + name: str + created_by: str + member_count: int + created_at: datetime + updated_at: datetime + + +class KitchenDetail(BaseModel): + id: str + name: str + created_by: str + members: list[KitchenMember] + created_at: datetime + updated_at: datetime + + +class KitchenRecipe(BaseModel): + id: str + kitchen_id: str + user_recipe_id: str + shared_by: str + shared_at: datetime + title: str + image_url: str | None + tags: list[str] | None + + +# ============================================================================= +# Helper functions +# ============================================================================= + + +def _member_to_schema(model: KitchenMemberModel, email: str | None) -> KitchenMember: + return KitchenMember( + id=model.id, + kitchen_id=model.kitchen_id, + user_id=model.user_id, + email=email, + role=model.role, + joined_at=model.joined_at, + ) + + +# ============================================================================= +# Kitchen Functions +# ============================================================================= + + +async def create_kitchen(user_id: str, name: str) -> KitchenSummary: + """Create a new kitchen and add the creator as admin.""" + now = datetime.now() + kitchen_id = str(uuid.uuid4()) + member_id = str(uuid.uuid4()) + + async with get_session() as session: + kitchen = KitchenModel( + id=kitchen_id, + name=name, + created_by=user_id, + created_at=now, + updated_at=now, + ) + session.add(kitchen) + + member = KitchenMemberModel( + id=member_id, + kitchen_id=kitchen_id, + user_id=user_id, + role="admin", + joined_at=now, + ) + session.add(member) + + return KitchenSummary( + id=kitchen_id, + name=name, + created_by=user_id, + member_count=1, + created_at=now, + updated_at=now, + ) + + +async def get_user_kitchens(user_id: str) -> list[KitchenSummary]: + """List all kitchens the user belongs to.""" + async with get_session() as session: + stmt = ( + select( + KitchenModel, + func.count(KitchenMemberModel.id).label("member_count"), + ) + .join(KitchenMemberModel, KitchenModel.id == KitchenMemberModel.kitchen_id) + .where( + KitchenModel.id.in_( + select(KitchenMemberModel.kitchen_id).where( + KitchenMemberModel.user_id == user_id + ) + ) + ) + .group_by(KitchenModel.id) + .order_by(KitchenModel.created_at.desc()) + ) + result = await session.execute(stmt) + rows = result.all() + + return [ + KitchenSummary( + id=kitchen.id, + name=kitchen.name, + created_by=kitchen.created_by, + member_count=count, + created_at=kitchen.created_at, + updated_at=kitchen.updated_at, + ) + for kitchen, count in rows + ] + + +async def get_kitchen(kitchen_id: str, user_id: str) -> KitchenDetail | None: + """Get kitchen details. Returns None if the user is not a member.""" + async with get_session() as session: + # Check membership + membership_result = await session.execute( + select(KitchenMemberModel) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .where(KitchenMemberModel.user_id == user_id) + ) + if membership_result.scalar_one_or_none() is None: + return None + + kitchen_result = await session.execute( + select(KitchenModel).where(KitchenModel.id == kitchen_id) + ) + kitchen = kitchen_result.scalar_one_or_none() + if kitchen is None: + return None + + # Fetch all members with email via LEFT JOIN on users table + members_result = await session.execute( + select(KitchenMemberModel, UserModel.email) + .outerjoin(UserModel, KitchenMemberModel.user_id == UserModel.id) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .order_by(KitchenMemberModel.joined_at) + ) + members = [_member_to_schema(member, email) for member, email in members_result.all()] + + return KitchenDetail( + id=kitchen.id, + name=kitchen.name, + created_by=kitchen.created_by, + members=members, + created_at=kitchen.created_at, + updated_at=kitchen.updated_at, + ) + + +async def get_member_role(kitchen_id: str, user_id: str) -> str | None: + """Return the user's role in the kitchen, or None if not a member.""" + async with get_session() as session: + result = await session.execute( + select(KitchenMemberModel.role) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .where(KitchenMemberModel.user_id == user_id) + ) + row = result.scalar_one_or_none() + return row + + +async def add_or_invite_member( + kitchen_id: str, invited_by_user_id: str, email: str +) -> tuple[KitchenMember | None, bool]: + """Add a member directly if the email matches a known user, else create a pending invite. + + Returns: + (KitchenMember, True) if directly added + (None, False) if invite was created + + Raises: + ValueError: If the user is already a member + """ + existing_user = await get_user_by_email(email) + + async with get_session() as session: + if existing_user is not None: + # Check if already a member + existing_member_result = await session.execute( + select(KitchenMemberModel) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .where(KitchenMemberModel.user_id == existing_user.id) + ) + if existing_member_result.scalar_one_or_none() is not None: + raise ValueError(f"{email} is already a member of this kitchen") + + now = datetime.now() + member_id = str(uuid.uuid4()) + member = KitchenMemberModel( + id=member_id, + kitchen_id=kitchen_id, + user_id=existing_user.id, + role="member", + joined_at=now, + ) + session.add(member) + return ( + KitchenMember( + id=member_id, + kitchen_id=kitchen_id, + user_id=existing_user.id, + email=email, + role="member", + joined_at=now, + ), + True, + ) + else: + # Check if invite already exists + existing_invite_result = await session.execute( + select(KitchenInviteModel) + .where(KitchenInviteModel.kitchen_id == kitchen_id) + .where(KitchenInviteModel.invited_email == email) + ) + if existing_invite_result.scalar_one_or_none() is not None: + raise ValueError(f"Invite already sent to {email}") + + invite = KitchenInviteModel( + id=str(uuid.uuid4()), + kitchen_id=kitchen_id, + invited_email=email, + invited_by=invited_by_user_id, + created_at=datetime.now(), + ) + session.add(invite) + return None, False + + +async def remove_member(kitchen_id: str, target_user_id: str) -> bool: + """Remove a member from a kitchen. Returns False if not found.""" + async with get_session() as session: + result = await session.execute( + select(KitchenMemberModel) + .where(KitchenMemberModel.kitchen_id == kitchen_id) + .where(KitchenMemberModel.user_id == target_user_id) + ) + member = result.scalar_one_or_none() + if member is None: + return False + await session.delete(member) + return True + + +async def process_pending_invites(user_id: str, email: str) -> int: + """Resolve pending kitchen invites for an email address. + + Called on /auth/me to convert pending invites into actual memberships. + Returns the number of kitchens joined. + """ + async with get_session() as session: + invites_result = await session.execute( + select(KitchenInviteModel).where(KitchenInviteModel.invited_email == email) + ) + invites = invites_result.scalars().all() + + if not invites: + return 0 + + now = datetime.now() + count = 0 + for invite in invites: + # Check not already a member (defensive) + existing_result = await session.execute( + select(KitchenMemberModel) + .where(KitchenMemberModel.kitchen_id == invite.kitchen_id) + .where(KitchenMemberModel.user_id == user_id) + ) + if existing_result.scalar_one_or_none() is None: + member = KitchenMemberModel( + id=str(uuid.uuid4()), + kitchen_id=invite.kitchen_id, + user_id=user_id, + role="member", + joined_at=now, + ) + session.add(member) + count += 1 + await session.delete(invite) + + return count + + +async def share_recipe_to_kitchen( + kitchen_id: str, user_recipe_id: str, shared_by: str +) -> KitchenRecipe: + """Share a user recipe with a kitchen. + + Raises: + ValueError: If user is not a member, recipe not found/owned, or already shared + """ + # Verify membership + role = await get_member_role(kitchen_id, shared_by) + if role is None: + raise ValueError("Not a member of this kitchen") + + async with get_session() as session: + # Verify recipe ownership + recipe_result = await session.execute( + select(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.user_id == shared_by) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + user_recipe = recipe_result.scalar_one_or_none() + if user_recipe is None: + raise ValueError("Recipe not found or not owned by user") + + # Check if already shared + existing_result = await session.execute( + select(KitchenRecipeModel) + .where(KitchenRecipeModel.kitchen_id == kitchen_id) + .where(KitchenRecipeModel.user_recipe_id == user_recipe_id) + ) + if existing_result.scalar_one_or_none() is not None: + raise ValueError("Recipe is already shared with this kitchen") + + now = datetime.now() + kr_id = str(uuid.uuid4()) + recipe_data = json.loads(user_recipe.recipe_data) + tags_data = json.loads(user_recipe.tags) if user_recipe.tags else None + + model = KitchenRecipeModel( + id=kr_id, + kitchen_id=kitchen_id, + user_recipe_id=user_recipe_id, + shared_by=shared_by, + shared_at=now, + ) + session.add(model) + + return KitchenRecipe( + id=kr_id, + kitchen_id=kitchen_id, + user_recipe_id=user_recipe_id, + shared_by=shared_by, + shared_at=now, + title=recipe_data.get("title", "Untitled"), + image_url=recipe_data.get("image"), + tags=tags_data, + ) + + +async def get_kitchen_recipes( + kitchen_id: str, + user_id: str, + cursor: str | None = None, + limit: int = 50, +) -> tuple[list[KitchenRecipe], str | None, bool]: + """List recipes shared with a kitchen. Returns None if user is not a member.""" + role = await get_member_role(kitchen_id, user_id) + if role is None: + raise ValueError("Not a member of this kitchen") + + async with get_session() as session: + stmt = ( + select(KitchenRecipeModel, UserRecipeModel) + .join(UserRecipeModel, KitchenRecipeModel.user_recipe_id == UserRecipeModel.id) + .where(KitchenRecipeModel.kitchen_id == kitchen_id) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + + if cursor: + cursor_result = await session.execute( + select(KitchenRecipeModel.shared_at).where(KitchenRecipeModel.id == cursor) + ) + cursor_row = cursor_result.scalar_one_or_none() + if cursor_row: + stmt = stmt.where(KitchenRecipeModel.shared_at < cursor_row) + + stmt = stmt.order_by(KitchenRecipeModel.shared_at.desc()).limit(limit + 1) + result = await session.execute(stmt) + rows = result.all() + + has_more = len(rows) > limit + if has_more: + rows = rows[:limit] + + recipes = [] + for kr, user_recipe in rows: + recipe_data = json.loads(user_recipe.recipe_data) + tags_data = json.loads(user_recipe.tags) if user_recipe.tags else None + recipes.append( + KitchenRecipe( + id=kr.id, + kitchen_id=kr.kitchen_id, + user_recipe_id=kr.user_recipe_id, + shared_by=kr.shared_by, + shared_at=kr.shared_at, + title=recipe_data.get("title", "Untitled"), + image_url=recipe_data.get("image"), + tags=tags_data, + ) + ) + + next_cursor = rows[-1][0].id if rows and has_more else None + return recipes, next_cursor, has_more + + +async def remove_kitchen_recipe(kitchen_id: str, kitchen_recipe_id: str, user_id: str) -> bool: + """Remove a recipe from a kitchen. Any member can remove.""" + role = await get_member_role(kitchen_id, user_id) + if role is None: + return False + + async with get_session() as session: + result = await session.execute( + select(KitchenRecipeModel) + .where(KitchenRecipeModel.id == kitchen_recipe_id) + .where(KitchenRecipeModel.kitchen_id == kitchen_id) + ) + kr = result.scalar_one_or_none() + if kr is None: + return False + await session.delete(kr) + return True diff --git a/apps/kitchen_mate/src/kitchen_mate/database/models.py b/apps/kitchen_mate/src/kitchen_mate/database/models.py index c2f929c..75e9d26 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/models.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/models.py @@ -68,3 +68,112 @@ class UserRecipeModel(Base): # Unique constraint: one user can save a recipe once Index("uq_user_recipe", "user_id", "recipe_id", unique=True), ) + + +class UserModel(Base): + """Persisted user records synced from JWT claims.""" + + __tablename__ = "users" + + id: Mapped[str] = mapped_column(Text, primary_key=True) # Supabase UUID + email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + __table_args__ = (Index("idx_users_email", "email"),) + + +class RecipeShareModel(Base): + """Public share links for individual user recipes.""" + + __tablename__ = "recipe_shares" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_recipe_id: Mapped[str] = mapped_column( + String(36), ForeignKey("user_recipes.id"), nullable=False + ) + share_token: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + user_recipe: Mapped["UserRecipeModel"] = relationship() + + __table_args__ = ( + Index("idx_recipe_shares_share_token", "share_token"), + Index("idx_recipe_shares_user_recipe_id", "user_recipe_id"), + ) + + +class KitchenModel(Base): + """Groups of users that can share recipes.""" + + __tablename__ = "kitchens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + name: Mapped[str] = mapped_column(Text, nullable=False) + created_by: Mapped[str] = mapped_column(Text, nullable=False) # user_id + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + members: Mapped[list["KitchenMemberModel"]] = relationship(back_populates="kitchen") + kitchen_recipes: Mapped[list["KitchenRecipeModel"]] = relationship(back_populates="kitchen") + + __table_args__ = (Index("idx_kitchens_created_by", "created_by"),) + + +class KitchenMemberModel(Base): + """Members belonging to a kitchen.""" + + __tablename__ = "kitchen_members" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + kitchen_id: Mapped[str] = mapped_column(String(36), ForeignKey("kitchens.id"), nullable=False) + user_id: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False) # "admin" | "member" + joined_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + kitchen: Mapped["KitchenModel"] = relationship(back_populates="members") + + __table_args__ = ( + Index("uq_kitchen_member", "kitchen_id", "user_id", unique=True), + Index("idx_kitchen_members_user_id", "user_id"), + ) + + +class KitchenInviteModel(Base): + """Pending email invitations to kitchens.""" + + __tablename__ = "kitchen_invites" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + kitchen_id: Mapped[str] = mapped_column(String(36), ForeignKey("kitchens.id"), nullable=False) + invited_email: Mapped[str] = mapped_column(Text, nullable=False) + invited_by: Mapped[str] = mapped_column(Text, nullable=False) # user_id + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + __table_args__ = ( + Index("uq_kitchen_invite", "kitchen_id", "invited_email", unique=True), + Index("idx_kitchen_invites_email", "invited_email"), + ) + + +class KitchenRecipeModel(Base): + """Recipes shared with a kitchen.""" + + __tablename__ = "kitchen_recipes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + kitchen_id: Mapped[str] = mapped_column(String(36), ForeignKey("kitchens.id"), nullable=False) + user_recipe_id: Mapped[str] = mapped_column( + String(36), ForeignKey("user_recipes.id"), nullable=False + ) + shared_by: Mapped[str] = mapped_column(Text, nullable=False) # user_id + shared_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + kitchen: Mapped["KitchenModel"] = relationship(back_populates="kitchen_recipes") + user_recipe: Mapped["UserRecipeModel"] = relationship() + + __table_args__ = ( + Index("uq_kitchen_recipe", "kitchen_id", "user_recipe_id", unique=True), + Index("idx_kitchen_recipes_kitchen_id", "kitchen_id"), + ) diff --git a/apps/kitchen_mate/src/kitchen_mate/database/repositories.py b/apps/kitchen_mate/src/kitchen_mate/database/repositories.py index 9836593..f64dec8 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/repositories.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/repositories.py @@ -4,17 +4,18 @@ import hashlib import json +import secrets import uuid from datetime import datetime from urllib.parse import urlparse from pydantic import BaseModel -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from recipe_clipper.models import Recipe from kitchen_mate.database.engine import get_session -from kitchen_mate.database.models import RecipeModel, UserRecipeModel +from kitchen_mate.database.models import RecipeModel, RecipeShareModel, UserModel, UserRecipeModel from kitchen_mate.schemas import Parser @@ -69,6 +70,25 @@ class UserRecipeSummary(BaseModel): updated_at: datetime +class DbUser(BaseModel): + """A persisted user record.""" + + id: str + email: str + created_at: datetime + updated_at: datetime + + +class RecipeShare(BaseModel): + """A public share link for a user recipe.""" + + id: str + user_recipe_id: str + share_token: str + created_at: datetime + expires_at: datetime | None + + # ============================================================================= # Helper functions # ============================================================================= @@ -648,3 +668,181 @@ async def delete_user_recipe(user_id: str, recipe_id: str) -> bool: result = await session.execute(stmt) return result.rowcount > 0 + + +# ============================================================================= +# User Functions +# ============================================================================= + + +async def upsert_user(user_id: str, email: str) -> DbUser: + """Upsert a user record. Called on every authenticated /auth/me request.""" + now = datetime.now() + + async with get_session() as session: + result = await session.execute(select(UserModel).where(UserModel.id == user_id)) + existing = result.scalar_one_or_none() + + if existing: + existing.email = email + existing.updated_at = now + return DbUser( + id=existing.id, + email=existing.email, + created_at=existing.created_at, + updated_at=now, + ) + + model = UserModel(id=user_id, email=email, created_at=now, updated_at=now) + session.add(model) + return DbUser(id=user_id, email=email, created_at=now, updated_at=now) + + +async def get_user_by_email(email: str) -> DbUser | None: + """Look up a user by email address.""" + async with get_session() as session: + result = await session.execute(select(UserModel).where(UserModel.email == email)) + row = result.scalar_one_or_none() + if row is None: + return None + return DbUser( + id=row.id, email=row.email, created_at=row.created_at, updated_at=row.updated_at + ) + + +# ============================================================================= +# Recipe Share Functions +# ============================================================================= + + +async def create_or_get_share(user_id: str, user_recipe_id: str) -> RecipeShare: + """Create a share link for a user recipe, or return the existing one. + + Verifies ownership before creating. + + Raises: + ValueError: If the recipe is not found or not owned by user_id + """ + async with get_session() as session: + # Verify ownership + ownership_result = await session.execute( + select(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.user_id == user_id) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + if ownership_result.scalar_one_or_none() is None: + raise ValueError(f"Recipe {user_recipe_id} not found or not owned by user") + + # Return existing share if present + existing_result = await session.execute( + select(RecipeShareModel).where(RecipeShareModel.user_recipe_id == user_recipe_id) + ) + existing = existing_result.scalar_one_or_none() + if existing: + return RecipeShare( + id=existing.id, + user_recipe_id=existing.user_recipe_id, + share_token=existing.share_token, + created_at=existing.created_at, + expires_at=existing.expires_at, + ) + + share_id = str(uuid.uuid4()) + share_token = secrets.token_urlsafe(32) + now = datetime.now() + model = RecipeShareModel( + id=share_id, + user_recipe_id=user_recipe_id, + share_token=share_token, + created_at=now, + expires_at=None, + ) + session.add(model) + return RecipeShare( + id=share_id, + user_recipe_id=user_recipe_id, + share_token=share_token, + created_at=now, + expires_at=None, + ) + + +async def get_share_by_token(share_token: str) -> RecipeShare | None: + """Fetch a share record by token. Returns None if not found or expired.""" + now = datetime.now() + async with get_session() as session: + result = await session.execute( + select(RecipeShareModel).where(RecipeShareModel.share_token == share_token) + ) + row = result.scalar_one_or_none() + if row is None: + return None + if row.expires_at is not None and row.expires_at < now: + return None + return RecipeShare( + id=row.id, + user_recipe_id=row.user_recipe_id, + share_token=row.share_token, + created_at=row.created_at, + expires_at=row.expires_at, + ) + + +async def get_share_for_user_recipe(user_id: str, user_recipe_id: str) -> RecipeShare | None: + """Get the share record for a user recipe, verifying ownership.""" + async with get_session() as session: + ownership_result = await session.execute( + select(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.user_id == user_id) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + if ownership_result.scalar_one_or_none() is None: + return None + + result = await session.execute( + select(RecipeShareModel).where(RecipeShareModel.user_recipe_id == user_recipe_id) + ) + row = result.scalar_one_or_none() + if row is None: + return None + return RecipeShare( + id=row.id, + user_recipe_id=row.user_recipe_id, + share_token=row.share_token, + created_at=row.created_at, + expires_at=row.expires_at, + ) + + +async def revoke_share(user_id: str, user_recipe_id: str) -> bool: + """Delete a share record, verifying ownership first.""" + async with get_session() as session: + ownership_result = await session.execute( + select(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.user_id == user_id) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + if ownership_result.scalar_one_or_none() is None: + return False + + result = await session.execute( + delete(RecipeShareModel).where(RecipeShareModel.user_recipe_id == user_recipe_id) + ) + return result.rowcount > 0 + + +async def get_user_recipe_by_id_no_auth(user_recipe_id: str) -> UserRecipe | None: + """Fetch a user recipe by ID without ownership check. Used by public share endpoints.""" + async with get_session() as session: + result = await session.execute( + select(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.deleted_at.is_(None)) + ) + row = result.scalar_one_or_none() + if row is None: + return None + return _user_recipe_model_to_schema(row) diff --git a/apps/kitchen_mate/src/kitchen_mate/main.py b/apps/kitchen_mate/src/kitchen_mate/main.py index 4173adc..853c06c 100644 --- a/apps/kitchen_mate/src/kitchen_mate/main.py +++ b/apps/kitchen_mate/src/kitchen_mate/main.py @@ -14,7 +14,7 @@ from kitchen_mate.config import get_settings from kitchen_mate.database import close_database, init_database -from kitchen_mate.routes import auth, clip, convert, files, me +from kitchen_mate.routes import auth, clip, convert, files, kitchens, me, sharing # Get settings at module load for CORS configuration @@ -60,6 +60,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(convert.router, prefix="/api") app.include_router(me.router, prefix="/api") app.include_router(files.router, prefix="/api") +app.include_router(sharing.router, prefix="/api") +app.include_router(kitchens.router, prefix="/api") @app.get("/health") diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/auth.py b/apps/kitchen_mate/src/kitchen_mate/routes/auth.py index 6068446..d81af57 100644 --- a/apps/kitchen_mate/src/kitchen_mate/routes/auth.py +++ b/apps/kitchen_mate/src/kitchen_mate/routes/auth.py @@ -8,6 +8,9 @@ from kitchen_mate.auth import User, get_current_user from kitchen_mate.authorization import TierInfo, get_tier_info +from kitchen_mate.config import Settings, get_settings +from kitchen_mate.database.kitchen_repositories import process_pending_invites +from kitchen_mate.database.repositories import upsert_user router = APIRouter() @@ -16,12 +19,16 @@ async def get_current_user_endpoint( current_user: Annotated[User, Depends(get_current_user)], tier_info: Annotated[TierInfo, Depends(get_tier_info)], + settings: Annotated[Settings, Depends(get_settings)], ) -> dict: - """Get the current authenticated user with tier information. + """Get the current authenticated user with tier information.""" + if settings.is_multi_tenant and settings.database.enabled and current_user.email is not None: + await upsert_user(current_user.id, current_user.email) + try: + await process_pending_invites(current_user.id, current_user.email) + except Exception: + pass # Invite processing failures must not block auth - Returns: - User information from JWT token with tier - """ return { "id": current_user.id, "email": current_user.email, diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py new file mode 100644 index 0000000..6bec291 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/routes/kitchens.py @@ -0,0 +1,259 @@ +"""Kitchen (group) management endpoints.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from kitchen_mate.auth import User, get_user +from kitchen_mate.config import Settings, get_settings +from kitchen_mate.database.kitchen_repositories import ( + add_or_invite_member, + create_kitchen, + get_kitchen, + get_kitchen_recipes, + get_member_role, + get_user_kitchens, + remove_kitchen_recipe, + remove_member, + share_recipe_to_kitchen, +) +from kitchen_mate.schemas import ( + AddMemberRequest, + AddMemberResponse, + CreateKitchenRequest, + KitchenDetailResponse, + KitchenMemberResponse, + KitchenRecipeResponse, + KitchenSummaryResponse, + ListKitchenRecipesResponse, + ShareToKitchenRequest, +) + +router = APIRouter() + + +def _require_multi_tenant(settings: Annotated[Settings, Depends(get_settings)]) -> None: + if settings.is_single_tenant: + raise HTTPException(status_code=403, detail="Kitchens require multi-tenant mode") + + +async def _require_admin(kitchen_id: str, user: User) -> None: + role = await get_member_role(kitchen_id, user.id) + if role != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + + +async def _require_member(kitchen_id: str, user: User) -> None: + role = await get_member_role(kitchen_id, user.id) + if role is None: + raise HTTPException(status_code=403, detail="Not a kitchen member") + + +@router.post( + "/kitchens", + response_model=KitchenSummaryResponse, + status_code=201, + dependencies=[Depends(_require_multi_tenant)], +) +async def create_kitchen_endpoint( + body: CreateKitchenRequest, + user: Annotated[User, Depends(get_user)], +) -> KitchenSummaryResponse: + """Create a new kitchen.""" + kitchen = await create_kitchen(user.id, body.name) + return KitchenSummaryResponse( + id=kitchen.id, + name=kitchen.name, + created_by=kitchen.created_by, + member_count=kitchen.member_count, + created_at=kitchen.created_at.isoformat(), + updated_at=kitchen.updated_at.isoformat(), + ) + + +@router.get( + "/kitchens", + response_model=list[KitchenSummaryResponse], + dependencies=[Depends(_require_multi_tenant)], +) +async def list_kitchens( + user: Annotated[User, Depends(get_user)], +) -> list[KitchenSummaryResponse]: + """List all kitchens the authenticated user belongs to.""" + kitchens = await get_user_kitchens(user.id) + return [ + KitchenSummaryResponse( + id=k.id, + name=k.name, + created_by=k.created_by, + member_count=k.member_count, + created_at=k.created_at.isoformat(), + updated_at=k.updated_at.isoformat(), + ) + for k in kitchens + ] + + +@router.get( + "/kitchens/{kitchen_id}", + response_model=KitchenDetailResponse, + dependencies=[Depends(_require_multi_tenant)], +) +async def get_kitchen_endpoint( + kitchen_id: str, + user: Annotated[User, Depends(get_user)], +) -> KitchenDetailResponse: + """Get kitchen details including members.""" + kitchen = await get_kitchen(kitchen_id, user.id) + if kitchen is None: + raise HTTPException(status_code=404, detail="Kitchen not found or not a member") + + return KitchenDetailResponse( + id=kitchen.id, + name=kitchen.name, + created_by=kitchen.created_by, + members=[ + KitchenMemberResponse( + user_id=m.user_id, + email=m.email, + role=m.role, + joined_at=m.joined_at.isoformat(), + ) + for m in kitchen.members + ], + created_at=kitchen.created_at.isoformat(), + updated_at=kitchen.updated_at.isoformat(), + ) + + +@router.post( + "/kitchens/{kitchen_id}/members", + response_model=AddMemberResponse, + dependencies=[Depends(_require_multi_tenant)], +) +async def add_member( + kitchen_id: str, + body: AddMemberRequest, + user: Annotated[User, Depends(get_user)], +) -> AddMemberResponse: + """Add a member to a kitchen (admin only). Sends pending invite if user not found.""" + await _require_admin(kitchen_id, user) + + try: + member, directly_added = await add_or_invite_member(kitchen_id, user.id, body.email) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + if directly_added: + return AddMemberResponse(added=True, message=f"{body.email} added to the kitchen") + return AddMemberResponse( + added=False, + message=f"Invite sent to {body.email}. They will be added when they sign in.", + ) + + +@router.delete( + "/kitchens/{kitchen_id}/members/{target_user_id}", + status_code=204, + dependencies=[Depends(_require_multi_tenant)], +) +async def remove_member_endpoint( + kitchen_id: str, + target_user_id: str, + user: Annotated[User, Depends(get_user)], +) -> None: + """Remove a member from a kitchen (admin only).""" + await _require_admin(kitchen_id, user) + + removed = await remove_member(kitchen_id, target_user_id) + if not removed: + raise HTTPException(status_code=404, detail="Member not found") + + +@router.post( + "/kitchens/{kitchen_id}/recipes", + response_model=KitchenRecipeResponse, + status_code=201, + dependencies=[Depends(_require_multi_tenant)], +) +async def share_recipe( + kitchen_id: str, + body: ShareToKitchenRequest, + user: Annotated[User, Depends(get_user)], +) -> KitchenRecipeResponse: + """Share a recipe with a kitchen (any member).""" + await _require_member(kitchen_id, user) + + try: + kr = await share_recipe_to_kitchen(kitchen_id, body.user_recipe_id, user.id) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + return KitchenRecipeResponse( + id=kr.id, + kitchen_id=kr.kitchen_id, + user_recipe_id=kr.user_recipe_id, + shared_by=kr.shared_by, + shared_at=kr.shared_at.isoformat(), + title=kr.title, + image_url=kr.image_url, + tags=kr.tags, + ) + + +@router.get( + "/kitchens/{kitchen_id}/recipes", + response_model=ListKitchenRecipesResponse, + dependencies=[Depends(_require_multi_tenant)], +) +async def list_kitchen_recipes( + kitchen_id: str, + user: Annotated[User, Depends(get_user)], + cursor: str | None = None, + limit: int = 50, +) -> ListKitchenRecipesResponse: + """List recipes shared with a kitchen (any member).""" + await _require_member(kitchen_id, user) + + try: + recipes, next_cursor, has_more = await get_kitchen_recipes( + kitchen_id, user.id, cursor, min(limit, 100) + ) + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) + + return ListKitchenRecipesResponse( + recipes=[ + KitchenRecipeResponse( + id=r.id, + kitchen_id=r.kitchen_id, + user_recipe_id=r.user_recipe_id, + shared_by=r.shared_by, + shared_at=r.shared_at.isoformat(), + title=r.title, + image_url=r.image_url, + tags=r.tags, + ) + for r in recipes + ], + next_cursor=next_cursor, + has_more=has_more, + ) + + +@router.delete( + "/kitchens/{kitchen_id}/recipes/{kitchen_recipe_id}", + status_code=204, + dependencies=[Depends(_require_multi_tenant)], +) +async def remove_recipe_from_kitchen( + kitchen_id: str, + kitchen_recipe_id: str, + user: Annotated[User, Depends(get_user)], +) -> None: + """Remove a recipe from a kitchen (any member).""" + removed = await remove_kitchen_recipe(kitchen_id, kitchen_recipe_id, user.id) + if not removed: + raise HTTPException(status_code=404, detail="Recipe not found in kitchen") diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/sharing.py b/apps/kitchen_mate/src/kitchen_mate/routes/sharing.py new file mode 100644 index 0000000..878c1fa --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/routes/sharing.py @@ -0,0 +1,92 @@ +"""Recipe sharing endpoints.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from kitchen_mate.auth import User, get_user +from kitchen_mate.config import Settings, get_settings +from kitchen_mate.database.repositories import ( + create_or_get_share, + get_share_by_token, + get_user_recipe_by_id_no_auth, + revoke_share, + save_user_recipe, +) +from kitchen_mate.schemas import CreateShareResponse, SaveSharedRecipeResponse, SharedRecipeResponse + +router = APIRouter() + + +@router.post("/me/recipes/{recipe_id}/share", response_model=CreateShareResponse) +async def create_share( + recipe_id: str, + user: Annotated[User, Depends(get_user)], + settings: Annotated[Settings, Depends(get_settings)], +) -> CreateShareResponse: + """Generate a shareable link for a recipe.""" + try: + share = await create_or_get_share(user.id, recipe_id) + except ValueError: + raise HTTPException(status_code=404, detail="Recipe not found") + + share_url = f"{settings.app_base_url}/shared/{share.share_token}" + return CreateShareResponse( + share_token=share.share_token, + share_url=share_url, + created_at=share.created_at.isoformat(), + expires_at=share.expires_at.isoformat() if share.expires_at else None, + ) + + +@router.delete("/me/recipes/{recipe_id}/share", status_code=204) +async def delete_share( + recipe_id: str, + user: Annotated[User, Depends(get_user)], +) -> None: + """Revoke a recipe's share link.""" + revoked = await revoke_share(user.id, recipe_id) + if not revoked: + raise HTTPException(status_code=404, detail="Share not found") + + +@router.get("/shared/{share_token}", response_model=SharedRecipeResponse) +async def get_shared_recipe(share_token: str) -> SharedRecipeResponse: + """View a shared recipe. No authentication required.""" + share = await get_share_by_token(share_token) + if share is None: + raise HTTPException(status_code=404, detail="Share link not found or expired") + + user_recipe = await get_user_recipe_by_id_no_auth(share.user_recipe_id) + if user_recipe is None: + raise HTTPException(status_code=404, detail="Recipe not found") + + return SharedRecipeResponse( + title=user_recipe.recipe.title, + recipe=user_recipe.recipe, + shared_at=share.created_at.isoformat(), + ) + + +@router.post("/shared/{share_token}/save", response_model=SaveSharedRecipeResponse) +async def save_shared_recipe( + share_token: str, + user: Annotated[User, Depends(get_user)], +) -> SaveSharedRecipeResponse: + """Add a shared recipe to the authenticated user's collection.""" + share = await get_share_by_token(share_token) + if share is None: + raise HTTPException(status_code=404, detail="Share link not found or expired") + + source = await get_user_recipe_by_id_no_auth(share.user_recipe_id) + if source is None: + raise HTTPException(status_code=404, detail="Recipe not found") + + saved, is_new = await save_user_recipe( + user_id=user.id, + recipe_id=source.recipe_id, + recipe_data=source.recipe, + ) + return SaveSharedRecipeResponse(user_recipe_id=saved.id, is_new=is_new) diff --git a/apps/kitchen_mate/src/kitchen_mate/schemas.py b/apps/kitchen_mate/src/kitchen_mate/schemas.py index 37b1136..8e811ef 100644 --- a/apps/kitchen_mate/src/kitchen_mate/schemas.py +++ b/apps/kitchen_mate/src/kitchen_mate/schemas.py @@ -253,3 +253,114 @@ class ThumbnailUploadResponse(BaseModel): """Response body for uploading a recipe thumbnail.""" image_url: str = Field(description="URL to the uploaded thumbnail") + + +# ============================================================================= +# Recipe Sharing Schemas +# ============================================================================= + + +class CreateShareResponse(BaseModel): + """Response body for creating a share link.""" + + share_token: str = Field(description="Unique share token") + share_url: str = Field(description="Full shareable URL") + created_at: str = Field(description="When the share was created") + expires_at: str | None = Field(description="Expiry time, null if no expiry") + + +class SharedRecipeResponse(BaseModel): + """Public view of a shared recipe (no user-specific data).""" + + title: str = Field(description="Recipe title") + recipe: Recipe = Field(description="The recipe data") + shared_at: str = Field(description="When the share link was created") + + +class SaveSharedRecipeResponse(BaseModel): + """Response body for saving a shared recipe to own collection.""" + + user_recipe_id: str = Field(description="ID of the newly saved user recipe") + is_new: bool = Field(description="Whether this is a new save or already existed") + + +# ============================================================================= +# Kitchen Schemas +# ============================================================================= + + +class CreateKitchenRequest(BaseModel): + """Request body for creating a kitchen.""" + + name: str = Field(min_length=1, max_length=100, description="Kitchen name") + + +class KitchenMemberResponse(BaseModel): + """A member of a kitchen.""" + + user_id: str = Field(description="User ID") + email: str | None = Field(description="User email, if available") + role: str = Field(description="Member role: 'admin' or 'member'") + joined_at: str = Field(description="When the user joined the kitchen") + + +class KitchenSummaryResponse(BaseModel): + """Summary of a kitchen for list views.""" + + id: str = Field(description="Kitchen ID") + name: str = Field(description="Kitchen name") + created_by: str = Field(description="User ID of the creator") + member_count: int = Field(description="Number of members") + created_at: str = Field(description="When the kitchen was created") + updated_at: str = Field(description="Last update time") + + +class KitchenDetailResponse(BaseModel): + """Full detail of a kitchen including members.""" + + id: str = Field(description="Kitchen ID") + name: str = Field(description="Kitchen name") + created_by: str = Field(description="User ID of the creator") + members: list[KitchenMemberResponse] = Field(description="Kitchen members") + created_at: str = Field(description="When the kitchen was created") + updated_at: str = Field(description="Last update time") + + +class AddMemberRequest(BaseModel): + """Request body for adding a member to a kitchen.""" + + email: str = Field(description="Email address of the user to add") + + +class AddMemberResponse(BaseModel): + """Response body for adding a member.""" + + added: bool = Field(description="True if directly added, False if pending invite") + message: str = Field(description="Human-readable status message") + + +class ShareToKitchenRequest(BaseModel): + """Request body for sharing a recipe with a kitchen.""" + + user_recipe_id: str = Field(description="ID of the user recipe to share") + + +class KitchenRecipeResponse(BaseModel): + """A recipe shared with a kitchen.""" + + id: str = Field(description="Kitchen recipe ID") + kitchen_id: str = Field(description="Kitchen ID") + user_recipe_id: str = Field(description="User recipe ID") + shared_by: str = Field(description="User ID who shared the recipe") + shared_at: str = Field(description="When the recipe was shared") + title: str = Field(description="Recipe title") + image_url: str | None = Field(description="Recipe image URL") + tags: list[str] | None = Field(description="Tags from the original user recipe") + + +class ListKitchenRecipesResponse(BaseModel): + """Response body for listing kitchen recipes.""" + + recipes: list[KitchenRecipeResponse] = Field(description="List of kitchen recipes") + next_cursor: str | None = Field(description="Cursor for next page") + has_more: bool = Field(description="Whether there are more recipes") diff --git a/apps/kitchen_mate/tests/conftest.py b/apps/kitchen_mate/tests/conftest.py index da70ee4..3645a3b 100644 --- a/apps/kitchen_mate/tests/conftest.py +++ b/apps/kitchen_mate/tests/conftest.py @@ -88,6 +88,7 @@ def settings_with_supabase(client: TestClient) -> Generator[Settings, None, None """Override settings with Supabase configuration for HS256 JWT verification.""" test_settings = Settings( _env_file=None, + cache_enabled=False, supabase_jwt_secret="test-secret-key-at-least-32-characters-long", supabase_url=None, )