feat: social profiles — claim a handle and publish recipes to it (phase 1) - #515
Open
plusmobileapps wants to merge 9 commits into
Open
feat: social profiles — claim a handle and publish recipes to it (phase 1)#515plusmobileapps wants to merge 9 commits into
plusmobileapps wants to merge 9 commits into
Conversation
Adds the backend for public profiles: a `profiles` table keyed by an immutable @handle, and a `recipes.published_at` flag that lists a recipe on its owner's profile. Why `published_at` is not `is_public`: `is_public` (20260713, widened to anon in 20260715) means "readable by anyone holding the unguessable share link" and is deliberately UNLISTED. Had profiles listed every is_public row, then on ship day every recipe every user had ever shared by link would retroactively appear on their public profile. Listing therefore gets its own explicit opt-in column. Publishing implies is_public (enforced by CHECK, since a listed recipe must be readable); sharing never implies publishing. Identity is mirrored into `public.profiles` because auth.users is not publicly readable by design. The mirror deliberately carries no email. The blanket public SELECT on profiles is safe because having a row is strictly opt-in — a user has no profile until they claim a handle — so enumeration only reveals people who chose to be public. That is the distinction the `TO authenticated` policy in 20260713 got wrong. get_published_recipes() and get_profile_by_*() are SECURITY DEFINER so their WHERE clause is the only gate, rather than leaning on how permissive RLS policies compose — the failure mode behind #487. get_accessible_recipes() needs no change, so published recipes never pollute anyone else's sync. Handles are permanent: immutable via trigger, and retired into reserved_handles on delete rather than freed, so deleting an account cannot hand @you to someone who would inherit every link that ever pointed at you. Verified against a local Postgres with a harness stubbing auth.users, auth.uid() and the out-of-band recipes table: 36 checks covering handle validation, the publish/share split, RLS for owner/stranger/anon, the #487 sync guard, and cascade. NOT yet applied to prod. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds client/profile/data/{public,impl,testing} following the three-layer
convention, mirroring how client/auth is already split.
SocialProfile is the public identity — deliberately no email, unlike ChefMateUser
which is the account and only ever visible to its owner. ProfileHandle holds the
handle rules shared by the claim form and the repository; they mirror the
server's CHECK constraint, which stays the real authority.
Remote-only by design: unlike recipes and groceries there is no SQLDelight
mirror, because a profile is only meaningful fetched fresh — someone else's bio
changes without any sync event reaching this device. Callers handle failure as a
normal state, the way PublicRecipeBloc.Model.Offline already does.
Claiming a handle is racy, so SupabaseProfileRepository translates the SQLSTATEs
the claim path can legitimately produce (23505 taken, 23514 rejected) into typed
failures; the UNIQUE index, not the availability pre-check, is what actually
decides. Anonymous sessions are refused a profile — they have no credentials to
come back to, so a handle claimed by one would be stranded forever.
Published-recipe fetching deliberately lives on RecipeRepository, not here,
since that already owns the remote-to-domain recipe mapping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the server-side recipes.published_at locally (11.sqm) and threads it through the domain model, the wire type and both sync directions. publishedAt is deliberately separate from isPublic (9.sqm): isPublic means "readable by anyone holding the share link" and is unlisted, while publishedAt means "listed on my profile". setRecipePublished forces isPublic on when publishing, because a recipe listed on a profile has to be readable by whoever browses it — the same coupling the server enforces via the recipes_published_implies_public CHECK. Unpublishing deliberately leaves isPublic alone so a link already handed out keeps working; revoking that is a separate action the user already has. fetchPublishedRecipes is backed by the get_published_recipes RPC rather than a filtered select, for the same reason as fetchAccessibleRecipes: the RPC's WHERE clause is the sole gate instead of relying on how permissive RLS policies compose (#487). Tests cover the split in both directions — publishing lists and forces public, unpublishing delists but keeps the link alive, and sharing a link never publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the existing account screen with a handle and bio rather than adding a separate claim flow — it already edits display name and avatar, and its save path already sequences avatar-upload then profile-update then old-avatar-cleanup. The handle field is editable only until claimed, then renders read-only with a note explaining why: handles are permanent server-side, and a field you can type into but never save would be a lie. Availability is checked with a debounce and the result is discarded if a newer keystroke has landed, so a slow early check can't stomp a newer one. Local format rules mirror the server's CHECK so obviously-bad input costs no round-trip. Saving with no handle is a no-op rather than an error: a public profile is opt-in, so changing only your display name must never mint one. Losing the race for a handle between check and save is the one failure with a specific, actionable message — the UNIQUE index, not the pre-check, is the real authority. The public profile row is written after the account update and only if it succeeded, so a failure leaves the user able to retry without losing their edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One bloc serves both "my profile" and someone else's — they differ only in which actions the header offers, which Model.Loaded.isOwnProfile drives. Splitting them would duplicate the whole load/render path to vary two buttons. Props.handle is null for your own profile, resolved from the session. Tapping a published recipe routes out to the existing PublicRecipeBloc, which already fetches a public recipe by remote id and offers to save an owned copy — no new preview screen. NoProfile is a distinct state from NotFound: only your own profile can be "not created yet" (the invitation to claim a handle), whereas a handle that came from a link and resolves to nothing genuinely does not exist. A failed recipe listing surfaces as Offline for the whole screen rather than rendering the header alone, since a profile that silently appears to have published nothing would be a lie. The container's own scrolling is disabled because the published list is a LazyColumn — nesting one inside a verticalScroll measures it with infinite height and throws. Same reason RecipeListScreen opts out. Sharing goes through a Flow the screen hands to rememberShareLauncher, mirroring RecipeDetailBloc.shareLink, rather than routing a share through root navigation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Publish to profile / Remove from profile alongside the existing share actions on recipe detail. Publishing is a louder action than sharing a link and is presented as one: a share link is an unlisted capability URL, while publishing puts the recipe on a page strangers can browse. Both get their own confirmation copy saying which is which. Publishing needs a public identity to publish to, so a user with no claimed handle is routed to the profile editor rather than shown a prompt they can't complete. That output is threaded up through RecipeRootBloc to root, which owns the ManageProfile destination. Tests cover the routing-when-no-profile path, the confirm/dismiss paths, the signed-out failure, and — most importantly — that sharing a link never publishes to the profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the shareable profile URL (https://chefmate.plusmobileapps.com/@handle), its parser, the DeepLink.Profile destination, the root Profile child, and a "My Public Profile" row on the More tab. Profiles live in their own `@`-prefixed namespace so a handle can never collide with a route: /@settings is a profile, /settings is the settings screen. The `@` branch is checked before the route table but still behind the existing web-host check, so a lookalike host can't reach it. The server's reserved-handle list enforces the same separation from the other side. Also registers pathPrefix="/@" for Android App Links. NOTE FOR REVIEW: iOS Universal Links will not route /@handle into the app until the apple-app-site-association in the chefmate-site repo allows that path. Until then those links open the website. That change lives outside this repo and must land alongside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds previews for both profile screens and records their references. ManageProfile gains the three handle states worth seeing: claiming (available), taken (save disabled), and already-claimed (field locked). The existing ManageProfile references are re-recorded because the screen now carries handle and bio fields. Profile covers own vs. visitor, both empty states, no-profile, not-found, offline, and dark. Recording these caught a real bug, fixed in the ProfileBloc commit: the LazyColumn nested inside PlusHeaderContainer's own verticalScroll rendered as blank 68-byte PNGs because a scrollable measured with infinite height throws. That would have crashed the real screen too, not just the snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds ProfileRobot, extends ManageProfileRobot with the handle and bio fields, and covers the flow end to end through runRootBlocTest: open My Public Profile with no handle, follow the create prompt into the editor, claim a handle, and land back on the profile. TestProfileRepository replaces SupabaseProfileRepository in the test graph, which would otherwise need a real SupabaseClient — mirroring TestAuthenticationRepository. ManageProfileRobot.awaitSaveEnabled exists because claiming debounces an availability check before Save unlocks; typing a handle and tapping Save immediately races it, which is exactly how the flow test failed first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the social layer to Chef Mate: you claim a
@handle, get a public profile, and can publish recipes to it. Others open your profile fromhttps://chefmate.plusmobileapps.com/@handle, browse what you've published, and save copies.Phase 1 only. Follows, feed, likes, comments and global discovery are deliberately out of scope — they all build on the
profilestable this adds.The decision worth reviewing first
Publishing uses a new
recipes.published_at, deliberately separate fromis_public.is_public(20260713, widened to anon in 20260715) means "readable by anyone holding the unguessable share link" — a capability URL, deliberately unlisted. If profiles had listed everyis_publicrow, then on the day this ships, every recipe every user has ever shared by link would retroactively appear on their new public profile. That's a privacy regression, so listing gets its own explicit opt-in column.Publishing implies
is_public— a listed recipe has to be readable — enforced by a DB CHECK and mirrored in the repository. Sharing never implies publishing. Unpublishing delists but leavesis_publicalone, so a link already handed out keeps working; revoking that is the separate "Stop sharing link" action users already have.There are tests asserting the split in both directions.
Why
profilesis a new tableEvery user-facing identity field lived in
auth.users.raw_user_meta_data, whichanon/authenticatedcannot read — by design. A public profile needs a publicly-readable row, so identity is mirrored intopublic.profiles. The mirror deliberately carries no email.The blanket public SELECT on
profilesis safe because having a row is opt-in: you have no profile until you deliberately claim a handle, so enumeration can only ever reveal people who chose to be public. That's the distinction theTO authenticatedpolicy in 20260713 got wrong.Handles are permanent
Immutable via trigger, and retired into
reserved_handleson delete rather than freed. Otherwise deleting an account would let a stranger claim@youand inherit every link, screenshot and mention that ever pointed at you — the same impersonation vector the immutability rule exists to close, reached through a different door.The cost: a user who deletes their account can never reclaim their own handle. That's a deliberate trade and it's reversible — drop
trg_profiles_retire_handleif you'd rather recycle them.Two things to know before merging
auth.users,auth.uid(), theanon/authenticatedroles and the out-of-bandrecipestable — 36 checks covering handle validation, the publish/share split, RLS for owner / stranger / anon, the Public recipes show up on everyone's account even when they just sign up #487 sync guard, and cascade. Given the known prod schema drift, re-verify there before applying./@handleinto the app until theapple-app-site-associationin the chefmate-site repo allows that path. Until then those links open the website. Android is done (pathPrefix="/@"). That change lives outside this repo and must land alongside.What's here
profiles(immutable lowercase handle, bio, avatar),reserved_handles,recipes.published_at+ partial index, and SECURITY DEFINER RPCs for lookup and listing. The listing gets its own explicit source of truth rather than leaning on how permissive RLS policies compose — the failure mode behind Public recipes show up on everyone's account even when they just sign up #487.get_accessible_recipes()needs no change, so published recipes never pollute anyone else's sync.client/profile/data/{public,impl,testing}. Remote-only, no SQLDelight mirror: a profile is only meaningful fetched fresh, so failures render an Offline state with retry.ManageProfileBlocrather than a new screen; it already edits display name and avatar. Debounced availability check, with the UNIQUE index as the real authority.ProfileBlocfor both your own and someone else's, since they differ only in which header actions show. Tapping a recipe reuses the existingPublicRecipeBloc, which already handles "save a copy".A real bug caught while recording snapshots
The profile screen's
LazyColumnnested insidePlusHeaderContainer's ownverticalScrollproduced blank 68-byte PNGs — a scrollable measured with infinite height throws. That would have crashed the real screen too, not just the snapshot. Fixed withscrollEnabled = false, the same wayRecipeListScreendoes it.Testing
/@handleparsing (including that a lookalike host and a route-shaped handle both behave), and arunRootBlocTestflow: no profile → create → claim → back to profile.🤖 Generated with Claude Code