Skip to content

Instantly share code, notes, and snippets.

@ryan-lingle
Last active March 18, 2026 15:15
Show Gist options
  • Select an option

  • Save ryan-lingle/090bb71a82a3b5561afec2f842c8b22f to your computer and use it in GitHub Desktop.

Select an option

Save ryan-lingle/090bb71a82a3b5561afec2f842c8b22f to your computer and use it in GitHub Desktop.
O-1668: VIP Room Shared Capacity — Architecture Briefing & Revised Plan
<!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 &amp; 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 &lt; now &lt; 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 &amp; 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 &amp; 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 &amp; 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>
<!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 &amp; 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