Org Web Adapter

hungryroot/popsicle/exp-242-shareable-cookbooks-tdd.org

ID
c2b5ad1e-35fa-44bc-b7ef-ec8f2a39a0b9

Exp 242 - Shareable User-Created Cookbooks

Strategy summary

Add the share-pattern primitives that let a user-created Cookbook be shared

outside Hungryroot via a short opaque URL, with referral credit accruing to

the sharer. Three additive fields on =Cookbook= (=owner=, =visibility=,

=share_token=), one new read endpoint, one rotate endpoint, and a serializer

guard. *No* parallel attribution system — sharing reuses the existing

=Customer.referral_code= rails (=/r/<code>= redirector, =PromoMiddleware=,

=award_referral_bonus=). This document covers only the share primitives;

user-cookbook CRUD scaffolding (create/update/list-mine/delete) is a separate

effort that consumes these primitives. Companion to [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 - Favorites +

User Cookbooks]]; builds on [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1430945793][Exp 232 - Cookbooks]].

Overview

- POD :: Brand

- Product Requirements / Brief :: TODO

- Design Document :: This doc

- Figma :: TODO

- Epic :: [[https://hungryroot.atlassian.net/browse/EP-650][EP-650]]

- Experiment :: Exp 242

- Slack Channel :: [[https://hungryroot.slack.com/archives/C0B4QA7Q05A][#exp-242]]

- Target Complete Date (Backend) :: TODO

- Target Launch Date (Feature) :: TODO

- WEB spec :: TODO

People

| Role | Review Approved (add initials) |

|-------------------------------+--------------------------------|

| Stakeholder Lead - TODO @ | |

| Product Lead - TODO @ | |

| Technical Team Lead - TODO @ | |

| Technical Primary - TODO @ | |

| Technical Secondary - TODO @ | |

| QA Lead - TODO @ | |

| Data Lead - TODO @ | |

| BFL Lead - TODO @ | |

| Additional Technical - TODO @ | |

Timeline and milestones

| Item | Date | Description | Key Stakeholder Sign-off |

|--------------------------------+------+-------------+---------------------------------------|

| Spec review complete | | | Primary Dev / Dev Lead / Project Lead |

| Code complete | | | ... |

| QA starts | | | ... |

| P1 features staging roll out | | | ... |

| P1 features production rollout | | | ... |

Background

Cookbooks today (=cookbooks/models.py:50-94=) are admin-managed, identified

by =slug=, and fully public when =is_active=True=. There is no customer

ownership, no per-cookbook visibility setting, and no share-token surface.

Product wants users to author cookbooks and share them with a flexible URL

pattern, and wants the sharer to earn referral credit when a recipient signs

up and places a first order.

The companion Phase 2 work in [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 - Favorites + User Cookbooks]] introduces

=Cookbook.owner= and =Cookbook.kind= so non-staff callers can write

=kind=user= Cookbooks. This spec extends that change with the *sharing*

primitives: visibility states, a short opaque share token, and a referral

attribution path. The two specs ship together (or sharing ships immediately

after user-cookbook CRUD), share the same =owner= column, and never touch

the Favorites proxy.

High-Level Solution

- *Three additive fields on* =Cookbook=. =owner= (FK → =Customer=, nullable

to preserve existing admin rows), =visibility= (tri-state enum:

=PRIVATE= / =UNLISTED= / =PUBLIC=), =share_token= (16-char unique

URL-safe string, nullable, lazily set on first share, rotatable). One

migration; existing admin cookbooks backfill to =visibility=PUBLIC= to

preserve current public-browsing behavior.

- *Token-keyed share URL.* =GET /api/v3/cookbooks/shared/<str:token>/=

returns the cookbook envelope for any caller (=AllowAny=) as long as

=visibility != PRIVATE=. Frontend reserves =/c/<token>= as the canonical

short URL.

- *Owner-only rotation.* =POST

/api/v3/cookbooks/<int:pk>/share-token/rotate/= mints a new token and

invalidates the previous one. Authenticated, owner check inside the view.

- *Serializer guard.* =share_token= is exposed in the general

=CookbookSerializer= only when the requesting user is the owner. A

dedicated =CookbookSharedSerializer= is used by the shared-detail

endpoint so the token + =owner_referral_code= never leak via list

responses or admin upserts.

- *Referral credit reuses existing rails.* The shared response includes

=owner_referral_code=; the frontend stamps the visitor's session via

the existing =/r/<code>= flow. =award_referral_bonus()= fires unchanged

on the referee's first order.

Experiment

*Hypothesis*: If we let customers share user-created Cookbooks via a short

URL and credit the sharer through the existing referral system, we will see

incremental new-customer acquisition through organic sharing without

degrading the public Cookbooks browsing experience.

*Experiment cleanup if it wins:* Keep the share primitives. Remove only the

client-cutover gate (if added). The =owner=, =visibility=, =share_token=

columns and the new endpoints become permanent.

*Experiment cleanup if it loses:* Delete the two new view classes, the

=cookbooks/share.py= helper, and =CookbookSharedSerializer=. The schema

fields can stay (nullable, no rows in losing arms beyond admin backfill of

=visibility=PUBLIC=) or a follow-up migration drops them. No data to roll

back.

Dynamic Configs

One optional DC:

- =cookbook_sharing_enabled= (bool, default =False=): When false the

=/api/v3/cookbooks/shared/<token>/= and =/share-token/rotate/= endpoints

return 404. Pure feature gate; no data-shape implication since the

underlying columns are populated either way.

No dual-write or backfill DC is needed.

Launch Plan

TBD with Product. See *Cutover Plan* below.

Implementation

All changes land in =cookbooks/=. No changes to =app/= models, REST, or

middleware.

Apps

- =cookbooks= :: EXTEND (model fields, new helper module, two views,

serializer field + new serializer class, URL wiring, list-view filter

tweak)

- =app= :: NO CHANGE. =Customer.referral_code= and the =/r/<code>=

redirector are reused as-is.

Models & Fields

Add to =cookbooks/models.py= near the top:

#+begin_src python

class CookbookVisibility(TextChoices):

PRIVATE = "private", "Private"

UNLISTED = "unlisted", "Unlisted"

PUBLIC = "public", "Public"

#+end_src

Extend =Cookbook= (=cookbooks/models.py:50-94=) with:

#+begin_src python

class Cookbook(BaseModelV1):

# ... existing Exp 232 fields ...

owner = ForeignKey -> app.Customer (

null=True,

blank=True,

on_delete=SET_NULL,

related_name="cookbooks",

)

visibility = CharField(

choices=CookbookVisibility.choices,

default=CookbookVisibility.PRIVATE,

db_index=True,

)

share_token = CharField(

max_length=16,

unique=True,

null=True,

blank=True,

db_index=True,

)

#+end_src

Notes:

- =owner=NULL= is the marker for admin/global cookbooks; preserves all

existing rows without backfill.

- =share_token= is lazily set the first time a cookbook is shared; rotation

overwrites in place.

- =owner= overlaps with the Phase-2 favorites spec's =owner= addition.

These specs ship together (or sharing ships after the Phase-2 columns

are already in place) so the migration appears in exactly one PR.

Migration

Single migration in =cookbooks/migrations/=:

- Add the three columns above.

- Backfill: =UPDATE cookbook SET visibility = 'public' WHERE owner_id IS

NULL;= so existing admin cookbooks remain browsable on the public list.

- Leave =share_token= NULL on all backfilled rows.

APIs

Retrieve Shared Cookbook

=GET /api/v3/cookbooks/shared/<str:token>/= → =CookbookSharedDetailView=

- =permission_classes = [AllowAny]= (mirrors =CookbookDetailView= at

=cookbooks/views.py:541-559=).

- Lookup by =share_token=. Returns 404 if not found *or* if

=visibility == PRIVATE= (a token may still exist from a prior shared

state but must never be reachable while private).

- Serialized via =CookbookSharedSerializer= so neither =share_token= nor

=owner_referral_code= can leak through the general serializer.

*Response (sketch):*

#+begin_src json

{

"id": 4321,

"slug": "summer-grilling",

"name": "Summer Grilling",

"owner_display_name": "Colin P.",

"owner_referral_code": "aB3xK9p2",

"visibility": "unlisted",

"sections": [ /* same shape as CookbookDetailView */ ]

}

#+end_src

=owner_referral_code= is populated only when =visibility ∈ {unlisted,

public}= and =owner_id IS NOT NULL=, and is omitted when the requesting

user is authenticated as the owner (self-referral guard; belt-and-

suspenders, since =award_referral_bonus()= and =redeem_promo_code()=

already block self-credit).

Rotate Share Token

=POST /api/v3/cookbooks/<int:pk>/share-token/rotate/= →

=CookbookRotateShareTokenView=

- =permission_classes = [IsAuthenticated]=, owner-only check in =post()=

(=request.user.customer == cookbook.owner=).

- Calls =rotate_share_token(cookbook)=; returns the new token in the

response body. The previous token immediately 404s on the shared-detail

endpoint.

Public List Filter

=CookbookListView= (=cookbooks/views.py= around line 515): default

queryset gains a =visibility=PUBLIC= filter for anonymous / non-owner

callers. Admin =include_inactive= path stays untouched. Owner-private and

unlisted cookbooks are excluded from public listings; unlisted cookbooks

are reachable only via direct token URL.

Out of scope here

- Owner CRUD endpoints for user-created cookbooks (list-mine, create,

update visibility, delete) — they belong to the user-cookbook scaffolding

effort and are wired separately.

- Username-namespaced vanity URL (=/c/<owner>/<slug>= for =public=

cookbooks). Additive, does not conflict with the token route.

Services

All token logic lives in =cookbooks/share.py=:

#+begin_src python

# cookbooks/share.py

import secrets

from django.db import IntegrityError

from cookbooks.models import Cookbook

_TOKEN_BYTES = 8

_MAX_RETRIES = 3

def generate_share_token() -> str:

# secrets.token_urlsafe(8) -> 11-char URL-safe base64, ~64 bits entropy.

return secrets.token_urlsafe(_TOKEN_BYTES)

def assign_share_token(cookbook: Cookbook) -> str:

if cookbook.share_token:

return cookbook.share_token

for _ in range(_MAX_RETRIES):

cookbook.share_token = generate_share_token()

try:

cookbook.save(update_fields=["share_token"])

return cookbook.share_token

except IntegrityError:

continue

raise RuntimeError("Could not allocate a unique share_token")

def rotate_share_token(cookbook: Cookbook) -> str:

cookbook.share_token = None

return assign_share_token(cookbook)

#+end_src

Key properties:

- *Idempotent on assign.* =assign_share_token= returns the existing token

if one is set; never silently rotates.

- *Bounded retry.* Three attempts on =IntegrityError= is enough at any

foreseeable scale (~64 bits of entropy); revisit token length only if

collision rate becomes measurable.

- *Single source of truth for the token shape.* Views never call

=secrets.token_urlsafe= directly.

Serializers

In =cookbooks/serializers.py= (around =CookbookSerializer= at line 266):

- Add =share_token= as a =SerializerMethodField= that returns the token

only when =self.context["request"].user.customer == obj.owner=.

Otherwise the field is =None= / omitted. Prevents the unlisted token

from leaking via any public response that happens to embed the

cookbook.

Add =CookbookSharedSerializer= for =CookbookSharedDetailView=:

- Exposes the public-safe cookbook fields plus =owner_referral_code= and

=owner_display_name=.

- Lazily ensures =cookbook.owner.referral_code= is populated before

serialization. *Reuses the existing reset/generate helper in

=app/services/api/pricing.py= (the one called at signup) — do not copy

the generation logic.* Keeps =PromoCode= side-effects consistent.

Referral Attribution on Shared Cookbooks

Reuse the existing referral rails — do not build a parallel attribution

system.

*Existing rails (verified):*

- =Customer.referral_code= — 8-char unique, generated via

=utils.generate_short_hash(8)= in =app/services/api/pricing.py:59=.

Doubles as a =PromoCode= (=type=REFERRAL_DISCOUNT=, =customer=referrer=).

- =/r/<code>= redirector (=app/views/web.py:388-407=) stamps

=request.session["promo"]= + =referral_code= cookie.

- =PromoMiddleware= (=app/middleware.py:178-212=) reads session /

=?promo== and redeems via =api.redeem_promo_code()= before first

purchase.

- =award_referral_bonus()= (=app/services/api/pricing.py:350-376=)

issues credit on referee's first order, idempotent via =CustomerLog=

(=ref3="Referral"=).

*Plan:*

1. =CookbookSharedDetailView= response carries =owner_referral_code=.

If =cookbook.owner.referral_code= is empty, call the shared

reset/generate helper before serializing.

2. Frontend, on loading =/c/<token>=, fires the same cookie-stamping

flow =/r/<code>= already triggers (easiest: hidden fetch to

=/r/<code>= so =redirector()= runs unchanged; or a future minimal

JSON endpoint that only sets the session/cookie — frontend choice,

out of scope here).

3. *Self-referral guard*: if the visitor is already authenticated as

the cookbook owner, =owner_referral_code= is omitted entirely

(belt-and-suspenders even though the underlying credit paths already

guard against self-credit).

4. No new credit logic. =award_referral_bonus()= fires unchanged when

the referee places their first order.

Caching

The shared-detail endpoint is keyed by an opaque token and is rendered

per-request; do not place it behind the public Cookbooks CDN cache. The

Phase-2 curated Cookbooks list keeps its existing CDN strategy from Exp

232 — the new =visibility=PUBLIC= filter is compatible with that cache

because all currently cached rows backfill to =PUBLIC=.

Django Settings

No new app registration. Add the optional DC

=cookbook_sharing_enabled= to dynamic config.

Cutover Plan

There is no data migration beyond the additive column backfill described

under *Models & Fields*. Cutover is purely deploy + feature gate.

1. Land schema migration + =cookbooks/share.py= + serializer guard

without exposing the new endpoints (URLs gated on

=cookbook_sharing_enabled=, default =False=).

2. Verify in staging: existing admin cookbooks return =visibility=public=

and remain listable; the public list endpoint excludes

private/unlisted/owner-private rows.

3. Ship the two new views and the URL routes behind the same DC.

4. Frontend ships the =/c/<token>= page and the share / rotate UI under

the same feature flag.

5. Flip =cookbook_sharing_enabled= for the experiment cohort. Compare

new-customer acquisition from share-attributed sessions vs. the

holdout.

6. (Post-experiment) On a win, remove the DC gate; on a loss, delete

the two new view classes, the URL routes, the helper, and

=CookbookSharedSerializer=. Columns can stay or be dropped in a

follow-up.

Risks & Mitigations

| Risk | Mitigation |

|---------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

| Unlisted token leaks through a list response or admin upsert | =share_token= exposed only via =SerializerMethodField= guarded by owner check. Shared endpoint uses dedicated =CookbookSharedSerializer=; never reuses =CookbookSerializer= for that view. |

| Token collision at scale | ~64 bits of entropy; 3-attempt retry on =IntegrityError= inside =assign_share_token=. Revisit length only when collision rate becomes measurable. |

| Owner has no =referral_code= when their cookbook is first shared | =CookbookSharedSerializer= lazily ensures one via the same reset/generate helper used at signup; never duplicates the generation logic. |

| Self-referral credit | View omits =owner_referral_code= when caller is authenticated as owner; =award_referral_bonus()= + =redeem_promo_code()= already guard at the credit layer. |

| Existing admin cookbooks disappear from public list after migration | Migration backfills =visibility=PUBLIC= for all =owner IS NULL= rows. Verified in staging before frontend rollout. |

| Rotate endpoint abused to invalidate shared links rapidly | Out of scope for v1. Add per-cookbook rate limit if/when measured. Flagged under *Future / Out of Scope*. |

Data Analytics

Track:

- Shared cookbook GET requests, with properties: =visibility=,

=owner_id=, anonymous-vs-authenticated.

- Share-token rotations.

- Sign-ups attributed to a shared-cookbook session (via existing

=CustomerLog= entries from =award_referral_bonus=, filtered by

source = share).

Segment

TBD with Data.

Back Office Considerations

Django Admin

Extend the existing =cookbooks= admin from Exp 232:

- Filterable list on =visibility= and =owner=.

- =share_token= visible to staff (read-only); rotation button optional.

- Owner-owned cookbooks should be effectively read-only for staff

(avoid editing customer-authored content); only =is_active=,

=visibility=, and =share_token= are editable.

Observability

Feature-level =structlog= events grouped under ="Cookbook Sharing"= with

=event_type==:

- =shared_view= — emitted on successful GET. Properties: =cookbook_id=,

=owner_id=, =visibility=, =authenticated= (bool).

- =share_token_rotated= — emitted on successful rotate. Properties:

=cookbook_id=, =owner_id=.

- =share_token_assigned= — emitted on first lazy assignment in

=assign_share_token=. Properties: =cookbook_id=, =owner_id=.

Referral credit events continue to flow through =award_referral_bonus()=

and =CustomerLog=; no new credit-side logging.

Technical Debt

*Debt incurred*

- =owner= column is nullable to preserve existing admin rows. New code

has to remember that =owner IS NULL= means "admin cookbook" rather

than "missing data." Documented in the model docstring; admin filters

enforce visibility of the distinction.

*Debt avoided*

- No parallel attribution system. Sharing rides on

=Customer.referral_code= + =/r/<code>= + =PromoMiddleware= +

=award_referral_bonus()= unchanged.

- No bespoke share-credit log table.

- No custom token-storage scheme; one column, one unique index.

Open Questions

- *Referral on =public= cookbooks?* Default here: yes — every share is

creditable. Easy to flip to "only =unlisted= shares earn credit" if PM

prefers.

- *Stamping mechanism on =/c/<token>=.* Hidden fetch to =/r/<code>= vs.

dedicated cookie-only endpoint. Frontend choice; doesn't affect the

backend response shape.

- *Vanity URL for public cookbooks* (=/c/<owner>/<slug>=). Out of scope

for v1; additive when wanted.

- *Audit log / metrics on token rotations.* Out of scope; revisit if

abuse appears.

- *Rate-limit on rotate endpoint.* Out of scope; revisit on abuse.

Ticket Breakdown

| Name | Estimate | Description | QA |

|-----------------------------------------------------+----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------|

| TODO ticket - Schema + migration | 1 | Add =CookbookVisibility= enum and =owner= / =visibility= / =share_token= columns to =Cookbook=. Backfill admin rows to =visibility=PUBLIC=. Model docstring updates. | TODO |

| TODO ticket - =cookbooks/share.py= token helper | 1 | =generate_share_token=, =assign_share_token=, =rotate_share_token=. Unit tests for retry-on-collision, idempotent assign, rotate-overwrites-previous. | TODO |

| TODO ticket - Serializer guard + shared serializer | 1 | =share_token= owner-guarded =SerializerMethodField= on =CookbookSerializer=. New =CookbookSharedSerializer= with =owner_referral_code= + lazy referral-code generation via existing pricing helper. Tests for leak prevention and self-referral guard. | TODO |

| TODO ticket - Shared GET + rotate endpoints | 2 | =CookbookSharedDetailView= and =CookbookRotateShareTokenView=. URL wiring under =cookbooks/urls_v3.py=. =cookbook_sharing_enabled= DC gate. Tests: 200 unlisted/public, 404 private/unknown, 401 anon rotate, 403 non-owner rotate, 200 owner rotate, old token 404s. | TODO |

| TODO ticket - Public list filter + admin tweaks | 1 | Default-public filter on =CookbookListView=. Admin filters by =visibility= / =owner=; owner-owned cookbooks effectively read-only for staff. | TODO |

| *Total* | 5–6 | | |

Appendix

Glossary

- *Cookbook* :: A Hungryroot cookbook (=cookbooks.Cookbook=, defined at

=cookbooks/models.py:50-94=). Originally admin-authored under Exp 232.

- *Owner* :: =Cookbook.owner= → =app.Customer=. =NULL= for legacy

admin/global cookbooks; non-null for user-created cookbooks (Phase 2

of [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 Favorites]]).

- *Visibility* :: =CookbookVisibility= enum on =Cookbook=: =PRIVATE= (not

exposed anywhere outside owner's own surfaces), =UNLISTED= (reachable

only by direct share token), =PUBLIC= (appears in public list).

- *Share token* :: 11-char URL-safe base64 string (=secrets.token_urlsafe(8)=)

stored in =Cookbook.share_token=. Opaque to clients; rotatable.

- *Referral code* :: =Customer.referral_code= — existing 8-char unique

string; doubles as a =PromoCode= of type =REFERRAL_DISCOUNT=.

Pre-existing mechanism, reused here unchanged.

- */c/<token>* :: Canonical short share URL on the marketing site.

Resolves to a frontend page that calls

=GET /api/v3/cookbooks/shared/<token>/= and stamps the referral

cookie via the existing =/r/<code>= flow.

Inline Review Comments

Open

- TODO — none yet; ready for first review pass.