Skip to content

feat(retailer): self-signup as first-class organizations - #235

Merged
scottcarlton merged 24 commits into
devfrom
feat/retailer-signup
Jul 10, 2026
Merged

feat(retailer): self-signup as first-class organizations#235
scottcarlton merged 24 commits into
devfrom
feat/retailer-signup

Conversation

@scottcarlton

Copy link
Copy Markdown
Owner

What

Adds retailer self-signup and models retailers as first-class organizations (org_type='retailer'), replacing the initial bespoke-table approach. A retailer signs up, becomes an organizations row whose founder is an admin member, and lands on the buyer portal.

The arc

  • SP1 — Retailer org identity + portal landing. org_type widens to ('rep','brand','retailer'). A retailer founder is an organization_members admin (no self-brand/seasons/shipping). The buyer portal is reached by org_type, not by absence-of-membership — the hook forks the membership branch on org type so a retailer member is isBuyer=true and routes to /dashboard. Resolves the redirect loop where a self-signup retailer had no account_users row.
  • Onboarding gains a third "Retailer" business-type card (three-up layout), routed through a reworked create-retailer endpoint.
  • SP3 — Shopping via linked accounts. Extends get_buyer_account_ids() — the single choke point every buyer RLS policy resolves through — to also return accounts linked to a retailer org via accounts.retailer_org_id. Every downstream buyer policy inherits retailer scope; no new policies. Currently inert: nothing sets retailer_org_id yet (that's SP4 discovery/linking), so live access is unchanged until then.
  • Security fix (bundled): deny /shop/[productId] to buyers with an empty brand-access list (empty scope was widening instead of denying).

Security

  • SP3's retailer arm is scoped to the caller's own memberships and org_type='retailer'. Link is necessary but not sufficient — a retailer sees a brand only when a linked account also has an account_brand_access grant, so the brand stays in control.
  • Legacy account_users buyers are untouched (the extension is a UNION that only adds rows).

Verification

  • bun run check — 0 errors.
  • bun run test:run — 734 passing (incl. new buyer-context + landing tests).
  • Integration harnesses against real local Supabase with real RLS + real JWT: SP1 identity 12/12, SP3 shopping 11/11 (link+grant sees brand/product, link-without-grant sees neither, cross-tenant sees nothing).
  • Retailer signup click-through verified in the browser.

Not in this PR (deferred)

  • SP4 — brand→retailer discovery/match/claim that sets accounts.retailer_org_id, account unification, and drift reconciliation (phase-2 spec committed).

Migrations

  • 20260709000001_retailer_org.sql — widen org_type; add accounts.retailer_org_id.
  • 20260709000002_retailer_buyer_account_ids.sql — extend get_buyer_account_ids().

🤖 Generated with Claude Code

scottcarlton and others added 22 commits July 9, 2026 10:50
Adds a `stores` directory table + `store_users` so retailers can sign up
unaided and land on the existing buyer portal. Rejects `org_type='store'`
to avoid a third federation direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 tasks, TDD. Corrects three spec errors found while reading the
real files: /shop already has its empty state, /dashboard has none
plus two dead links, and there is no automated RLS test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nsight

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reference-identity + local-override + drift-stewardship model. Pin on
edit, warn only the diverging brand on the diverged field, reconcile
wholesale or per-field, surfaced at point-of-use incl. the order.
Substrate (retailers table vs org_type='retailer') left explicitly open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retailers become org_type='retailer' with organization_members; hook
forks the membership branch on org_type so retailer-org members reach
the buyer portal. Supersedes the v1 retailers/retailer_users tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the unmerged v1 retailers/retailer_users tables into first-class
organizations. A retailer is now an organizations row (org_type='retailer')
whose founder is an organization_members admin, reaching the buyer portal via
isBuyer resolved from org_type in hooks.server.ts.

- migration: drop retailers/retailer_users + helpers; widen org_type CHECK to
  include 'retailer'; accounts.retailer_id -> accounts.retailer_org_id (SP4 seam)
- createRetailer: insert organizations + organization_members (admin), idempotent
  on an existing retailer-org membership, slug + 409 collision like create-org;
  no self-brand/seasons/shipping seeding
- hooks: fork the membership branch on org_type (retailer -> empty buyer context,
  /dashboard); fork the /login redirect; drop locals.retailer
- buyer-context: query only account_users (retailer handled in the hook)
- auth/callback + onboarding: route retailer-org members to /dashboard
- types: OrgType += 'retailer'; drop Retailer/RetailerUser; app.d.ts drops retailer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd, routing helper

Addresses adversarial review of the SP1 retailer-org rework:

- createRetailer idempotency now filters org_type='retailer' server-side via an
  inner join, so a user who admins both a rep/brand org and a retailer org can't
  fall through and mint a duplicate retailer
- roll back (delete) the just-created org if the membership insert fails —
  otherwise an ownerless org holds the slug forever and 409s every retry
- set onboarding_completed_at atomically in the org INSERT; drop the unchecked
  client-side completion write in the onboarding wizard
- block buyers (retailer-org members AND legacy account_users buyers) from the
  org AI endpoint via `|| locals.isBuyer` in the 401 guard
- move the profiles.display_name write to after successful org+member creation
  so a 409/500 no longer mutates the profile
- retailer hook branch sets brandScope/scopedBrandNames = null explicitly
- extract landingPathForOrgType() helper, used by both the /login fork and
  auth/callback so they can't drift; add landing.test.ts (3 org types)
- migration: DROP CONSTRAINT IF EXISTS for fresh-DB robustness

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend get_buyer_account_ids() — the single choke point every buyer RLS
policy resolves through — to also return accounts linked to a retailer org
the caller belongs to, via accounts.retailer_org_id. One function; every
downstream buyer policy (brands, products, variants, images, accounts,
orders, order_lines, seasons) inherits retailer scope. No new policies.

Security: link is necessary but not sufficient — a retailer sees a brand
only when a linked account ALSO has an account_brand_access grant (the
brand stays in control). Scoped to the caller's own memberships and
org_type='retailer'; legacy account_users buyers untouched (UNION only
adds). Proven end-to-end against real local RLS (11/11): link+grant sees
brand/product, link-without-grant sees neither, cross-tenant sees nothing.

hooks.server.ts retailer branch now resolves buyerAccounts/buyerBrandIds
from linked accounts via new resolveRetailerBuyerContext(), mirroring the
RLS helper in the app layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadline Ready Ready Preview, Comment Jul 10, 2026 4:29pm

dev extracted hooks.server.ts context-loading into src/lib/server/auth.ts
(loadUserContext/applyUserContext, a discriminated union of identity kinds).
Re-home the retailer identity into that structure rather than the old inline
hook:

- auth.ts: add a fifth 'retailer' kind — a hybrid that carries org-member
  locals (membership, allMemberships, orgType='retailer') AND buyer locals
  (isBuyer, buyerAccounts, buyerBrandIds). loadUserContext resolves it via
  resolveRetailerBuyerContext (accounts linked by retailer_org_id); the
  branch precedes brand-scope/SSO, which retailers skip.
- hooks.server.ts: take dev's switch orchestration; 'retailer' (like 'buyer')
  falls through with no redirect → lands on /dashboard.
- auth/callback: keep both dev's isSsoSession (gated domain auto-join) and
  SP1's landingPathForOrgType (retailer → /dashboard, rep/brand → /insight).
- auth.test.ts: add loadUserContext + applyUserContext coverage for the
  retailer branch, matching dev's per-branch test pattern.

Verified: 761 tests pass, bun run check 0 errors, retailer-shopping RLS
harness 11/11 against real local DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/lib/server/buyer-context.ts Outdated
Comment thread src/hooks.server.ts Outdated
…outing

Address PR #235 review:
- Remove resolveBuyerContext + its tests. dev's loadUserContext inlines the
  invited-buyer resolution, so this export was dead in production and its tests
  gave false confidence (they exercised a copy, not the live path). The two
  could drift silently. resolveRetailerBuyerContext (the live SP3 path) stays.
- /login fork: a membership-less legacy account_users buyer went to /insight
  and was bounced to /dashboard by the insight guard (two hops). Now they route
  to /dashboard directly, and a not-yet-onboarded user to /onboarding, in one
  hop — mirroring auth/callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@scottcarlton
scottcarlton merged commit dfdfae1 into dev Jul 10, 2026
6 checks passed
@scottcarlton
scottcarlton deleted the feat/retailer-signup branch July 10, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant