Public Access
R1 stabilization: pytest harness with transactional db fixture, smoke + alembic + auth + scrape + approval + swiftly tests, github actions ci yaml. Bearer-token admin auth + signed-cookie session for family ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns 202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL fail-fast on empty. R2 deferred-risk spikes: live lucky california fetch (R2-A), full email+per-voter approval click round trip with single-use enforcement (R2-B, console email backend, sendgrid stub). R3-0 phase 3 redesign: replaced playwright html scraper with requests based swiftly json api client. 17 categories, ~10k products per scrape, upsert by (source, external_id). 401 surfaces actionable token-refresh message via ScrapeLog.error_message. Pre-existing defects fixed: shopping_list.py syntax error blocking app import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid kwarg, missing requests dep, calorie_target schema drift, every SQLEnum needed values_callable, 0001 had empty downgrade(), seed had duplicate ingredient rows. Migrations added: 0003 grocery_item.description, 0004 family_profile. calorie_target, 0005 grocery_item.external_id + source + composite index. Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean, frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.5 KiB
5.5 KiB
Context — Recovery Takeover
Why this plan exists
Prior agent marked Phases 1, 2, 3, 7 complete and consensus blockers "addressed" in docs, but verification of the repo shows:
- Auth blocker (review §1.2) closed in docs only — no auth dependency on any router;
/api/admin/scrapeis open. - No tests, no CI; verification matrix from
Review/reviewconcensus.md §6was never run. - Review §2.4 explicitly warned: spike scrape + email-approval BEFORE schema/UI commits. Prior agent did the opposite — schema, full API surface, and UI shell first; scrape unverified, email-approval not started.
/api/admin/scraperuns Playwright synchronously inside the request handler; will time out in production.- Phase 7 UI ships above engines (4/5/9) that don't exist — Dashboard renders meal plans the system can't generate.
Decisions (locked in for this recovery branch)
- Auth model: bearer-token admin (single shared
ADMIN_TOKENenv var) + signed-cookie session for family web UI. Matches what was claimed in ORIENTATION.md "Adversarial Review" section. No public-internet exposure assumed; nginx is sole entrypoint, already correct indocker-compose.yml. - Path canonicalization (R1-B+D): dropped
/listand/plannedsuffixes; routers use@router.get("")(no trailing slash) so the canonical paths are/api/profile,/api/recipes,/api/recipes/ingredients,/api/meals,/api/pantry,/api/shopping-list. Frontendfrontend/src/api/index.tsand smoke tests updated to enforce. - Login bootstrap:
/api/auth/loginsigns the family-profile id; if no profile row exists yet, signs literal "bootstrap" so first-run isn't blocked. Cookie validates regardless; downstream code that needs a real id should re-issue after profile creation. - Recipe-ingredient: stay JSONB-only (already chosen). Do not reopen.
- Household model: keep
family_membertable (already chosen). Do not reopen. - Day-of-week: ISO (1=Mon). Already chosen.
- Migrations: Alembic only. Never
Base.metadata.create_all()at runtime. - Background work: FastAPI
BackgroundTasksfor the scrape now; APScheduler container with--workers 1later (R3-E).
Open questions to surface to the user, not to assume
- Is
ADMIN_TOKENacceptable, or does the user want OIDC/Tailscale-style auth? Default for now: bearer token, easy to swap. - Email backend for the spike: real SendGrid (needs key) or a console/file backend? Default for spike: console backend, swap to SendGrid in R3-C.
Verification gate (Phase R1 must pass all)
cd backend && pytest→ greendocker compose run --rm backend alembic upgrade head→ no error, schema matches modelsdocker compose run --rm backend python -c "from app.main import app; print(app.title)"→ "MealPlanner"docker compose run --rm frontend npm run build→ no errorcurl -X POST http://localhost/api/admin/scrape(no token) → 401curl http://localhost/api/profile(no session) → 200 (read), POST/PUT → 401- CI workflow runs all of the above on push.
Phase ordering rule (do not violate)
R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 verification and R2 spikes pass. If R2 reveals schema impact, schema changes happen on this branch BEFORE R3-A.
Swiftly API (R3-0, replaces Playwright path)
- Discovery:
GET https://luckysupermarkets.com/categories(HTML, no auth). Selector:<a class="swiftlyCouponCategory" href="/categories/<urlencoded slug>">. Slug regex:/categories/(.+)$thenurllib.parse.unquote. Fixture (2026-05-05) yielded 17 distinct slugs (e.g.Product/meat_seafood,Product/produce, ...). - Products:
GET https://prod.swiftlyapi.net/search/api/v1/products/categories?cat=<slug>&store=757&limit=10000withAuthorization: Bearer <SWIFTLY_BEARER_TOKEN>. Response shape:{"products": {"info": {"count": N}, "items": [...], "facets": [...]}}.meat_seafoodreturned 256 items. - Field mapping (item dict → grocery_item):
id(string) → newexternal_idcolumn (migration 0005)name→namedescription→descriptionbrand→brandprimaryImage.url→image_urlprice.ok.regPriceText(e.g."$3.49 /lb") → parsedregular_price(Decimal) +unit(e.g."lb", may be NULL when no/unitsuffix)price.ok.promoArea.promoText(e.g."$2.49 /lb") → parsedsale_price(Decimal); when presentis_on_sale=True, elseis_on_sale=Falseprice.ok.promoArea.validityText(e.g."Valid 04/29/26 - 05/05/26") → ignored for v1 (no migration to add date columns; existingsale_start_date/sale_end_dateleft null)- aisle: extracted from the queried category slug (
Product/meat_seafood→meat_seafood) product_url→ NULL (site has no public product page; per R2-A note kept nullable)
- Auth scoping: bearer header is attached ONLY to
prod.swiftlyapi.netrequests, NOT to the publicluckysupermarkets.comHTML page. Tworequests.Sessionobjects (one with default UA, one with the bearer header). - 401 detection: cannot use
BaseScraper._getbecause it swallows HTTPError into aNonereturn. The new client callssession.get(...)directly and checksresp.status_code == 401BEFOREraise_for_statusto raiseSwiftlyAuthError. Token in.env.exampleexpires hourly per spec; on 401 the scraper aborts with a fixed error_message instructing the admin to refresh the token. - Idempotency key:
(source, external_id)upserts. Migration 0005 addsgrocery_item.external_id(nullable text, indexed; not unique because legacy R2-A rows lack one).