Skip to content

feat: v1.10.0 — allergens, multi-image recipes, public sharing#326

Merged
gregqualls merged 9 commits into
mainfrom
feature/allergens-v1.10.0
May 21, 2026
Merged

feat: v1.10.0 — allergens, multi-image recipes, public sharing#326
gregqualls merged 9 commits into
mainfrom
feature/allergens-v1.10.0

Conversation

@gregqualls

@gregqualls gregqualls commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

v1.10.0 release. Six PRs into the integration branch, two user-visible features:

  • Sharable recipes (#311) — share token URLs at /r/{token}, Blade-rendered for OG/SEO, opt-in family attribution. Carved multi-image support (#323) out so cookbook-scan thumbnails wouldn't leak into shared recipes.
  • Allergen tagging & filtering (#312) — Big 9 + family customs, tri-state per recipe (contains / may-contain / absent), per-member reviewed profiles, false-safe filtering, AI-assisted backfill with confidence routing, meal-planner acknowledgement guard.

Architecture decision: DEC-013 — severe-stakes allergen system design (false-safe filtering invariant, confidence routing, provenance tracking, mass-assignment hardening).

Sub-PRs included

  • #319 — allergen foundation (schema, profiles, family settings)
  • #320 — recipe allergen tagging (manual)
  • #321 — filtering + meal planner safety
  • #322 — AI integration + backfill
  • #324 — multi-image recipes
  • #325 — public recipe sharing

Pre-merge /review pass

/review flagged 3 BLOCK + 11 WARN before this PR opened. All addressed:

  • share_token, share_visible_attribution, allergen_profile_reviewed_at removed from $fillable — explicit forceFill only.
  • UserAllergenPolicy gates reads (family-scoped) and writes (parent or self).
  • Backfill job validates family_id and short-circuits on mismatch.
  • Per-family rate limits on backfill: 30/min/family on the worker, 2/day/family on dispatch.
  • Public recipe Blade page: noindex,nofollow, decimal-to-fraction conversion.
  • 13 new MCP actions on KinholdFood covering the full allergen + sharing surface.

Closes

Closes #311
Closes #312
Closes #323

Test Plan

  • 443/443 PHPUnit tests green
  • Pint, PHPStan, Prettier clean
  • Dogfooded share flow end-to-end on local preview (modal copy, preview link, public page render, print stylesheet)
  • Allergen meal-planner guard verified (409 + ack flow)
  • Mobile (375px) verified for share modal + public page
  • CI green
  • Upsun deploy succeeds after merge

Checklist

  • Tested locally
  • Mobile-first verified (375px)
  • Dark mode verified
  • No credentials committed
  • API changes documented (REFERENCE.md Module 10)
  • Version bumped to 1.10.0 (will be tagged by /merge)

Session Handoff (auto-updated by /handoff)

Updated: 2026-05-21 13:50
Branch: feature/allergens-v1.10.0
Last commit: 4553295 fix: CI test failures from v1.10.0 changes

What was done this session

Quality state

  • PHPUnit: 443/443 green on CI (Tests (PHP 8.4) job, run 26244125973).
  • Pint, PHPStan (level + memory limits), Prettier, ESLint, Frontend build, CodeQL, Lint & Static Analysis: all PASS on CI.
  • Upsun preview environment: deployed at https://pr-326-et2izmq-2rozcvqjtjdta.ch-1.platformsh.site/.

What's next

  1. /merge PR feat: v1.10.0 — allergens, multi-image recipes, public sharing #326 into main. Will squash, delete branch, tag v1.10.0 (config/version.php already bumped), and monitor Upsun deploy of the main environment.
  2. /cleanup skill to prune local branches and worktrees that piled up across the six sub-PRs.
  3. Phase B punch list: only Enhancement: Show Kinhold icon in Claude Desktop MCP connector #99 (Kinhold icon in Claude Desktop MCP connector) remains open under Phase B, low priority. Phase C/D items (Two-way Google Calendar sync (create/edit events) #71 two-way Google Calendar, AI photo-to-calendar: snap a flyer, extract events #74 AI photo-to-calendar, Family announcements / bulletin board #72 announcements) are the next meaningful feature work.

Blockers / gotchas

  • None. CI fully green, preview deploy is up, version is bumped, CHANGELOG + ROADMAP + REFERENCE + ARCHITECTURE all written.

Open questions

  • None.

gregqualls and others added 9 commits May 21, 2026 10:15
…314) (#319)

PR 1 of 4 toward v1.10.0. Targets feature/allergens-v1.10.0.

Schema:
- allergens table with global Big 9 seed (family_id null) + per-family customs
- member_allergens pivot (UUID-keyed, flat user↔allergen binary)
- users.allergen_profile_reviewed_at nullable timestamp (drives dashboard prompt)

Backend:
- Allergen model with availableToFamily(string $familyId) scope (Big 9 union customs)
- MemberAllergen pivot model (HasUuids)
- AllergenPolicy: parents may create; only the owning family may rename/delete
  their own customs; Big 9 are immutable for everyone
- AllergenController: list / add / rename / delete customs
- UserAllergenController: read / replace member profile + mark-reviewed.
  Parents may edit anyone; members may edit only their own. Cross-family
  access 404s. PUT with empty array still counts as "reviewed".
- Routes gated by `module:food` middleware (food disabled → 403 on all paths)

Vue (Composition API, design-system aligned, mobile-first):
- Pinia allergens store (list + per-member profiles + CRUD actions)
- AllergenSelector: chip-based multi-select. Big 9 row + family customs row.
  Reusable; recipe form (PR 2) will consume it.
- AllergyProfileEditor: wraps selector + Save / "Confirm: no allergies"
- AllergyProfileReviewBanner: dashboard banner shown until current member's
  profile has been reviewed (food module on + profile loaded + not yet reviewed)
- FamilyAllergenSettings: Big 9 read-only chips + family customs list with
  inline rename/delete (parents only) + add-custom form
- Settings: new "Allergens" collapsible section (parents) and "My Allergies"
  card (members), both hidden when food module is off
- Dashboard: mounted the review banner above the widget grid

Tests: 19 new in AllergenTest covering Big 9 seed, CRUD permission matrix,
member profile read/write/mark-reviewed, cross-family scoping, module gating.
Total suite: 383 tests, 1004 assertions, green.

Manually verified in preview: banner → /settings#allergens → add Cinnamon →
toggle Peanuts on Adaeze → Save → banner disappears on dashboard.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…320)

* feat: recipe allergen tagging (manual) — pivot, badges, form, detail (#315)

PR 2 of 4+1 toward v1.10.0. Targets feature/allergens-v1.10.0.

Schema:
- recipe_allergens pivot: (recipe, allergen, presence, source, confidence,
  confirmed_by, confirmed_at), UUID-keyed via RecipeAllergen pivot model.
  Unique on (recipe_id, allergen_id, presence) so the same allergen can be
  carried at both contains and may_contain on one recipe.
- AllergenPresence enum: contains | may_contain
- AllergenSource enum: ai_auto | ai_suggested | human_confirmed | imported
  (only human_confirmed is written in this PR; AI sources land in #317)

Backend:
- Recipe::allergens() BelongsToMany with pivot fields
- StoreRecipeRequest + UpdateRecipeRequest accept `allergens: [{allergen_id,
  presence}]` with enum validation
- RecipeService::syncAllergens replaces a recipe's allergen list. All rows
  written by the form path are stamped human_confirmed + confirmed_by/at.
  availableToFamily() guard silently drops any allergen not visible to the
  recipe's family.
- RecipeAllergenController for single-row edits: POST adds (idempotent on
  duplicate via firstOrCreate), PATCH confirms / changes presence / removes.
  Used by recipe detail (PR 4 wires AI-tag confirmations through it).
- RecipeResource exposes allergens with full pivot shape (presence, source,
  confidence, confirmed_at) so the frontend can render unconfirmed badges
  in PR 4 without touching the API again.
- show + searchRecipes eager-load allergens

Vue:
- AllergenBadge: single allergen chip. Contains = status-error tint;
  may_contain = status-warning + dashed border. Optional `unconfirmed` prop
  reserved for PR 4 (subtle opacity + dashed outline).
- AllergenBadgeRow: wraps a recipe.allergens array into a badge row with
  optional `limit` + "+N more" indicator (used on cards).
- RecipeForm: new Allergens section with tri-state cycle per chip
  (off → contains → may_contain → off). Tap once / twice / three times.
  Hidden when food module is off. Auto-loads allergens on mount.
- RecipeCard renders AllergenBadgeRow under the card (limit 3).
- RecipeDetailView renders the full badge row inline with meta info.

Tests: 16 new in RecipeAllergenTaggingTest. Total suite: 399 tests,
1048 assertions, green. Pint clean, PHPStan clean.

Manually verified in preview: edit Banana Bread → cycle Wheat (contains) +
Tree nuts twice (may_contain) → Update → detail page shows
"Wheat / gluten" + "May contain tree nuts" → recipes list also surfaces
the badges on the card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: clarify Family Allergens header as informational, not selectable

The read-only Big 9 chip row in FamilyAllergenSettings was visually
indistinguishable from the selectable chips in the per-member AllergyProfileEditor
below, leading a tester to assume those top chips should be clickable. Replaced
with a single descriptive line that names the Big 9 and points users to the
per-member profiles below for actual selection.

The selectable chips remain unchanged — they continue to live inside each
member's AllergenSelector (still includes Big 9 + family customs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: allergen filtering + meal planner safety guard (#316)

PR 3 of 4+1 toward v1.10.0. Targets feature/allergens-v1.10.0.

Recipe list filtering:
- searchRecipes() accepts safe_for_members[]=<member_id> and safe_for=all.
  Excludes any recipe carrying a contains|may_contain allergen on the given
  reviewed-profile members. Members without a reviewed profile are silently
  dropped — better to under-filter than to mark a recipe "safe for someone
  we never asked about."
- RecipeController index forwards the new params.

UI — Safe-for filter chip:
- SafeForMemberFilter.vue: dropdown chip listing family members with reviewed
  profiles. Multi-select with checkboxes. Loads each member's profile lazily.
- Wired into RecipesTab toolbar (food module only). Selection drives a refetch
  via the recipes store's new safeForMemberIds state.

Meal planner safety:
- AllergenAcknowledgementRequired exception renders 409 with hits[] and
  requires_acknowledgement=true.
- MealPlanService::addEntry() checks the recipe against reviewed family
  profiles. Throws unless data.acknowledge_allergens === true.
- StoreMealPlanEntryRequest accepts the acknowledge_allergens flag.
- MealPlannerAllergenWarning.vue: modal grouping hits by member, severity-coded
  copy (contains = red, may_contain = yellow), explicit "I understand, plan
  anyway" button.
- mealsStore.addEntry surfaces requiresAllergenAck + retry callback when 409
  comes back. MealEntryPicker shows the modal and re-submits with the
  acknowledgement on confirm.

Tests: 9 new in AllergenFilteringTest (safe_for_members excludes correctly,
may_contain treated as unsafe, safe_for=all skips unreviewed members,
empty-allergen member returns everything, unreviewed member silently skipped,
planner refuses without ack, acknowledged plan succeeds, no-allergen recipe
proceeds, unreviewed-profile match does not block). Total suite: 417 tests,
1098 assertions, green.

Manually verified in preview: Adaeze (peanuts profile) + Chocolate Chip
Cookies (peanuts) — list filter excludes the recipe; planner returns 409
with hit details; modal renders with member + allergen; "I understand"
re-submits with acknowledge_allergens=true and the entry lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: allergen palette renders as red — was silently transparent (status-error → status-failed)

The allergen UI was authored against Tailwind tokens `bg-status-error`,
`text-status-error`, `border-status-error` and friends — but the design
system only ships `status-failed` (Kinhold convention for red). Tailwind
silently generated no CSS for the `-error` variants, so:

- the "contains" chip in the recipe form rendered as the default sunken pill
  (Greg: "turns white which doesn't feel selected")
- AllergenBadge "contains" rendered as ink text on no background
- MealPlannerAllergenWarning's red iconography + per-member bullets were
  monochrome
- inline error messages in FamilyAllergenSettings / AllergyProfileEditor
  rendered as default ink

Renamed every `status-error` reference across the five new allergen-related
components + RecipeForm to `status-failed`. Also bumped the contains chip
in the recipe form to a solid fill (`bg-status-failed text-white
border-status-failed`, `border-2`, `font-semibold`) so the selected state
reads as unambiguous before the user even sees the label change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…322)

PR 4 of 4+1 toward v1.10.0. Targets feature/allergens-v1.10.0.

AI extraction on import:
- Both URL_SYSTEM_PROMPT and PHOTO_PROMPT are now built dynamically per family
  (Big 9 + family customs) so the model returns slug-anchored allergen tags.
- Prompts ask the model to emit `allergens: [{slug, presence, confidence}]`
  with conservative rules and explicit guidance for sneaky cases
  (wheat in soy sauce / beer, milk in butter / cream, eggs in mayo).
- JSON-LD imports skip the LLM entirely, so we run a separate
  `extractAllergensFromIngredients()` pass over the parsed ingredient list
  so coverage is consistent across import methods.

Confidence routing:
- `persistAiAllergens()`: confidence ≥ 0.95 → source = ai_auto. Otherwise
  source = ai_suggested. Idempotent on (recipe, allergen, presence).
  Never overwrites a human-confirmed row.

Frontend:
- `RecipeForm.populateFromImportPreview()` now translates AI's `{slug, presence,
  confidence}` shape to the form's `{allergen_id, presence}` shape, fetching
  the allergens store if it hasn't loaded yet. Result: AI suggestions land
  pre-checked in the form, the user can adjust or accept.
- `AllergenBadgeRow` gets an `editable` mode. Unconfirmed (ai_auto /
  ai_suggested) badges render with a small `✓` button beside them; clicking
  hits PATCH and flips the row to human_confirmed.
- `RecipeDetailView` passes `editable={isParent}` so only parents can confirm
  AI tags.
- `RecipeResource` now exposes `pivot_id` so the frontend can target the
  pivot row by ID for confirm.
- `recipesStore.confirmAllergen()` patches in place and stamps the local
  recipe's source so the badge re-renders without a refetch.

Backfill:
- `php artisan recipes:backfill-allergens` command with `--family`,
  `--force`, and `--sync` flags. Dispatches `BackfillRecipeAllergens` job
  per recipe. Idempotent: skips recipes with existing rows unless `--force`.
- Parent-only `POST /api/v1/recipes/allergens/backfill` endpoint queues the
  same jobs for the requesting family. Returns the count queued.
- `BackfillRecipeAllergens` job is safe to re-run: skips already-tagged
  recipes, skips recipes with no ingredients, swallows extraction failures
  with a warning log (no retries that would chew through API budget).

No-AI fallback:
- The dynamic prompt builder is purely additive — when AI is disabled or
  fails, the existing manual tagging path from PR2 is unchanged. JSON-LD
  imports also still work without AI; the secondary allergen pass returns
  an empty array if the family has no AI configured.

Tests: 11 new in AllergenAiIntegrationTest covering confidence routing,
idempotency, ignoring unknown slugs, PATCH confirm → human_confirmed +
confirmed_by stamping, pivot_id exposure in RecipeResource, parent-only
backfill endpoint, queueing logic with/without --force, job idempotency
on already-tagged recipes, job no-op on empty ingredients, mocked
extraction flow end-to-end. Total suite: 419 tests, 1098 assertions, green.

Manually verified in preview: parent user → recipe detail view → manually
flipped a row to ai_suggested via SQL → page reload showed dashed
"unconfirmed" badge + green checkmark button → click confirm → API PATCH
→ source becomes human_confirmed → checkmark disappears, badge becomes
solid.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…) (#324)

PR 5 of 6 toward v1.10.0. Targets feature/allergens-v1.10.0.
Sub-PR carved out of #311 (public sharing) because cookbook-scan thumbnails
make sharing pages look bad; multi-image is the prerequisite.

Schema:
- recipe_images table: (recipe_id, path, sort_order, is_primary, timestamps)
  UUID-keyed.
- Data migration backfills existing recipes.image_path into a single
  primary recipe_images row per recipe. recipes.image_path stays as a
  denormalized cache of the primary's path so cards / cross-cutting readers
  don't break.

Backend:
- Recipe::images() HasMany sorted by sort_order
- RecipeImage model (HasUuids, casts is_primary/sort_order)
- RecipeService::syncImages(): diff against existing rows by id (preserves
  IDs across edits), creates new rows for new paths, deletes dropped paths.
  Sets is_primary based on the first image (or explicit flag). Mirrors the
  primary path into recipes.image_path.
- StoreRecipeRequest + UpdateRecipeRequest accept `images: [{id?, path,
  sort_order?, is_primary?}]`
- Legacy single image_path still works (gets converted into a single
  primary image entry by the service)
- RecipeResource exposes `images: [{id, path, sort_order, is_primary}]`;
  show() + searchRecipes() eager-load the relation

Frontend:
- RecipeImageGallery: thumbnail strip with drag-to-reorder (SortableJS),
  tap-to-set-primary (star button), delete (trash button), and an "Add
  photo" tile that handles multi-file upload. Primary thumbnail wears a
  lavender border + Primary badge. Uploading shows a dimmed local preview
  until the server returns a path.
- RecipeForm wires the gallery to form.images and ships the full list on
  Save. Replaces the single PhotoUpload.
- RecipeDetailView: hero of the primary image + scrollable thumbnail row
  of extras below. Tap a thumbnail to swap the hero (client-side only;
  doesn't change the saved primary). This sets the pattern PR6 will reuse
  on the public sharing page.

Tests: 8 new in RecipeImageGalleryTest — multi-image create, image_path
sync to primary, first-image-becomes-primary default, update swaps
primary + appends new, subset removes dropped, empty array clears,
RecipeResource ordering, legacy image_path-only save still works.
Total suite: 427 tests, 1125 assertions, green.

Manually verified in preview: existing seeded recipe loaded with backfilled
image as Primary chip; gallery shows the helper copy and an Add photo
tile; can navigate the form without errors.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: public recipe sharing — token URLs + beautiful Blade page (#311)

PR 6 of 6 toward v1.10.0. Final sub-PR. Targets feature/allergens-v1.10.0.

Schema:
- recipes.share_token (string, nullable, unique) — null = not shared.
- recipes.share_visible_attribution (boolean, default false) — anonymous
  by default. Owner opts in to surface the family name.

Backend:
- RecipeShareController: parent-only
  - POST /api/v1/recipes/{id}/share — mints a 22-char base62 token
    (~131 bits of entropy). Idempotent: re-calling on an already-shared
    recipe returns the same URL. Optional visible_attribution flag on
    initial publish.
  - PATCH /api/v1/recipes/{id}/share — toggles attribution without
    rotating the token.
  - DELETE /api/v1/recipes/{id}/share — revoke. Clears the token + the
    attribution flag. Re-sharing later mints a brand-new token by design
    (old links should die permanently on revoke).
- RecipeResource exposes a `share` object on every detail payload
  (is_shared / url / visible_attribution) so the SPA can render the
  toggle in place.

Public page:
- Web route `/r/{token}` declared before the SPA catch-all.
- PublicRecipeController renders a dedicated Blade view. No auth needed
  — the unguessable token is the access grant. 404 on miss (covers
  never-shared and revoked cases).
- View design takes cues from the Crackin' events layout Greg shared:
  blurred hero image as full-bleed background with a gradient overlay
  for legibility, floating content over the bottom half, category pill
  ("RECIPE" or first food tag), big Plus Jakarta Sans title, 2×2 meta
  cards (Prep / Cook / Total / Serves) with Total highlighted in the
  lavender accent, prominent allergen banner with the same contains
  (red) / may_contain (yellow dashed) treatment used elsewhere in the
  app, source link to the original site if imported, optional extras
  gallery row, two-column ingredients (sticky on desktop) + numbered
  method instructions, notes panel in the sun accent if present, and
  an attribution footer card (anonymous "Shared via Kinhold" by
  default; opts up to "Shared by The Ellis Family" when the toggle is
  on) plus a "Try Kinhold free" CTA. All Tailwind tokens are the same
  ones the SPA uses, so the public page reads native to Kinhold's
  brand.
- OG / Twitter Card meta tags wired from recipe title + description +
  primary image so link unfurls render the recipe card in iMessage /
  Slack / Discord / Twitter.
- Pure SSR — no Vue hydration. Fast, SEO-friendly, low cost.

Frontend (SPA):
- ShareRecipeModal: parent-only sheet on the recipe detail view.
  Publish CTA when not shared. When shared: read-only URL field with
  copy-to-clipboard (✓ feedback for 2s), visible-attribution KinSwitch
  with helper copy explaining the trade-off, and a Revoke action with
  confirm.
- New Share icon-button in the recipe detail header, parent-only.
- recipe.share state mutated locally on modal updates so the modal
  reads the latest state on next open without a refetch.

Tests: 15 new in PublicRecipeSharingTest — share/revoke/re-share token
rotation, PATCH attribution preserves token, attribution fails-422 when
not shared, child cannot publish, resource exposes share state, public
route renders recipe + OG tags + allergens, attribution anonymous by
default, opt-in family name visible, 404 on bad token, no auth required.
Total suite: 442 tests, 1168 assertions, green.

Manually verified in preview: shared Chocolate Chip Cookies → opened
the /r/{token} URL → page rendered with blurred warm hero, Recipe pill,
big title, full meta-card row, allergen banner (Peanuts in red),
numbered method, attribution footer with Kinhold branding + free-tier
CTA, and footer links. Mobile-first layout holds at 375px.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: redesign public recipe page based on dogfood feedback + Preview/Print

Working through Greg's design notes on PR #325:

Hero
- Drop the blurred-photo + floating-card layout; the food image is now full-
  bleed at the top of the page, sharp and unobstructed.
- Title and description overlay the bottom of the image inside a soft gradient
  that fades the image into the page background. Title clamped to 2 lines and
  description to 3 lines so long copy can't push the title out of the hero.
- Drop the redundant "RECIPE" pill.
- Section gets bg-surface-app so any sub-pixel anti-alias seam where the
  gradient meets the body bg is filled by the same color underneath, and the
  gradient itself extends 1px below the image to remove the visible line Greg
  was catching.
- Use the actual Tailwind token (surface-app) — the previous CSS variable
  references to a non-existent surface-base were rendering as transparent,
  which is why the prior gradient overlay had no effect.

Allergens
- Move out of the hero entirely; they now sit at the bottom of the
  Ingredients column under a small "Contains" header.
- Replace emoji glyphs with custom inline SVG line-art icons for the Big 9
  (milk carton, egg, fish, shrimp, almond, two-lobed peanut, wheat stalk,
  bean pod with seeds, sesame seeds). Custom family allergens fall back to a
  neutral warning icon.

Quantities
- Ingredients render as fractions ("2 1/4 cups", "3/4 cup") not decimals
  ("2.25", "0.75"). Ported the SPA's RecipeForm decimalToFraction helper to
  PHP so the public page and the in-app view stay in sync.

Share modal
- Clipboard copy now falls back through document.execCommand('copy') when
  navigator.clipboard rejects (sandboxed iframes, focus issues). The "Copy
  failed" toast only fires when both paths fail.
- Read-only URL input wears a subtle lavender focus ring instead of the
  browser's native warm-gold outline which read as a danger state.
- New "Preview what your family will see" button opens the public URL in a
  new tab so the parent can sanity-check before sharing.

Print
- Dedicated Print button in the top bar (line-art printer icon, parent-
  visible). Wires straight to window.print().
- Print stylesheet hides chrome (top bar, gradient overlay, attribution CTA
  card, footer links, extras gallery), pulls the title out of overlay
  positioning so it flows under the image, constrains the hero to ~220px,
  forces ink-friendly black on white, swaps the lavender step circles to
  solid black, and uses break-inside: avoid on ingredient + method rows.
  Elements opted out of print carry .no-print or data-* hooks.

PWA
- Adds `/^\/r\//` to navigateFallbackDenylist in vite.config.js. New SW
  builds will pass /r/{token} requests through to the server instead of
  serving the cached SPA shell (which is what the 404 Greg saw was — the
  service worker intercepting his shared URL).

Mobile
- Verified at 375px: Print + Try Kinhold buttons fit in the top bar, hero
  image full-bleed, title large and readable in the gradient zone, meta
  cards reflow to a 2×2 grid, ingredients still read cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Security and architecture hardening based on the pre-merge review.

- Harden mass-assignment: remove share_token, share_visible_attribution,
  and allergen_profile_reviewed_at from $fillable; use forceFill where
  the system needs to write them. Defeats the false-safe filtering
  invariant if a request could mass-assign reviewed_at.
- Add UserAllergenPolicy and route reads/writes through it from the
  UserAllergenController.
- Rate-limit allergen backfill: per-family per-minute job middleware and
  per-day per-family dispatch throttle on the route.
- BackfillRecipeAllergens job now takes family_id and short-circuits on
  mismatch. Service centralizes queueing for both the controller and the
  console command.
- Public recipe controller no longer eager-loads creator (unused by
  Blade). Search no longer eager-loads images (cards use image_path).
- updateRecipe now takes the editor User so allergen sync attribution
  is correct.
- KinholdFood MCP tool exposes 13 new actions covering allergens,
  allergen profiles, recipe allergen tagging, backfill, and sharing.
- Docs: add DEC-013 (severe-stakes allergen system) and Module 10
  reference covering allergens, multi-image, and public sharing.
- Public recipe Blade: noindex/nofollow, decimal-to-fraction
  conversion, design polish.
- Tests updated for new ctor sig, forceFill setup, and the family_id
  mismatch defense.

443/443 tests green. Pint, PHPStan, Prettier all clean.
Two CI-only failures from the v1.10.0 surface:

- PublicRecipeSharingTest hit ViteManifestNotFoundException because the
  public recipe Blade view uses @Vite() and CI doesn't compile assets
  before phpunit. withoutVite() in setUp skips manifest resolution for
  these tests; the assertions are about routing/auth/render, not Vite.
- RecipeImportTest::test_url_import_with_json_ld_extracts_recipe was
  asserting Anthropic was never called; the JSON-LD path now runs a
  separate allergen-extraction pass that legitimately hits Anthropic.
  Stub Anthropic with an empty-allergens payload and assert call count
  instead. Recipe assertions still prove the recipe data came from
  local JSON-LD parsing, not from the LLM stub.
@gregqualls gregqualls merged commit 7faf159 into main May 21, 2026
7 checks passed
@gregqualls gregqualls deleted the feature/allergens-v1.10.0 branch May 21, 2026 18:22
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.

Recipes: support multiple images per recipe with drag-reorder + primary selection Recipe allergen tagging and filtering Public recipe sharing pages

1 participant