Last active
March 18, 2026 15:15
-
-
Save ryan-lingle/090bb71a82a3b5561afec2f842c8b22f to your computer and use it in GitHub Desktop.
O-1668: VIP Room Shared Capacity — Architecture Briefing & Revised Plan
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>O-1668: VIP Room Shared Add-On — Architecture Briefing</title> | |
| <style> | |
| :root { | |
| --bg: #0d1117; | |
| --surface: #161b22; | |
| --surface-bright: #1c2333; | |
| --border: #30363d; | |
| --text: #c9d1d9; | |
| --text-dim: #8b949e; | |
| --heading: #f0f6fc; | |
| --accent: #58a6ff; | |
| --accent2: #7ee787; | |
| --accent3: #d2a8ff; | |
| --accent4: #ffa657; | |
| --red: #f85149; | |
| --yellow: #e3b341; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.6; | |
| padding: 2rem; | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| } | |
| h1 { color: var(--heading); font-size: 2rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--accent); padding-bottom: 0.5rem; } | |
| h2 { color: var(--accent); font-size: 1.5rem; margin: 2rem 0 1rem; border-left: 4px solid var(--accent); padding-left: 0.75rem; } | |
| h3 { color: var(--accent3); font-size: 1.15rem; margin: 1.5rem 0 0.5rem; } | |
| h4 { color: var(--accent4); font-size: 1rem; margin: 1rem 0 0.5rem; } | |
| p { margin-bottom: 0.75rem; } | |
| a { color: var(--accent); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| code { | |
| font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; | |
| background: var(--surface-bright); | |
| padding: 0.15rem 0.4rem; | |
| border-radius: 4px; | |
| font-size: 0.9em; | |
| color: var(--accent4); | |
| } | |
| pre { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| overflow-x: auto; | |
| margin: 0.75rem 0; | |
| font-size: 0.85rem; | |
| line-height: 1.5; | |
| } | |
| pre code { background: none; padding: 0; color: var(--text); } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1.25rem; | |
| margin: 1rem 0; | |
| } | |
| .card-accent { border-left: 3px solid var(--accent); } | |
| .card-green { border-left: 3px solid var(--accent2); } | |
| .card-purple { border-left: 3px solid var(--accent3); } | |
| .card-orange { border-left: 3px solid var(--accent4); } | |
| .card-red { border-left: 3px solid var(--red); } | |
| .badge { | |
| display: inline-block; | |
| padding: 0.15rem 0.5rem; | |
| border-radius: 12px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .badge-blue { background: rgba(88,166,255,0.15); color: var(--accent); } | |
| .badge-green { background: rgba(126,231,135,0.15); color: var(--accent2); } | |
| .badge-purple { background: rgba(210,168,255,0.15); color: var(--accent3); } | |
| .badge-orange { background: rgba(255,166,87,0.15); color: var(--accent4); } | |
| .badge-red { background: rgba(248,81,73,0.15); color: var(--red); } | |
| .badge-yellow { background: rgba(227,179,65,0.15); color: var(--yellow); } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 0.75rem 0; | |
| font-size: 0.9rem; | |
| } | |
| th { background: var(--surface-bright); color: var(--heading); text-align: left; font-weight: 600; } | |
| th, td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); } | |
| tr:nth-child(even) { background: var(--surface); } | |
| .file-link { font-family: 'SF Mono', Consolas, monospace; font-size: 0.85em; } | |
| .toc { list-style: none; padding: 0; } | |
| .toc li { margin: 0.3rem 0; } | |
| .toc li::before { content: "→ "; color: var(--accent); } | |
| .toc a { color: var(--text); } | |
| .toc a:hover { color: var(--accent); } | |
| .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } | |
| @media (max-width: 768px) { .columns { grid-template-columns: 1fr; } } | |
| .subtitle { color: var(--text-dim); font-size: 1rem; margin-bottom: 2rem; } | |
| .sep { border: none; border-top: 1px solid var(--border); margin: 2rem 0; } | |
| .mermaid-placeholder { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1.5rem; | |
| font-family: monospace; | |
| font-size: 0.8rem; | |
| white-space: pre; | |
| line-height: 1.4; | |
| overflow-x: auto; | |
| } | |
| .highlight { color: var(--accent2); } | |
| .dim { color: var(--text-dim); } | |
| .warn { color: var(--yellow); } | |
| details { margin: 0.5rem 0; } | |
| details summary { | |
| cursor: pointer; | |
| color: var(--accent); | |
| font-weight: 500; | |
| padding: 0.25rem 0; | |
| } | |
| details summary:hover { text-decoration: underline; } | |
| .cursor-link { color: var(--accent); font-family: 'SF Mono', Consolas, monospace; font-size: 0.85em; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>O-1668: Shared VIP Room Add-On</h1> | |
| <p class="subtitle">Architecture Briefing — Masquerade VIP Room shared capacity pool across multiple events</p> | |
| <div class="card card-accent"> | |
| <strong>Business Context:</strong> Masquerade (a team) has a VIP room that is its own venue (separate VenueOwnership). They want to sell access to this room as an add-on during checkout for events at OTHER venues — e.g., 4 events on the same night at 4 different venues, all offering a VIP room add-on that draws from <strong>one shared capacity pool</strong>. | |
| <br><br> | |
| <strong>Scope:</strong> Shared capacity pool only. Manual add-on creation. Membership integration is a follow-up ticket. | |
| </div> | |
| <h2>Table of Contents</h2> | |
| <ul class="toc"> | |
| <li><a href="#architecture">Architecture Decision: Room-Based Shared Capacity</a></li> | |
| <li><a href="#model-addon">Model: AddOn (app/models/add_on.rb)</a></li> | |
| <li><a href="#model-room">Model: Room (app/models/room.rb)</a></li> | |
| <li><a href="#model-fulfillment">Model: FulfillmentMetric (app/models/fulfillment_metric.rb)</a></li> | |
| <li><a href="#model-addon-order-item">Model: AddOnOrderItem (app/models/add_on_order_item.rb)</a></li> | |
| <li><a href="#model-venue-ownership">Model: VenueOwnership (app/models/venue_ownership.rb)</a></li> | |
| <li><a href="#model-calendar-event">Model: CalendarEvent (app/models/calendar_event.rb)</a></li> | |
| <li><a href="#controller-addons">Controller: AddOnsController</a></li> | |
| <li><a href="#checkout-flow">Checkout Flow (Zero Changes Needed)</a></li> | |
| <li><a href="#ability">Authorization: ability.rb</a></li> | |
| <li><a href="#routes">Routes</a></li> | |
| <li><a href="#frontend-admin">Frontend: Admin Add-On Management</a></li> | |
| <li><a href="#frontend-checkout">Frontend: Checkout Components</a></li> | |
| <li><a href="#schema">Database Schema</a></li> | |
| <li><a href="#tests">Test Files & Patterns</a></li> | |
| <li><a href="#plan-summary">Implementation Plan Summary</a></li> | |
| </ul> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="architecture">Architecture Decision: Room-Based Shared Capacity</h2> | |
| <div class="card card-green"> | |
| <h4>Chosen Approach: <code>room_id</code> on AddOn</h4> | |
| <ul> | |
| <li>Add <code>room_id</code> FK to <code>add_ons</code> — links an add-on to a Room for shared capacity</li> | |
| <li>All add-ons with the same <code>room_id</code> share one capacity pool (<code>Room.capacity</code>)</li> | |
| <li>Override <code>sold_out?</code> and availability methods to aggregate across the room pool</li> | |
| <li>Each add-on's FulfillmentMetric still tracks per-add-on sold/reserved</li> | |
| <li><strong>No changes</strong> to existing <code>share_inventory_with_id</code> mechanism</li> | |
| </ul> | |
| </div> | |
| <div class="card card-red"> | |
| <h4>Why NOT extend <code>share_inventory_with_id</code>?</h4> | |
| <ul> | |
| <li><strong>Tree structure mismatch</strong> — <code>share_inventory_with_id</code> requires parent→child hierarchy; this use case is flat (all add-ons are equal peers)</li> | |
| <li><strong>Seated add-on gate</strong> — Validation at <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:85">add_on.rb:85</a> blocks <code>share_inventory_with</code> unless <code>seating_chart_category_key</code> is present</li> | |
| <li><strong>Root add-on problem</strong> — Would require a "root" add-on on a virtual event</li> | |
| </ul> | |
| </div> | |
| <div class="mermaid-placeholder"> | |
| ┌──────────────────────────────────┐ | |
| │ Room (VIP Room, capacity: 50) │ | |
| │ on VenueOwnership #3 │ | |
| └──────────┬───────────────────────┘ | |
| │ room_id FK | |
| ┌──────────────────┼──────────────────┐ | |
| ▼ ▼ ▼ | |
| ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ | |
| │ AddOn (Event A) │ │ AddOn (Event B) │ │ AddOn (Event C) │ | |
| │ VenueOwnership 1 │ │ VenueOwnership 2 │ │ VenueOwnership 1 │ | |
| │ room_id = 7 │ │ room_id = 7 │ │ room_id = 7 │ | |
| └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ | |
| │ │ │ | |
| ┌────────▼─────────┐ ┌───────▼──────────┐ ┌──────▼───────────┐ | |
| │ FulfillmentMetric │ │ FulfillmentMetric │ │ FulfillmentMetric │ | |
| │ sold: 8 │ │ sold: 2 │ │ sold: 0 │ | |
| │ reserved: 1 │ │ reserved: 0 │ │ reserved: 3 │ | |
| └──────────────────┘ └──────────────────┘ └──────────────────┘ | |
| Room capacity: 50 | |
| room_total_committed = 8+1 + 2+0 + 0+3 = 14 | |
| room_sellable_count = max(50 - 14, 0) = 36 | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-addon">Model: AddOn</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb">app/models/add_on.rb</a> <span class="dim">(844 lines)</span></p> | |
| <h3>Associations</h3> | |
| <table> | |
| <tr><th>Type</th><th>Name</th><th>Line</th><th>Details</th></tr> | |
| <tr><td><code>belongs_to</code></td><td>:team</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:35">35</a></td><td>Top-level team</td></tr> | |
| <tr><td><code>belongs_to</code></td><td>:calendar_event</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:36">36</a></td><td>The event (Confirm) this add-on is on</td></tr> | |
| <tr><td><code>belongs_to</code></td><td>:seating_chart_event</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:37">37</a></td><td>Optional, for seated add-ons</td></tr> | |
| <tr><td><code>belongs_to</code></td><td>:share_inventory_with</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:48">48</a></td><td>Self-referential FK for existing inventory sharing (tree-based)</td></tr> | |
| <tr><td><code>has_many</code></td><td>:shared_inventory_add_ons</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:49">49</a></td><td>Reverse of share_inventory_with</td></tr> | |
| <tr><td><code>has_one</code></td><td>:fulfillment_metric</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:28">28</a></td><td>Polymorphic, tracks sold/reserved/held counts</td></tr> | |
| <tr><td><code>has_many</code></td><td>:add_on_order_items</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:54">54</a></td><td>dependent: :restrict_with_error</td></tr> | |
| <tr><td><code>has_many</code></td><td>:orders</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:55">55</a></td><td>Through add_on_order_items</td></tr> | |
| <tr><td><code>has_many</code></td><td>:tickets</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:56">56</a></td><td>Through add_on_order_items</td></tr> | |
| <tr><td><code>has_many</code></td><td>:add_on_ticket_types</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:51">51</a></td><td>Join table for require_ticket_type feature</td></tr> | |
| <tr><td><code>has_many</code></td><td>:price_points</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:61">61</a></td><td>Dynamic pricing tiers</td></tr> | |
| <tr><td><code>has_many</code></td><td>:taxable_items</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:58">58</a></td><td>Polymorphic tax association</td></tr> | |
| </table> | |
| <h3>Delegated Methods (from FulfillmentMetric)</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:32">Line 32-33</a></p> | |
| <pre><code>delegate :sold_count, :reserved_count, :held_count, :committed_count, | |
| :own_sellable_count, :sellable_count, | |
| to: :find_or_create_fulfillment_metric!</code></pre> | |
| <h3>Key Methods to Modify</h3> | |
| <div class="card card-orange"> | |
| <h4><code>sold_out</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:393">Line 393-404</a> <span class="badge badge-red">MODIFY</span></h4> | |
| <p>Current implementation checks <code>share_inventory_with</code> tree or single add-on sold count vs <code>available</code>. Needs a new <code>room_id</code> branch that compares <code>room_total_sold</code> against <code>room.capacity</code>.</p> | |
| <pre><code>def sold_out | |
| return false if available.blank? | |
| total_tickets_sold = if share_inventory_with.present? || shared_inventory_add_ons.exists? | |
| root_share_inventory_with.shared_inventory_add_on_tree.sum(&:number_of_tickets_sold) | |
| else | |
| number_of_tickets_sold | |
| end | |
| (total_tickets_sold >= available) | |
| end | |
| alias sold_out? sold_out</code></pre> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4><code>quantity_options</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:617">Line 617-626</a> <span class="badge badge-red">MODIFY</span></h4> | |
| <p>Currently uses <code>sellable_count</code> (per-add-on). Needs to use <code>room_sellable_count</code> when <code>room_id</code> present.</p> | |
| <pre><code>def quantity_options | |
| max = [maximum_per_order, sellable_count].min | |
| options = (minimum_per_order..max).to_a | |
| options.unshift(0) if options.exclude?(0) | |
| options | |
| end</code></pre> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4><code>as_json</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:189">Line 189-226</a> <span class="badge badge-red">MODIFY</span></h4> | |
| <p>Needs <code>room_id</code> and <code>room_sellable_count</code> added to the serialized output for the frontend.</p> | |
| <details> | |
| <summary>Current :only and :methods fields</summary> | |
| <p><strong>:only</strong> — id, available, price, start_time, end_time, minimum_per_order, maximum_per_order, description, confirmation_details, require_ticket_type, position, seating_chart_category_key, availability, hide_qr_code, start_show_activity_id, end_show_activity_id, round_up, share_inventory_with_id, absorb_fees, seating_chart_event_id, timed_entry, timed_entry_availability, timed_entry_start, timed_entry_end, timed_entry_start_show_activity_id, timed_entry_end_show_activity_id, check_in_limit, disable_require_ticket_type_with_direct_link, image_file_name, image_file_size, dynamic_pricing_enabled, dynamic_pricing_min, dynamic_pricing_max</p> | |
| <p><strong>:methods</strong> — ticket_type_ids, price_two_decimals, start_date_midday, end_date_midday, start_time_with_wrong_date, available_tickets, end_time_with_wrong_date, scheduled, human_start_time, number_of_tickets_sold, number_of_tickets_checked_in, quantity_options, price_to_currency, sold_out, sales_timeline, display_cost, simple_format_description, face_value, start_time, end_time, name, base_name, all_in_price_description, base_price, base_price_two_decimals, price, calculated_price, number_of_tickets_reserved, image_url, image_thumb_url, sold_count, reserved_count, held_count, sellable_count</p> | |
| </details> | |
| </div> | |
| <h3>Existing Shared Inventory Mechanism (NOT modified)</h3> | |
| <p>The existing <code>share_inventory_with_id</code> system uses recursive CTEs to walk a tree:</p> | |
| <ul> | |
| <li><code>root_share_inventory_with</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:562">Line 562-587</a> — CTE traverses up to root</li> | |
| <li><code>shared_inventory_add_on_tree</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:589">Line 589-615</a> — CTE traverses down entire tree</li> | |
| <li>Gated by validation: <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:85">Line 85</a> — requires <code>seating_chart_category_key</code></li> | |
| </ul> | |
| <h3>Validations</h3> | |
| <table> | |
| <tr><th>Rule</th><th>Line</th></tr> | |
| <tr><td><code>:name, :available, :price, :minimum_per_order, :maximum_per_order</code> — presence</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:83">83</a></td></tr> | |
| <tr><td><code>:availability</code> — inclusion in AVAILABILITIES</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:84">84</a></td></tr> | |
| <tr><td><code>:share_inventory_with</code> — absence unless seating_chart_category_key present</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:85">85</a></td></tr> | |
| <tr><td><code>:available</code> — integer >= 0</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:87">87</a></td></tr> | |
| <tr><td><code>:price</code> — >= 0</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:88">88</a></td></tr> | |
| <tr><td>min/max per order — integer >= 0</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:89">89-90</a></td></tr> | |
| <tr><td>Custom: min < max per order</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:96">96</a></td></tr> | |
| <tr><td>Custom: start_time before end_time</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:97">97</a></td></tr> | |
| </table> | |
| <h3>Aliases</h3> | |
| <pre><code>alias number_of_tickets_sold sold_count # line 431 | |
| alias number_of_tickets_reserved reserved_count # line 432 | |
| alias available_tickets sellable_count # line 433</code></pre> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-room">Model: Room</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb">app/models/room.rb</a> <span class="dim">(69 lines)</span></p> | |
| <h3>Associations</h3> | |
| <table> | |
| <tr><th>Type</th><th>Name</th><th>Line</th><th>Details</th></tr> | |
| <tr><td><code>belongs_to</code></td><td>:team</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:9">9</a></td><td>Direct FK — team_id on rooms table</td></tr> | |
| <tr><td><code>belongs_to</code></td><td>:venue_ownership</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:10">10</a></td><td>The VenueOwnership this room physically belongs to</td></tr> | |
| <tr><td><code>has_one</code></td><td>:venue</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:12">12</a></td><td>Through venue_ownership (polymorphic source: Venue)</td></tr> | |
| <tr><td><code>has_many</code></td><td>:room_assignments</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:14">14</a></td><td>dependent: :destroy</td></tr> | |
| <tr><td><code>has_many</code></td><td>:hold_positions</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:15">15</a></td><td>Through room_assignments</td></tr> | |
| <tr><td><code>has_many</code></td><td>:offer_rooms</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:17">17</a></td><td>dependent: :destroy</td></tr> | |
| <tr><td><code>has_many</code></td><td>:offers</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:18">18</a></td><td>Through offer_rooms</td></tr> | |
| </table> | |
| <div class="card card-green"> | |
| <h4>Key: <code>has_many :add_ons</code> must be ADDED <span class="badge badge-red">NEW</span></h4> | |
| <p>Room currently has NO add_on association. The plan adds <code>has_many :add_ons</code> plus a capacity sync callback and a factory method.</p> | |
| </div> | |
| <h3>Validations</h3> | |
| <table> | |
| <tr><th>Rule</th><th>Line</th></tr> | |
| <tr><td><code>:name, :team, :venue_ownership, :stage_type, :capacity</code> — presence</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:20">20</a></td></tr> | |
| <tr><td><code>:name</code> — uniqueness scoped to team_id (case insensitive)</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:21">21</a></td></tr> | |
| <tr><td><code>:capacity</code> — integer >= 0</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:24">24</a></td></tr> | |
| <tr><td><code>:stage_type</code> — inclusion in STAGE_TYPES</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:23">23</a></td></tr> | |
| </table> | |
| <h3>Constants</h3> | |
| <pre><code>STAGE_TYPES = ["Auditorium", "Bar Stage", "Black Box", "Custom", "Lounge", | |
| "Main Stage", "Outdoor Stage", "Rooftop", "Studio", "Theater"]</code></pre> | |
| <h3>Callbacks</h3> | |
| <table> | |
| <tr><th>Callback</th><th>Line</th><th>Purpose</th></tr> | |
| <tr><td><code>before_validation :set_calendar_color_hex, on: :create</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:30">30</a></td><td>Auto-generate hex color</td></tr> | |
| <tr><td><code>before_destroy :ensure_hold_positions_remain_assigned</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:31">31</a></td><td>Prevent orphaning hold positions</td></tr> | |
| <tr><td><code>before_destroy :ensure_venue_has_other_rooms</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb:32">32</a></td><td>Venue must keep at least 1 room</td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-fulfillment">Model: FulfillmentMetric</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/fulfillment_metric.rb">app/models/fulfillment_metric.rb</a> <span class="dim">(~259 lines)</span></p> | |
| <div class="card card-purple"> | |
| <p>Polymorphic model (<code>fulfillment_metricable</code>) that tracks inventory for both TicketTypes and AddOns. Each AddOn gets exactly one FulfillmentMetric.</p> | |
| </div> | |
| <h3>Core Fields</h3> | |
| <table> | |
| <tr><th>Field</th><th>Default</th><th>Purpose</th></tr> | |
| <tr><td><code>sold_count</code></td><td>0</td><td>Permanently sold tickets</td></tr> | |
| <tr><td><code>reserved_count</code></td><td>0</td><td>Temporarily reserved during checkout</td></tr> | |
| <tr><td><code>held_count</code></td><td>0</td><td>Admin-held inventory</td></tr> | |
| </table> | |
| <h3>Key Methods</h3> | |
| <h4><code>own_sellable_count</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/fulfillment_metric.rb:171">Line 171-183</a></h4> | |
| <pre><code>def own_sellable_count(include_reservations: true, revealed_held_count: 0) | |
| return 0 if available.nil? | |
| non_held_committed = include_reservations ? sold_count + reserved_count : sold_count | |
| physical_remaining = available - non_held_committed | |
| calculate_effective_sellable(physical_remaining, held_count, revealed_held_count) | |
| end</code></pre> | |
| <h4><code>sellable_count</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/fulfillment_metric.rb:185">Line 185-193</a></h4> | |
| <p>Calls <code>own_sellable_count</code> and constrains by parent group if applicable.</p> | |
| <h4><code>increment_with_propagation!</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/fulfillment_metric.rb:42">Line 42-96</a></h4> | |
| <p>Atomically increments a count field and propagates up parent groups. Returns log IDs for audit trail.</p> | |
| <h4><code>calculate_effective_sellable</code> — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/fulfillment_metric.rb:247">Line 247-257</a></h4> | |
| <pre><code>def calculate_effective_sellable(physical_remaining, held_count, revealed_held_count) | |
| effective_held = [held_count, physical_remaining].min | |
| effective_revealed = [revealed_held_count, effective_held].min | |
| unrevealed_held = effective_held - effective_revealed | |
| sellable_quantity = physical_remaining - unrevealed_held | |
| [sellable_quantity, 0].max | |
| end</code></pre> | |
| <div class="card card-accent"> | |
| <strong>Availability Formula:</strong><br> | |
| <code>sellable = available - sold_count - reserved_count - (held_count - revealed_held_count)</code> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-addon-order-item">Model: AddOnOrderItem</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb">app/models/add_on_order_item.rb</a></p> | |
| <h3>Associations</h3> | |
| <table> | |
| <tr><th>Type</th><th>Name</th><th>Line</th></tr> | |
| <tr><td><code>belongs_to</code></td><td>:orderable (polymorphic — Order or TicketReservation)</td><td>4</td></tr> | |
| <tr><td><code>belongs_to</code></td><td>:add_on</td><td>9</td></tr> | |
| <tr><td><code>has_many</code></td><td>:tickets (as: :order_itemable)</td><td>12</td></tr> | |
| </table> | |
| <h3>Critical Validations</h3> | |
| <table> | |
| <tr><th>Validation</th><th>Line</th><th>Impact on This Feature</th></tr> | |
| <tr><td><code>add_on_must_be_on_sale</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb:135">135-146</a></td><td>Checks start_time < now < end_time — <span class="highlight">no change needed</span></td></tr> | |
| <tr><td><code>quantity_allowed_by_orderable</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb:167">167-191</a></td><td>Enforces min/max per order — <span class="highlight">no change needed</span></td></tr> | |
| <tr><td><strong><code>add_on_must_belong_to_calendar_event</code></strong></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb:193">193-200</a></td><td><span class="badge badge-green">KEY</span> Validates <code>orderable.calendar_event == add_on.calendar_event</code>. This PASSES because each room-linked add-on IS on the event.</td></tr> | |
| <tr><td><code>seated_add_on_must_have_seatsio_objects</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb:202">202-210</a></td><td>Only for seated — <span class="highlight">no change needed</span></td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-venue-ownership">Model: VenueOwnership</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/venue_ownership.rb">app/models/venue_ownership.rb</a></p> | |
| <h3>Key Relationships</h3> | |
| <table> | |
| <tr><th>Association</th><th>Line</th><th>Relevance</th></tr> | |
| <tr><td><code>has_many :rooms, dependent: :destroy</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/venue_ownership.rb:76">76</a></td><td>VIP room is a Room on a VenueOwnership</td></tr> | |
| <tr><td><code>has_many :add_ons, through: :calendar_events</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/venue_ownership.rb:109">109</a></td><td>Add-ons are on events, not directly on venues</td></tr> | |
| </table> | |
| <div class="card"> | |
| <strong>Business model:</strong> The VIP room is a separate VenueOwnership owned by Masquerade. It has a Room with a capacity. Add-ons on events at OTHER VenueOwnerships link back to this Room via <code>room_id</code>. The Room's <code>team_id</code> must match the add-on's <code>team_id</code>. | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="model-calendar-event">Model: CalendarEvent</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb">app/models/calendar_event.rb</a></p> | |
| <h3>Add-On Associations</h3> | |
| <table> | |
| <tr><th>Association</th><th>Line</th></tr> | |
| <tr><td><code>has_many :add_ons, dependent: :destroy</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:276">276</a></td></tr> | |
| <tr><td><code>accepts_nested_attributes_for :add_ons, allow_destroy: true</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:277">277</a></td></tr> | |
| <tr><td><code>has_many :add_on_order_items, through: :add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:279">279</a></td></tr> | |
| <tr><td><code>has_many :add_on_tickets, through: :add_on_order_items</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:280">280</a></td></tr> | |
| <tr><td><code>has_many :rooms, -> { distinct }, through: :hold_positions</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:122">122</a></td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="controller-addons">Controller: AddOnsController</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb">app/controllers/add_ons_controller.rb</a></p> | |
| <h3>Authorization</h3> | |
| <pre><code>authenticate_user! # line 24 | |
| load_and_authorize_resource :team # line 26 | |
| load_and_authorize_resource :add_on, through: :team, | |
| except: [:new, :create, :index] # line 27</code></pre> | |
| <h3>ALLOWED_PARAMS — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:5">Lines 5-22</a> <span class="badge badge-red">MODIFY</span></h3> | |
| <p>Add <code>:room_id</code> to this constant. Current params include:</p> | |
| <pre><code>ALLOWED_PARAMS = [ | |
| :calendar_event_id, :name, :available, :availability, :price, :position, | |
| :start_show_activity_id, :start_time, :end_show_activity_id, :end_time, | |
| :minimum_per_order, :maximum_per_order, :description, :confirmation_details, | |
| :require_ticket_type, :absorb_fees, :hide_qr_code, :timed_entry, :round_up, | |
| :timed_entry_availability, :timed_entry_start, :timed_entry_end, | |
| :timed_entry_start_show_activity_id, :timed_entry_end_show_activity_id, | |
| :check_in_limit, :disable_require_ticket_type_with_direct_link, :image, :remove_image, | |
| :dynamic_pricing_enabled, :dynamic_pricing_min, :dynamic_pricing_max, | |
| ticket_type_ids: [], | |
| taxable_items_attributes: [:id, :manual_tax_id, :_destroy], | |
| price_points_attributes: [:id, :price, :starts_at, :name, :_destroy], | |
| add_ons: [...] | |
| ]</code></pre> | |
| <h3>Actions</h3> | |
| <table> | |
| <tr><th>Action</th><th>Line</th><th>Purpose</th></tr> | |
| <tr><td><code>index</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:31">31</a></td><td>List add-ons for a calendar event</td></tr> | |
| <tr><td><code>new</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:38">38</a></td><td>Build new or copy existing add-on</td></tr> | |
| <tr><td><code>create</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:71">71</a></td><td>Create new add-on for event</td></tr> | |
| <tr><td><code>edit</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:88">88</a></td><td>Render add-on for editing</td></tr> | |
| <tr><td><code>update</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:92">92</a></td><td>Update add-on (seated vs non-seated params)</td></tr> | |
| <tr><td><code>destroy</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:108">108</a></td><td>Delete add-on (blocked for seated)</td></tr> | |
| <tr><td><code>build_ticket</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:118">118</a></td><td>Simulate ticket for fee preview</td></tr> | |
| <tr><td><code>dup_with_shared_inventory</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:178">178</a></td><td>Duplicate add-on with shared inventory link</td></tr> | |
| </table> | |
| <h3>Other AddOn Controllers</h3> | |
| <table> | |
| <tr><th>File</th><th>Purpose</th></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/api/v2/add_ons_controller.rb">api/v2/add_ons_controller.rb</a></td><td>Public API — index/show with Ransack search</td></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/api/v2/admin/add_ons_controller.rb">api/v2/admin/add_ons_controller.rb</a></td><td>Admin API — global add-on query</td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="checkout-flow">Checkout Flow <span class="badge badge-green">ZERO CHANGES NEEDED</span></h2> | |
| <p>This is the key advantage of the "shadow add-on per event" approach. Each event gets its own AddOn record linked to the shared Room. The checkout flow works unmodified because:</p> | |
| <div class="columns"> | |
| <div class="card card-green"> | |
| <h4>1. Load Add-Ons</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/web_orders_controller.rb:61">web_orders_controller.rb:61-66</a></p> | |
| <p>Already loads <code>@confirm.add_ons</code> — room-linked add-ons ARE on the confirm, so they load automatically.</p> | |
| </div> | |
| <div class="card card-green"> | |
| <h4>2. Display Quantity</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/WebOrderForm/components/ChooseAddOns.jsx:88">ChooseAddOns.jsx:88-94</a></p> | |
| <p>Uses <code>addOn.available_tickets</code> and <code>addOn.sold_out</code> — both overridden to use room pooling.</p> | |
| </div> | |
| <div class="card card-green"> | |
| <h4>3. Validate Event Match</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on_order_item.rb:193">add_on_order_item.rb:193-200</a></p> | |
| <p><code>add_on.calendar_event == orderable.calendar_event</code> passes because each room add-on IS on the event.</p> | |
| </div> | |
| <div class="card card-green"> | |
| <h4>4. Reserve Inventory</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/concerns/orderable.rb:694">orderable.rb:694-716</a></p> | |
| <p>FulfillmentMetric per-add-on still works. <code>sellable_count</code> check uses the overridden <code>room_sellable_count</code>.</p> | |
| </div> | |
| </div> | |
| <h3>Inventory Reservation Flow Detail</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/concerns/orderable.rb:653">orderable.rb:653-718</a> — <code>check_and_reserve_ticket_availability</code></p> | |
| <pre><code># For each add-on order item (lines 694-716): | |
| add_on_order_items.sort_by(&:add_on_id).each do |add_on_order_item| | |
| next unless add_on_order_item.actionable? | |
| add_on = AddOn.find(add_on_order_item.add_on_id) | |
| metric = add_on.find_or_create_fulfillment_metric! | |
| requested_quantity = add_on_order_item.quantity | |
| # THIS is where oversells are prevented: | |
| if validatable? && requested_quantity > add_on.sellable_count # ← uses room_sellable_count when room_id present | |
| errors.add(:base, "One or more items in your cart are no longer available...") | |
| end | |
| if errors.blank? | |
| if is_a?(TicketReservation) | |
| metric.increment_with_propagation!(:reserved_count, requested_quantity, ...) | |
| elsif is_a?(Order) | |
| metric.increment_with_propagation!(:sold_count, requested_quantity, ...) | |
| end | |
| end | |
| end</code></pre> | |
| <div class="card card-accent"> | |
| <strong>Lock mechanism:</strong> <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/concerns/orderable.rb:732">orderable.rb:732-777</a><br> | |
| Uses <code>with_conditional_inventory_locks</code> with 30s lock timeout, 35s statement timeout. Pessimistic row locking on FulfillmentMetric records when <code>enable_fulfillment_metric_locking?</code> is true. | |
| <br><br> | |
| <span class="warn">⚠ Note:</span> The lock is per-FulfillmentMetric (per-add-on), NOT per-room. With room-based pooling, concurrent purchases on different events could race. The <code>room_sellable_count</code> query aggregates across add-ons, but each add-on's metric is locked independently. This may need attention if high concurrency is expected. | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="ability">Authorization: ability.rb</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb">app/models/ability.rb</a></p> | |
| <h3>Current Permissions</h3> | |
| <table> | |
| <tr><th>Role</th><th>AddOn</th><th>Room</th><th>Line</th></tr> | |
| <tr><td>Consumer</td><td><code>can :read</code> (team-scoped)</td><td>—</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb:55">55</a></td></tr> | |
| <tr><td>Promoter</td><td><code>can :manage</code> (conditional, MOKB only)</td><td>—</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb:141">141</a></td></tr> | |
| <tr><td>Contributor</td><td><code>can :manage</code> (team-scoped)</td><td>—</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb:155">155</a></td></tr> | |
| <tr><td>Administrator</td><td>Inherited (<code>can :manage, :all</code>-like)</td><td><code>can :manage</code> (team-scoped)</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb:313">313</a></td></tr> | |
| </table> | |
| <div class="card card-orange"> | |
| <h4>New: RoomAddOn authorization <span class="badge badge-red">NEW</span></h4> | |
| <p>The new <code>RoomAddOnsController</code> needs ability definitions. Since creating a room add-on is essentially creating an AddOn, the existing AddOn permissions may suffice — but if CanCanCan tries to authorize the <code>RoomAddOn</code> resource name, explicit rules are needed.</p> | |
| <p>Pattern to follow: <code>can :create, AddOn, team_id: team_membership.team_id</code></p> | |
| </div> | |
| <h3>Ability Test Files</h3> | |
| <table> | |
| <tr><th>File</th><th>AddOn Tests At</th><th>Room Tests At</th></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/administrator_ability_test.rb:1334">administrator_ability_test.rb</a></td><td>Line 1334</td><td>Line 2018</td></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/contributor_ability_test.rb:1306">contributor_ability_test.rb</a></td><td>Line 1306</td><td>Line 2012</td></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/consumer_ability_test.rb:1318">consumer_ability_test.rb</a></td><td>Line 1318</td><td>—</td></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/promoter_ability_test.rb:1405">promoter_ability_test.rb</a></td><td>Line 1405</td><td>—</td></tr> | |
| <tr><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/guest_ability_test.rb">guest_ability_test.rb</a></td><td>—</td><td>—</td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="routes">Routes</h2> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb">config/routes.rb</a></p> | |
| <h3>Existing Add-On Routes</h3> | |
| <table> | |
| <tr><th>Route</th><th>Line</th><th>Actions</th></tr> | |
| <tr><td>Team-scoped: <code>/teams/:team_id/add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:536">536-544</a></td><td>CRUD (except index, show) + <code>dup_with_shared_inventory</code>, <code>create_defaults</code>, <code>build_ticket</code></td></tr> | |
| <tr><td>Event-nested: <code>/teams/:team_id/calendar_events/:id/add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:737">737</a></td><td>Index only</td></tr> | |
| <tr><td>API v1: <code>/api/v1/confirms/:confirm_id/add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:1134">1134</a></td><td>Index, Show</td></tr> | |
| <tr><td>API v2: <code>/api/v2/confirms/:confirm_id/add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:1209">1209</a></td><td>Index, Show</td></tr> | |
| </table> | |
| <h3>Existing Room Routes</h3> | |
| <table> | |
| <tr><th>Route</th><th>Line</th><th>Actions</th></tr> | |
| <tr><td>Team-scoped: <code>/teams/:team_id/rooms</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:756">756</a></td><td>new, create, edit, update, destroy</td></tr> | |
| <tr><td>API v1: <code>/api/v1/.../rooms</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:1239">1239</a></td><td>Index, Show</td></tr> | |
| </table> | |
| <div class="card card-orange"> | |
| <h4>New Route Needed <span class="badge badge-red">NEW</span></h4> | |
| <pre><code># Nested under team scope: | |
| resources :rooms, only: [] do | |
| resources :room_add_ons, only: [:create], controller: "room_add_ons" | |
| end | |
| # → POST /teams/:team_id/rooms/:room_id/room_add_ons</code></pre> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="frontend-admin">Frontend: Admin Add-On Management</h2> | |
| <h3>AddOnsTab.jsx — Main Admin Container</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnsTab.jsx">ConfirmTicketing/components/AddOnsTab.jsx</a></p> | |
| <div class="card"> | |
| <p>Main interface for managing add-ons on an event. Handles creation, editing, deletion, seating chart assignment, shared inventory duplication, and fee calculation.</p> | |
| <h4>Key State (lines 293-296):</h4> | |
| <ul> | |
| <li><code>duplicatingAddOnId</code> — tracks add-on being duplicated</li> | |
| <li><code>isSavingDuplicateAddOn</code> — loading state</li> | |
| <li><code>showDesigner</code> — seating chart designer visibility</li> | |
| </ul> | |
| <h4>API Calls:</h4> | |
| <ul> | |
| <li><code>POST /teams/:id/calendar_events/:id/seating_chart_events</code> — create seating chart event</li> | |
| <li><code>POST /teams/:id/add_ons/:id/dup_with_shared_inventory</code> — duplicate with shared inventory</li> | |
| <li><code>POST /teams/:id/add_ons/build_ticket</code> — calculate fees/taxes</li> | |
| </ul> | |
| <h4>Helper: <code>isSharingInventory(addOns, addOn)</code> (lines 202-207):</h4> | |
| <p>Checks <code>share_inventory_with_id</code> — this would need updating to also check <code>room_id</code> for the "Shared" badge.</p> | |
| </div> | |
| <h3>AddOnRow.jsx — Individual Row Display</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnRow.jsx">ConfirmTicketing/components/AddOnRow.jsx</a></p> | |
| <div class="card"> | |
| <p>Renders a single add-on in the admin table. Shows name, price, on-sale dates, inventory count, and action buttons (edit/copy/duplicate/delete).</p> | |
| <ul> | |
| <li><strong>Line 92-96:</strong> "Shared Inventory" badge — currently checks <code>isSharingInventory()</code> which only looks at <code>share_inventory_with_id</code></li> | |
| <li><strong>Line 171:</strong> Displays <code>addOn.available</code> as inventory count</li> | |
| <li><strong>Line 266:</strong> Delete blocked if <code>number_of_tickets_sold > 0</code></li> | |
| </ul> | |
| </div> | |
| <h3>AddOnsTable.jsx — Table Container</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnsTable.jsx">ConfirmTicketing/components/AddOnsTable.jsx</a></p> | |
| <div class="card"> | |
| <p>Container with drag-and-drop reordering. Shows total sold/available footer.</p> | |
| <ul> | |
| <li><strong>Line 27-31:</strong> <code>totalNumberSold(addOns)</code> — sums <code>number_of_tickets_sold</code></li> | |
| <li><strong>Line 33-37:</strong> <code>totalNumberAvailable(addOns)</code> — sums <code>available</code></li> | |
| <li><strong>Line 227:</strong> Footer: <code>${totalNumberSold}/${totalNumberAvailable}</code></li> | |
| </ul> | |
| </div> | |
| <h3>AddOnsForm.jsx — Conductor/Show Add-On Form</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConductorShow/components/AddOnsForm.jsx">ConductorShow/components/AddOnsForm.jsx</a></p> | |
| <div class="card"> | |
| <p>Reusable form for show add-ons. Pure form component (no API calls). Fields: name, price (CurrencyInput), qty, on-sale schedule (Immediately or Scheduled with show activity dropdowns).</p> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="frontend-checkout">Frontend: Checkout Components</h2> | |
| <h3>ChooseAddOns.jsx — Customer Add-On Selection</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/WebOrderForm/components/ChooseAddOns.jsx">WebOrderForm/components/ChooseAddOns.jsx</a> <span class="badge badge-green">NO CHANGES NEEDED</span></p> | |
| <div class="card card-green"> | |
| <p>Customer-facing checkout component. Filters non-seated add-ons, displays cards with images, pricing, descriptions, and quantity dropdowns.</p> | |
| <ul> | |
| <li><strong>Line 28:</strong> Filters: <code>visibleAddOns.filter((addOn) => !addOnIsSeated(addOn))</code></li> | |
| <li><strong>Lines 88-94:</strong> Sold out check: <code>addOn.available_tickets == 0 || addOn.sold_out</code></li> | |
| <li><strong>Lines 134-138:</strong> Quantity options from <code>addOnQuantityOptions(addOn, ...)</code></li> | |
| <li><strong>Lines 101-131:</strong> onChange handler updates quantity and triggers <code>buildTicketReservation()</code></li> | |
| </ul> | |
| <p><strong>Why no changes:</strong> This component reads <code>available_tickets</code>, <code>sold_out</code>, and <code>quantity_options</code> from the add-on JSON. All three are overridden at the model level to use room pooling.</p> | |
| </div> | |
| <h3>WebOrderForm.jsx — Main Checkout Container</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/WebOrderForm/components/WebOrderForm.jsx">WebOrderForm/components/WebOrderForm.jsx</a></p> | |
| <div class="card"> | |
| <ul> | |
| <li><strong>Lines 491-514:</strong> Fetches initial availability from <code>/confirms/:id/web_orders/available_tickets</code></li> | |
| <li><strong>Lines 68-97:</strong> <code>visibleAddOns</code> function filters by time window and available quantity options</li> | |
| </ul> | |
| </div> | |
| <h3>RealtimeDataMutator.js — Live Availability Updates</h3> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/RealtimeDataMutator/RealtimeDataMutator.js">RealtimeDataMutator/RealtimeDataMutator.js</a></p> | |
| <div class="card"> | |
| <ul> | |
| <li><strong>Lines 153-200:</strong> Real-time add-on availability updates via ActionCable</li> | |
| <li><strong>Line 176:</strong> Checks if <code>currentQuantityMax > addOnData.available_tickets</code></li> | |
| <li><strong>Lines 194-197:</strong> Filters quantity options to cap at <code>available_tickets</code></li> | |
| </ul> | |
| <p><span class="warn">⚠ Note:</span> Real-time updates push per-add-on data. With room pooling, a sale on Event A needs to push updates to Event B's checkout too. This may need a room-level broadcast.</p> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="schema">Database Schema</h2> | |
| <h3><code>add_ons</code> table — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/db/schema.rb:129">schema.rb:129-177</a></h3> | |
| <table> | |
| <tr><th>Column</th><th>Type</th><th>Default</th><th>Notes</th></tr> | |
| <tr><td><code>team_id</code></td><td>integer</td><td></td><td>FK to teams (indexed)</td></tr> | |
| <tr><td><code>calendar_event_id</code></td><td>integer</td><td></td><td>FK to calendar_events (indexed)</td></tr> | |
| <tr><td><code>available</code></td><td>integer</td><td></td><td>Total capacity for this add-on</td></tr> | |
| <tr><td><code>name</code></td><td>string</td><td></td><td></td></tr> | |
| <tr><td><code>price</code></td><td>decimal(15,3)</td><td></td><td></td></tr> | |
| <tr><td><code>description</code></td><td>text</td><td></td><td></td></tr> | |
| <tr><td><code>confirmation_details</code></td><td>text</td><td></td><td></td></tr> | |
| <tr><td><code>minimum_per_order</code></td><td>integer</td><td>1</td><td></td></tr> | |
| <tr><td><code>maximum_per_order</code></td><td>integer</td><td>10</td><td></td></tr> | |
| <tr><td><code>start_time</code></td><td>datetime</td><td></td><td>When add-on goes on sale</td></tr> | |
| <tr><td><code>end_time</code></td><td>datetime</td><td></td><td>When add-on stops selling</td></tr> | |
| <tr><td><code>availability</code></td><td>string</td><td>"Based on show details"</td><td>DATE_AND_TIME or SHOW_DETAIL_BASED</td></tr> | |
| <tr><td><code>share_inventory_with_id</code></td><td>integer</td><td></td><td>Self-ref FK for tree-based sharing (indexed)</td></tr> | |
| <tr><td><code>seating_chart_category_key</code></td><td>string</td><td></td><td>For seated add-ons</td></tr> | |
| <tr><td><code>seating_chart_event_id</code></td><td>integer</td><td></td><td>For seated add-ons (indexed)</td></tr> | |
| <tr><td><code>position</code></td><td>integer</td><td></td><td>Display ordering</td></tr> | |
| <tr><td><code>require_ticket_type</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>absorb_fees</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>hide_qr_code</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>timed_entry</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>round_up</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>dynamic_pricing_enabled</code></td><td>boolean</td><td>false</td><td></td></tr> | |
| <tr><td><code>dynamic_pricing_min</code></td><td>decimal(15,3)</td><td></td><td></td></tr> | |
| <tr><td><code>dynamic_pricing_max</code></td><td>decimal(15,3)</td><td></td><td></td></tr> | |
| <tr><td><code>check_in_limit</code></td><td>integer</td><td>0</td><td></td></tr> | |
| <tr><td><code>image_*</code></td><td>various</td><td></td><td>Paperclip attachment fields</td></tr> | |
| <tr style="background: rgba(248,81,73,0.1);"><td><strong><code>room_id</code></strong></td><td><strong>integer</strong></td><td></td><td><span class="badge badge-red">NEW</span> FK to rooms (indexed)</td></tr> | |
| </table> | |
| <h3><code>rooms</code> table — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/db/schema.rb:4430">schema.rb:4430-4442</a></h3> | |
| <table> | |
| <tr><th>Column</th><th>Type</th><th>Notes</th></tr> | |
| <tr><td><code>team_id</code></td><td>integer</td><td>FK to teams (indexed)</td></tr> | |
| <tr><td><code>venue_ownership_id</code></td><td>integer</td><td>FK to venue_ownerships (indexed)</td></tr> | |
| <tr><td><code>name</code></td><td>string</td><td></td></tr> | |
| <tr><td><code>calendar_color_hex</code></td><td>string</td><td></td></tr> | |
| <tr><td><code>capacity</code></td><td>integer</td><td>The shared capacity pool</td></tr> | |
| <tr><td><code>stage_type</code></td><td>string</td><td></td></tr> | |
| <tr><td><code>notes</code></td><td>text</td><td></td></tr> | |
| </table> | |
| <h3><code>fulfillment_metrics</code> table — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/db/schema.rb:2664">schema.rb:2664-2673</a></h3> | |
| <table> | |
| <tr><th>Column</th><th>Type</th><th>Default</th></tr> | |
| <tr><td><code>fulfillment_metricable_type</code></td><td>string</td><td>(polymorphic)</td></tr> | |
| <tr><td><code>fulfillment_metricable_id</code></td><td>bigint</td><td>(polymorphic)</td></tr> | |
| <tr><td><code>sold_count</code></td><td>integer</td><td>0</td></tr> | |
| <tr><td><code>reserved_count</code></td><td>integer</td><td>0</td></tr> | |
| <tr><td><code>held_count</code></td><td>integer</td><td>0</td></tr> | |
| </table> | |
| <h3><code>add_on_order_items</code> table — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/db/schema.rb:105">schema.rb:105-118</a></h3> | |
| <table> | |
| <tr><th>Column</th><th>Type</th><th>Notes</th></tr> | |
| <tr><td><code>orderable_id</code></td><td>integer</td><td>Polymorphic (Order or TicketReservation)</td></tr> | |
| <tr><td><code>orderable_type</code></td><td>string</td><td></td></tr> | |
| <tr><td><code>add_on_id</code></td><td>integer</td><td>FK to add_ons (indexed)</td></tr> | |
| <tr><td><code>quantity</code></td><td>integer</td><td></td></tr> | |
| <tr><td><code>price</code></td><td>decimal</td><td></td></tr> | |
| <tr><td><code>face_value</code></td><td>decimal(15,2)</td><td></td></tr> | |
| <tr><td><code>seatsio_objects</code></td><td>jsonb</td><td>default: []</td></tr> | |
| <tr><td><code>original_price</code></td><td>decimal(15,3)</td><td></td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="tests">Test Files & Patterns</h2> | |
| <h3>AddOn Test — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/models/add_on_test.rb">test/models/add_on_test.rb</a> <span class="dim">(~1190 lines)</span></h3> | |
| <div class="card"> | |
| <h4>Setup Pattern (lines 4-16):</h4> | |
| <pre><code>setup do | |
| @team = create(:team) | |
| @venue_ownership = create(:venue_ownership, team: @team) | |
| @confirm = create(:confirm, venue_ownership: @venue_ownership) | |
| @add_on = create(:add_on, team: @team, calendar_event: @confirm) | |
| # + various stubs | |
| end</code></pre> | |
| <h4>Existing Test Categories:</h4> | |
| <ul> | |
| <li>FulfillmentMetric integration (lines 20-35)</li> | |
| <li>Price calculations (lines 39-268)</li> | |
| <li>Name/description methods (lines 270-1024)</li> | |
| <li><strong>Inventory & sold_out tests (lines 289-327)</strong></li> | |
| <li><strong>Quantity options tests (lines 764-832)</strong></li> | |
| <li>Validations (lines 834-922)</li> | |
| <li>Available tickets (lines 843-865)</li> | |
| <li>Broadcast tests (lines 867-900)</li> | |
| <li>PaperTrail versions (lines 902-910)</li> | |
| </ul> | |
| </div> | |
| <h3>Factory: AddOn — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/factories/add_ons.rb">test/factories/add_ons.rb</a></h3> | |
| <pre><code>factory :add_on do | |
| team | |
| association :calendar_event, factory: :confirm | |
| available { 150 } | |
| sequence(:name) {|n| "VIP Package ##{n}" } | |
| price { 55.00 } | |
| minimum_per_order { 1 } | |
| maximum_per_order { 10 } | |
| availability { "Date & Time" } | |
| start_time { 1.day.ago } | |
| end_time { 1.day.from_now } | |
| end</code></pre> | |
| <h3>Factory: Room — <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/test/factories/rooms.rb">test/factories/rooms.rb</a></h3> | |
| <pre><code>factory :room do | |
| team | |
| venue_ownership | |
| sequence(:name) { |n| "Venue Room #{n}" } | |
| capacity { 100 } | |
| stage_type { "Main Stage" } | |
| notes { "Default room notes" } | |
| end</code></pre> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2 id="plan-summary">Implementation Plan Summary</h2> | |
| <h3>Phase 1: Migration & Model Changes</h3> | |
| <table> | |
| <tr><th>Task</th><th>File</th><th>Type</th></tr> | |
| <tr><td>Add <code>room_id</code> FK to add_ons</td><td><code>db/migrate/XXX_add_room_id_to_add_ons.rb</code></td><td><span class="badge badge-red">NEW</span></td></tr> | |
| <tr><td><code>belongs_to :room, optional: true</code> + validation</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb">add_on.rb</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Override <code>sold_out</code> (line 393)</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:393">add_on.rb:393</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Add <code>room_total_sold</code>, <code>room_total_committed</code>, <code>room_sellable_count</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb">add_on.rb</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Override <code>quantity_options</code> (line 617)</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:617">add_on.rb:617</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Add to <code>as_json</code> (line 189)</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/add_on.rb:189">add_on.rb:189</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td><code>has_many :add_ons</code> + capacity sync + factory method</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/room.rb">room.rb</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Add <code>:room_id</code> to ALLOWED_PARAMS</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/add_ons_controller.rb:5">add_ons_controller.rb:5</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| </table> | |
| <h3>Phase 2: Admin API</h3> | |
| <table> | |
| <tr><th>Task</th><th>File</th><th>Type</th></tr> | |
| <tr><td>RoomAddOnsController with create action</td><td><code>app/controllers/room_add_ons_controller.rb</code></td><td><span class="badge badge-red">NEW</span></td></tr> | |
| <tr><td>Route: <code>POST /teams/:id/rooms/:id/room_add_ons</code></td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb">routes.rb</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>Ability definitions for RoomAddOn</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ability.rb">ability.rb</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| </table> | |
| <h3>Phase 3: Frontend Admin UI</h3> | |
| <table> | |
| <tr><th>Task</th><th>File</th><th>Type</th></tr> | |
| <tr><td>"Link to Room" dropdown in add-on form</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnsTab.jsx">AddOnsTab.jsx</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>"Attach Room" button on event add-ons page</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnsTab.jsx">AddOnsTab.jsx</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| <tr><td>"Shared capacity" badge for room-linked add-ons</td><td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/AddOnRow.jsx:92">AddOnRow.jsx:92</a></td><td><span class="badge badge-orange">MODIFY</span></td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <h2>Relationship Hierarchy</h2> | |
| <div class="mermaid-placeholder"> | |
| Team | |
| ├── has_many :venue_ownerships | |
| │ ├── has_many :rooms ← VIP room lives here | |
| │ │ ├── belongs_to :team | |
| │ │ ├── :capacity ← shared pool size | |
| │ │ └── has_many :add_ons ← NEW (via room_id FK) | |
| │ └── has_many :calendar_events | |
| │ └── has_many :add_ons ← existing (via calendar_event_id FK) | |
| │ ├── has_one :fulfillment_metric ← per-add-on tracking | |
| │ ├── has_many :add_on_order_items | |
| │ │ └── has_many :tickets | |
| │ ├── belongs_to :room (optional) ← NEW link to shared capacity | |
| │ └── belongs_to :share_inventory_with (self-ref, existing) | |
| ├── has_many :add_ons (direct) | |
| └── has_many :rooms (direct) | |
| </div> | |
| <hr class="sep"> | |
| <div class="card card-accent" style="margin-top: 2rem;"> | |
| <p><strong>Plan file:</strong> <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/plans/o-1668-masquerade-vip-room-add-on.md">plans/o-1668-masquerade-vip-room-add-on.md</a></p> | |
| <p><strong>Branch:</strong> <code>erictobias/o-1668-masquerade-vip-room-add-on</code></p> | |
| <p><strong>Worktree:</strong> <code>/Users/ryanlingle/worktrees/opendate-o-1668-briefing</code></p> | |
| <p class="dim">Generated 2026-03-16</p> | |
| </div> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>O-1668: Foreign Group Nesting — Revised Implementation Plan</title> | |
| <style> | |
| :root { | |
| --bg: #0d1117; | |
| --surface: #161b22; | |
| --surface-bright: #1c2333; | |
| --border: #30363d; | |
| --text: #c9d1d9; | |
| --text-dim: #8b949e; | |
| --heading: #f0f6fc; | |
| --accent: #58a6ff; | |
| --accent2: #7ee787; | |
| --accent3: #d2a8ff; | |
| --accent4: #ffa657; | |
| --red: #f85149; | |
| --yellow: #e3b341; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| line-height: 1.6; | |
| padding: 2rem; | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| } | |
| h1 { color: var(--heading); font-size: 2rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--accent); padding-bottom: 0.5rem; } | |
| h2 { color: var(--accent); font-size: 1.5rem; margin: 2.5rem 0 1rem; border-left: 4px solid var(--accent); padding-left: 0.75rem; } | |
| h3 { color: var(--accent3); font-size: 1.15rem; margin: 1.5rem 0 0.5rem; } | |
| h4 { color: var(--accent4); font-size: 1rem; margin: 1rem 0 0.5rem; } | |
| p { margin-bottom: 0.75rem; } | |
| a { color: var(--accent); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| code { | |
| font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; | |
| background: var(--surface-bright); | |
| padding: 0.15rem 0.4rem; | |
| border-radius: 4px; | |
| font-size: 0.9em; | |
| color: var(--accent4); | |
| } | |
| pre { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| overflow-x: auto; | |
| margin: 0.75rem 0; | |
| font-size: 0.85rem; | |
| line-height: 1.5; | |
| } | |
| pre code { background: none; padding: 0; color: var(--text); } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1.25rem; | |
| margin: 1rem 0; | |
| } | |
| .card-accent { border-left: 3px solid var(--accent); } | |
| .card-green { border-left: 3px solid var(--accent2); } | |
| .card-purple { border-left: 3px solid var(--accent3); } | |
| .card-orange { border-left: 3px solid var(--accent4); } | |
| .card-red { border-left: 3px solid var(--red); } | |
| .card-yellow { border-left: 3px solid var(--yellow); } | |
| .badge { | |
| display: inline-block; | |
| padding: 0.15rem 0.5rem; | |
| border-radius: 12px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .badge-blue { background: rgba(88,166,255,0.15); color: var(--accent); } | |
| .badge-green { background: rgba(126,231,135,0.15); color: var(--accent2); } | |
| .badge-purple { background: rgba(210,168,255,0.15); color: var(--accent3); } | |
| .badge-orange { background: rgba(255,166,87,0.15); color: var(--accent4); } | |
| .badge-red { background: rgba(248,81,73,0.15); color: var(--red); } | |
| .badge-yellow { background: rgba(227,179,65,0.15); color: var(--yellow); } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 0.75rem 0; | |
| font-size: 0.9rem; | |
| } | |
| th { background: var(--surface-bright); color: var(--heading); text-align: left; font-weight: 600; } | |
| th, td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); } | |
| tr:nth-child(even) { background: var(--surface); } | |
| .sep { border: none; border-top: 1px solid var(--border); margin: 2rem 0; } | |
| .subtitle { color: var(--text-dim); font-size: 1rem; margin-bottom: 2rem; } | |
| .dim { color: var(--text-dim); } | |
| .warn { color: var(--yellow); } | |
| .highlight { color: var(--accent2); } | |
| .cursor-link { color: var(--accent); font-family: 'SF Mono', Consolas, monospace; font-size: 0.85em; } | |
| .mermaid-placeholder { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1.5rem; | |
| font-family: monospace; | |
| font-size: 0.8rem; | |
| white-space: pre; | |
| line-height: 1.4; | |
| overflow-x: auto; | |
| } | |
| .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } | |
| @media (max-width: 768px) { .columns { grid-template-columns: 1fr; } } | |
| .future-ticket { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-left: 3px solid var(--text-dim); | |
| border-radius: 8px; | |
| padding: 1.25rem; | |
| margin: 1rem 0; | |
| opacity: 0.75; | |
| } | |
| ul { margin: 0.5rem 0 0.75rem 1.5rem; } | |
| li { margin: 0.25rem 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>O-1668: Foreign Group Nesting</h1> | |
| <p class="subtitle">Revised Implementation Plan — Shared capacity via TicketGroup foreign parent references</p> | |
| <!-- ======================================================= --> | |
| <h2>Ticket Scope</h2> | |
| <div class="card card-green"> | |
| <h4>This Ticket: Foreign Group Nesting <span class="badge badge-green">O-1668</span></h4> | |
| <p>Allow a TicketGroup on one event to have a <code>parent_id</code> pointing to a TicketGroup on a <strong>different</strong> event. FulfillmentMetric propagation handles shared capacity automatically. Masquerade uses this with ticket types to share VIP room capacity across events.</p> | |
| </div> | |
| <div class="future-ticket"> | |
| <h4>Future Ticket: Add-On Groups <span class="badge badge-purple">SEPARATE</span></h4> | |
| <p>Add TicketGroup support for add-ons (same as ticket types already have). An add-on would <code>belongs_to :ticket_group</code>, with ticket settings UI to create/manage add-on groups, set group capacity, and organize add-ons within groups. This is purely same-event, same as ticket types work today.</p> | |
| <p><strong>Why separate:</strong> This is a general-purpose feature that benefits all teams regardless of shared capacity. It enables capacity sub-pools for add-ons within a single event (e.g., "VIP Packages" group with 50 capacity containing 3 different VIP add-on tiers).</p> | |
| <p><strong>Depends on:</strong> Nothing. Can be built before or after O-1668.</p> | |
| <p><strong>Unlocks:</strong> Combined with O-1668's foreign group nesting, add-on groups could also share capacity across events — giving Masquerade the option to sell VIP room access as add-ons instead of ticket types if they prefer that checkout UX.</p> | |
| </div> | |
| <div class="future-ticket"> | |
| <h4>Future Ticket: Membership Capacity Pooling <span class="badge badge-purple">SEPARATE</span></h4> | |
| <p>Integrate membership products with shared capacity pools. A membership grants access to the VIP room across all events — the member's "ticket" to the VIP room is automatically provisioned (or pre-reserved) based on their membership tier, drawing from the same shared capacity pool.</p> | |
| <p><strong>Why separate:</strong> Membership integration involves a different domain (recurring access vs. per-event purchase) with its own complexities around reservation timing, cancellation, and renewal.</p> | |
| <p><strong>Depends on:</strong> O-1668 (foreign group nesting) for the shared capacity mechanism. May also depend on Add-On Groups if memberships grant add-on access.</p> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Architecture: Why This Works</h2> | |
| <div class="card card-accent"> | |
| <h4>Key Insight</h4> | |
| <p>The existing FulfillmentMetric infrastructure already supports cross-event capacity sharing. The propagation goes <strong>upward</strong> (child → parent) via <code>parent_group</code>. The <code>sellable_count</code> ceiling, <code>increment/decrement_with_propagation!</code>, and <code>lock_hierarchy!</code> all follow the <code>parent_group</code> chain — and none of them check <code>calendar_event_id</code>.</p> | |
| <p>The only thing preventing cross-event parents today is the <strong>ticket settings UI</strong>, which only shows groups from the current event. No model validation blocks it. No DB constraint blocks it.</p> | |
| </div> | |
| <div class="mermaid-placeholder"> | |
| VIP Event (VIP Room VenueOwnership) | |
| └── Root TicketGroup (capacity: -) | |
| └── TicketGroup "VIP Room Pool" (capacity: 50) ← shared parent | |
| │ | |
| │ parent_id FK (cross-event) | |
| │ | |
| ├── TicketGroup on Event A ──→ TicketType "VIP Room" ($XX) | |
| ├── TicketGroup on Event B ──→ TicketType "VIP Room" ($XX) | |
| ├── TicketGroup on Event C ──→ TicketType "VIP Room" ($XX) | |
| └── TicketGroup on Event D ──→ TicketType "VIP Room" ($XX) | |
| Each TicketType has: | |
| - calendar_event_id → its own event (shows in that event's checkout) | |
| - ticket_group_id → group with parent on VIP Event (capacity ceiling) | |
| FulfillmentMetric flow (on purchase at Event A): | |
| TicketType FM: increment sold_count +1 | |
| → propagates to Event A's group FM: increment sold_count +1 | |
| → propagates to VIP Room Pool FM: increment sold_count +1 | |
| → sellable_count = 50 - total_committed = ceiling for ALL events | |
| </div> | |
| <h3>What already works (zero changes)</h3> | |
| <table> | |
| <tr><th>Component</th><th>Why it works</th></tr> | |
| <tr><td><code>sellable_count</code></td><td>Returns <code>min(own, parent.sellable_count)</code> — pure math, no event checks</td></tr> | |
| <tr><td><code>increment_with_propagation!</code></td><td>Follows <code>parent_group</code> chain unconditionally</td></tr> | |
| <tr><td><code>decrement_with_propagation!</code></td><td>Same — follows <code>parent_group</code> chain</td></tr> | |
| <tr><td><code>lock_hierarchy!</code></td><td>Locks up the chain. Locking is on for everyone — no conditional flag risk</td></tr> | |
| <tr><td>Checkout flow</td><td>Loads ticket types by <code>calendar_event_id</code> — the ticket type IS on the event</td></tr> | |
| <tr><td>FulfillmentMetricLog</td><td>Tracks <code>calendar_event_id</code> independently per log entry</td></tr> | |
| <tr><td><code>parent_id</code> FK</td><td>No DB constraint requiring same <code>calendar_event_id</code> — foreign parents save today</td></tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Implementation</h2> | |
| <h3>Phase 1: Model & Validation</h3> | |
| <div class="card card-orange"> | |
| <h4>1.1 TicketGroup: Allow and validate foreign parents</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ticket_group.rb">app/models/ticket_group.rb</a></p> | |
| <p>Currently no validation prevents cross-event parents. Add an explicit validation that the foreign parent belongs to the <strong>same team</strong>:</p> | |
| <pre><code>validate :parent_belongs_to_same_team, if: -> { parent_id.present? } | |
| private | |
| def parent_belongs_to_same_team | |
| if parent.calendar_event.team_id != calendar_event.team_id | |
| errors.add(:parent, "must belong to the same team") | |
| end | |
| end</code></pre> | |
| <p>This intentionally does NOT enforce same <code>calendar_event_id</code> — that's the whole feature.</p> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>1.2 TicketGroup: Foreign parent helper methods</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ticket_group.rb">app/models/ticket_group.rb</a></p> | |
| <pre><code>def foreign_parent? | |
| parent_id.present? && parent.calendar_event_id != calendar_event_id | |
| end | |
| def foreign_sub_groups | |
| sub_groups.where.not(calendar_event_id: calendar_event_id) | |
| end | |
| def local_sub_groups | |
| sub_groups.where(calendar_event_id: calendar_event_id) | |
| end</code></pre> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>1.3 Event duplication: Preserve foreign parent references</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:3112">calendar_event.rb:3112-3127</a> — <code>duplicate_ticket_group_hierarchy</code></p> | |
| <p>When duplicating an event that has a group with a foreign parent, the duplicated group should reference the <strong>same foreign parent</strong> (not duplicate it). The foreign parent belongs to another event and should gain a new child.</p> | |
| <pre><code># In duplicate_ticket_group_hierarchy: | |
| # If the source group has a foreign parent, point the copy to the SAME foreign parent | |
| # (don't try to duplicate the foreign parent or remap it) | |
| if source_group.foreign_parent? | |
| new_group.parent = source_group.parent # same foreign parent | |
| else | |
| new_group.parent = parent_mapping[source_group.parent_id] # local remapping | |
| end</code></pre> | |
| </div> | |
| <h3>Phase 2: Admin API</h3> | |
| <div class="card card-orange"> | |
| <h4>2.1 TicketGroupsController: Accept foreign parent_id</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/ticket_groups_controller.rb">app/controllers/ticket_groups_controller.rb</a></p> | |
| <p>The controller already permits <code>:parent_id</code> in strong params (<a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/ticket_groups_controller.rb:39">line 39-44</a>). The only change is ensuring authorization checks the parent's team matches.</p> | |
| <p>The existing <code>load_and_authorize_resource :ticket_group, through: :team</code> already team-scopes. The model validation from 1.1 handles the rest.</p> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>2.2 New endpoint: List eligible foreign parent groups</h4> | |
| <p>The frontend needs a way to browse TicketGroups from other events in the same team. Add an endpoint:</p> | |
| <pre><code># In ticket_groups_controller.rb or a new endpoint: | |
| # GET /teams/:team_id/ticket_groups/foreign_parents?exclude_event_id=123 | |
| # | |
| # Returns TicketGroups from other events in the team that can be used as parents. | |
| # Filters: same team, different event, has capacity set. | |
| def foreign_parents | |
| groups = @team.calendar_events | |
| .where.not(id: params[:exclude_event_id]) | |
| .joins(:ticket_groups) | |
| .merge(TicketGroup.where.not(capacity: nil)) | |
| # serialize with event name for display context | |
| end</code></pre> | |
| </div> | |
| <h3>Phase 3: Ticket Settings UI</h3> | |
| <div class="card card-orange"> | |
| <h4>3.1 "Link to Shared Group" option in TicketGroup component</h4> | |
| <p><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/TicketGroup.jsx">ConfirmTicketing/components/TicketGroup.jsx</a></p> | |
| <p>Add an action (button or menu item) to link a sub-group to a foreign parent:</p> | |
| <ul> | |
| <li>Opens a modal/dropdown listing eligible groups from other events (via the 2.2 endpoint)</li> | |
| <li>Shows: Event Name → Group Name (capacity: N)</li> | |
| <li>On selection, sets <code>parent_id</code> to the foreign group's ID</li> | |
| <li>The capacity field becomes read-only (inherited from foreign parent)</li> | |
| </ul> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>3.2 Shared capacity indicator</h4> | |
| <p>For groups with a foreign parent, show:</p> | |
| <ul> | |
| <li><span class="badge badge-blue">SHARED CAPACITY</span> badge on the group header</li> | |
| <li>"Linked to [Group Name] on [Event Name] — X sold / Y capacity across Z events"</li> | |
| <li>Capacity field shows the foreign parent's capacity (read-only)</li> | |
| <li>Link to the foreign parent event for capacity management</li> | |
| </ul> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>3.3 Foreign children indicator on the VIP Event</h4> | |
| <p>On the VIP Event's ticket settings, the "VIP Room Pool" group's <code>sub_groups</code> will include the foreign children from Events A-D. Show these with:</p> | |
| <ul> | |
| <li>Event name label on each foreign sub-group</li> | |
| <li>Sold/reserved counts per foreign child</li> | |
| <li>Total pool utilization summary</li> | |
| </ul> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>What Changes vs. What Doesn't</h2> | |
| <div class="columns"> | |
| <div class="card card-green"> | |
| <h4>No changes needed</h4> | |
| <ul> | |
| <li>No migration (parent_id already exists, no same-event FK constraint)</li> | |
| <li>FulfillmentMetric (all propagation works)</li> | |
| <li>Checkout flow (ticket types load by calendar_event_id)</li> | |
| <li>Locking (always on, lock_hierarchy! works cross-event)</li> | |
| <li>AddOnOrderItem validations</li> | |
| <li>WebOrdersController</li> | |
| <li>TicketReservationsController</li> | |
| <li>Orderable concern</li> | |
| <li>ChooseAddOns.jsx / checkout frontend</li> | |
| </ul> | |
| </div> | |
| <div class="card card-orange"> | |
| <h4>Changes needed</h4> | |
| <ul> | |
| <li>TicketGroup model: team validation, helper methods</li> | |
| <li>TicketGroup duplication: preserve foreign parent refs</li> | |
| <li>TicketGroupsController: foreign parents endpoint</li> | |
| <li>TicketGroup.jsx: "Link to Shared Group" UI</li> | |
| <li>TicketGroup.jsx: shared capacity indicator</li> | |
| <li>Blueprint: include foreign parent context in serialization</li> | |
| <li>Ability tests for foreign group operations</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Critical Files</h2> | |
| <table> | |
| <tr><th>File</th><th>Change</th><th>Type</th></tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/ticket_group.rb">app/models/ticket_group.rb</a></td> | |
| <td>Team validation, <code>foreign_parent?</code>, <code>foreign_sub_groups</code>, <code>local_sub_groups</code></td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/models/calendar_event.rb:3112">app/models/calendar_event.rb</a></td> | |
| <td><code>duplicate_ticket_group_hierarchy</code> — preserve foreign parent refs</td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/controllers/ticket_groups_controller.rb">app/controllers/ticket_groups_controller.rb</a></td> | |
| <td>New <code>foreign_parents</code> endpoint</td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/config/routes.rb:513">config/routes.rb</a></td> | |
| <td>Add <code>foreign_parents</code> collection route</td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/blueprints/internal/confirm_show/ticket_group_blueprint.rb">app/blueprints/.../ticket_group_blueprint.rb</a></td> | |
| <td>Add foreign parent context fields</td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| <tr> | |
| <td><a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/app/javascript/bundles/ConfirmTicketing/components/TicketGroup.jsx">ConfirmTicketing/components/TicketGroup.jsx</a></td> | |
| <td>"Link to Shared Group" UI, shared capacity indicator, foreign children display</td> | |
| <td><span class="badge badge-orange">MODIFY</span></td> | |
| </tr> | |
| </table> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Verification</h2> | |
| <h3>Unit Tests</h3> | |
| <ul> | |
| <li>TicketGroup with foreign parent validates same team</li> | |
| <li>TicketGroup with foreign parent from different team is rejected</li> | |
| <li><code>foreign_parent?</code> returns true/false correctly</li> | |
| <li><code>foreign_sub_groups</code> / <code>local_sub_groups</code> filter correctly</li> | |
| <li>FulfillmentMetric <code>sellable_count</code> uses foreign parent capacity as ceiling</li> | |
| <li>FulfillmentMetric <code>increment_with_propagation!</code> propagates to foreign parent</li> | |
| <li><code>lock_hierarchy!</code> locks foreign parent's FulfillmentMetric</li> | |
| </ul> | |
| <h3>Integration Test</h3> | |
| <ul> | |
| <li>Create VIP Event with TicketGroup (capacity: 10)</li> | |
| <li>Create Events A and B on different VenueOwnerships, same team</li> | |
| <li>Create TicketGroups on A and B with foreign parent → VIP group</li> | |
| <li>Create TicketTypes on A and B in those groups</li> | |
| <li>Simulate selling 8 via Event A → Event B shows 2 remaining</li> | |
| <li>Simulate selling 2 via Event B → both show sold out</li> | |
| <li>Duplicate Event A → duplicated event references same VIP parent, VIP group now has 3 children</li> | |
| </ul> | |
| <h3>Manual Verification</h3> | |
| <ul> | |
| <li>Create VIP Room VenueOwnership and Confirm</li> | |
| <li>Create TicketGroup with capacity on VIP Confirm</li> | |
| <li>Create events on other venues</li> | |
| <li>Link sub-groups to the VIP group via ticket settings UI</li> | |
| <li>Add "VIP Room Access" ticket types to each linked group</li> | |
| <li>Purchase on Event A → capacity decreases across all events</li> | |
| <li>VIP Event ticket settings shows all foreign children with utilization</li> | |
| </ul> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Masquerade Setup Guide</h2> | |
| <p>Once the feature is built, Masquerade's setup is:</p> | |
| <div class="card card-green"> | |
| <ol style="margin-left: 1.25rem;"> | |
| <li><strong>Create a Confirm</strong> on the VIP Room VenueOwnership (the "VIP Event")</li> | |
| <li><strong>Create a TicketGroup</strong> on the VIP Event with <code>capacity: 50</code> (or whatever the room holds)</li> | |
| <li>For each event (A, B, C, D) that should offer VIP room access: | |
| <ul> | |
| <li><strong>Add a sub-group</strong> on the event</li> | |
| <li><strong>Link it</strong> to the VIP Event's group (via "Link to Shared Group" UI)</li> | |
| <li><strong>Add a ticket type</strong> "VIP Room Access" in that group with the desired price</li> | |
| </ul> | |
| </li> | |
| <li>Customers see "VIP Room Access" as a regular ticket type during checkout</li> | |
| <li>Capacity is shared — 50 total across all events</li> | |
| <li>VIP Event ticket settings shows total utilization across all linked events</li> | |
| </ol> | |
| </div> | |
| <hr class="sep"> | |
| <!-- ======================================================= --> | |
| <h2>Risk Assessment</h2> | |
| <table> | |
| <tr><th>Risk</th><th>Severity</th><th>Mitigation</th></tr> | |
| <tr> | |
| <td><strong>Capacity dilution on event duplication</strong> — duplicating Event A adds a 5th consumer to the shared pool without explicit notice</td> | |
| <td><span class="badge badge-yellow">LOW</span></td> | |
| <td>Shared capacity indicator in UI makes the relationship visible. Admin is intentionally duplicating.</td> | |
| </tr> | |
| <tr> | |
| <td><strong>Blueprint serialization</strong> — VIP Event's group serializes foreign children, potentially large payload</td> | |
| <td><span class="badge badge-yellow">LOW</span></td> | |
| <td>Lazy-load foreign children or paginate if needed. Unlikely to exceed 10-20 foreign children.</td> | |
| </tr> | |
| <tr> | |
| <td><strong>acts_as_list scope</strong> — foreign children have different <code>calendar_event_id</code>, so position is scoped independently per event</td> | |
| <td><span class="badge badge-yellow">LOW</span></td> | |
| <td>Positions work correctly within each event. Display order of foreign children on the VIP Event can use created_at or event date.</td> | |
| </tr> | |
| </table> | |
| <hr class="sep"> | |
| <div class="card card-accent" style="margin-top: 2rem;"> | |
| <p><strong>Previous plan (superseded):</strong> <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/plans/o-1668-masquerade-vip-room-add-on.md">plans/o-1668-masquerade-vip-room-add-on.md</a> — room_id approach</p> | |
| <p><strong>Architecture briefing:</strong> <a class="cursor-link" href="cursor://file/Users/ryanlingle/worktrees/opendate-o-1668-briefing/O-1668-architecture-briefing.html">O-1668-architecture-briefing.html</a></p> | |
| <p><strong>Branch:</strong> <code>erictobias/o-1668-masquerade-vip-room-add-on</code></p> | |
| <p><strong>Worktree:</strong> <code>/Users/ryanlingle/worktrees/opendate-o-1668-briefing</code></p> | |
| <p class="dim">Revised 2026-03-18 — foreign group nesting approach replaces room_id approach</p> | |
| </div> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment