Skip to content

feat: social profiles — claim a handle and publish recipes to it (phase 1) - #515

Open
plusmobileapps wants to merge 9 commits into
mainfrom
claude/chef-mate-profiles-recipes-4fbeca
Open

feat: social profiles — claim a handle and publish recipes to it (phase 1)#515
plusmobileapps wants to merge 9 commits into
mainfrom
claude/chef-mate-profiles-recipes-4fbeca

Conversation

@plusmobileapps

Copy link
Copy Markdown
Collaborator

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 from https://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 profiles table this adds.

The decision worth reviewing first

Publishing uses a new recipes.published_at, deliberately separate from is_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 every is_public row, 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 leaves is_public alone, 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 profiles is a new table

Every user-facing identity field lived in auth.users.raw_user_meta_data, which anon/authenticated cannot read — by design. A public profile needs a publicly-readable row, so identity is mirrored into public.profiles. The mirror deliberately carries no email.

The blanket public SELECT on profiles is 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 the TO authenticated policy in 20260713 got wrong.

Handles are permanent

Immutable via trigger, and retired into reserved_handles on delete rather than freed. Otherwise deleting an account would let a stranger claim @you and 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_handle if you'd rather recycle them.

Two things to know before merging

  1. The migration has not been applied to prod. It was verified against a local Postgres with a harness stubbing auth.users, auth.uid(), the anon/authenticated roles and the out-of-band recipes table — 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.
  2. iOS Universal Links won't 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. Android is done (pathPrefix="/@"). That change lives outside this repo and must land alongside.

What's here

  • Backendprofiles (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.
  • Data — new 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.
  • Handle claiming — folded into the existing ManageProfileBloc rather than a new screen; it already edits display name and avatar. Debounced availability check, with the UNIQUE index as the real authority.
  • Profile screen — one ProfileBloc for both your own and someone else's, since they differ only in which header actions show. Tapping a recipe reuses the existing PublicRecipeBloc, which already handles "save a copy".
  • Publishing — Publish / Remove from profile on recipe detail, routing to the editor if no handle is claimed yet.

A real bug caught while recording snapshots

The profile screen's LazyColumn nested inside PlusHeaderContainer's own verticalScroll produced blank 68-byte PNGs — a scrollable measured with infinite height throws. That would have crashed the real screen too, not just the snapshot. Fixed with scrollEnabled = false, the same way RecipeListScreen does it.

Testing

  • 985 tests pass, including new coverage for the publish/share split, handle claiming and the race between check and save, /@handle parsing (including that a lookalike host and a route-shaped handle both behave), and a runRootBlocTest flow: no profile → create → claim → back to profile.
  • 16 screenshot references recorded and validated (own / visitor / empty / no-profile / not-found / offline / dark, plus the three handle states). Existing ManageProfile references re-recorded since the screen gained two fields.
  • Commits are split by concern for review: schema, then data layers, then one per BLoC with its tests, then snapshots, then robots.

🤖 Generated with Claude Code

plusmobileapps and others added 9 commits July 31, 2026 10:12
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant