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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/kitchen_mate/alembic/versions/b1c2d3e4f5a6_add_users_table.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions apps/kitchen_mate/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -202,6 +205,9 @@ function AppContent() {
<Route path="/add/upload" element={<AddFromUploadPage />} />
<Route path="/add/manual" element={<AddManualPage />} />
<Route path="/recipes/:id" element={<SavedRecipeView />} />
<Route path="/shared/:token" element={<SharedRecipePage />} />
<Route path="/kitchens" element={<KitchensPage />} />
<Route path="/kitchens/:id" element={<KitchenDetailPage />} />
{/* Redirect old /clip route to new /add/url */}
<Route path="/clip" element={<Navigate to="/add/url" replace />} />
</Routes>
Expand Down
112 changes: 112 additions & 0 deletions apps/kitchen_mate/frontend/src/api/kitchens.ts
Original file line number Diff line number Diff line change
@@ -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<T>(res: Response): Promise<T> {
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<T>;
}

export async function listKitchens(): Promise<KitchenSummary[]> {
const res = await fetch("/api/kitchens", { credentials: "include" });
return handleResponse<KitchenSummary[]>(res);
}

export async function getKitchen(kitchenId: string): Promise<KitchenDetail> {
const res = await fetch(`/api/kitchens/${encodeURIComponent(kitchenId)}`, {
credentials: "include",
});
return handleResponse<KitchenDetail>(res);
}

export async function createKitchen(name: string): Promise<KitchenSummary> {
const res = await fetch("/api/kitchens", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
return handleResponse<KitchenSummary>(res);
}

export async function addMember(
kitchenId: string,
email: string
): Promise<AddMemberResponse> {
const res = await fetch(`/api/kitchens/${encodeURIComponent(kitchenId)}/members`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
return handleResponse<AddMemberResponse>(res);
}

export async function removeMember(kitchenId: string, userId: string): Promise<void> {
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<KitchenRecipe> {
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<KitchenRecipe>(res);
}

export async function listKitchenRecipes(
kitchenId: string,
cursor?: string,
limit = 50
): Promise<ListKitchenRecipesResponse> {
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<ListKitchenRecipesResponse>(res);
}

export async function removeKitchenRecipe(
kitchenId: string,
kitchenRecipeId: string
): Promise<void> {
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);
}
}
55 changes: 55 additions & 0 deletions apps/kitchen_mate/frontend/src/api/sharing.ts
Original file line number Diff line number Diff line change
@@ -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<T>(res: Response): Promise<T> {
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<T>;
}

export async function createShare(recipeId: string): Promise<CreateShareResponse> {
const res = await fetch(`/api/me/recipes/${encodeURIComponent(recipeId)}/share`, {
method: "POST",
credentials: "include",
});
return handleResponse<CreateShareResponse>(res);
}

export async function revokeShare(recipeId: string): Promise<void> {
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<SharedRecipeResponse> {
const res = await fetch(`/api/shared/${encodeURIComponent(token)}`);
return handleResponse<SharedRecipeResponse>(res);
}

export async function saveSharedRecipe(token: string): Promise<SaveSharedRecipeResponse> {
const res = await fetch(`/api/shared/${encodeURIComponent(token)}/save`, {
method: "POST",
credentials: "include",
});
return handleResponse<SaveSharedRecipeResponse>(res);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const menuItems = [
export function AddRecipeDropdown({ variant = "button" }: AddRecipeDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isAuthorized, isPro } = useRequireAuth();
const { isAuthorized, isPro, loading } = useRequireAuth();

useEffect(() => {
function handleClickOutside(event: MouseEvent) {
Expand Down Expand Up @@ -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" : "";

Expand Down
Loading
Loading