Initial commit - Black Canyon Tickets whitelabel platform

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 12:31:31 -06:00
commit 997c129383
139 changed files with 60476 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create organizations table
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
logo TEXT,
stripe_account_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create users table with organization reference
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL,
name TEXT,
organization_id UUID REFERENCES organizations(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create events table
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
slug TEXT NOT NULL,
venue TEXT NOT NULL,
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
description TEXT,
created_by UUID REFERENCES users(id) NOT NULL,
organization_id UUID REFERENCES organizations(id) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(slug, organization_id)
);
-- Create tickets table
CREATE TABLE tickets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID REFERENCES events(id) NOT NULL,
uuid TEXT UNIQUE NOT NULL DEFAULT uuid_generate_v4()::TEXT,
price DECIMAL(10,2) NOT NULL,
purchaser_email TEXT NOT NULL,
purchaser_name TEXT,
checked_in BOOLEAN DEFAULT FALSE,
scanned_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create payouts table
CREATE TABLE payouts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID REFERENCES events(id) NOT NULL,
gross DECIMAL(10,2) NOT NULL,
fee DECIMAL(10,2) NOT NULL,
net DECIMAL(10,2) NOT NULL,
stripe_transfer_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE payouts ENABLE ROW LEVEL SECURITY;
-- RLS Policies for organizations
CREATE POLICY "Users can view their own organization" ON organizations
FOR SELECT USING (id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
));
CREATE POLICY "Users can update their own organization" ON organizations
FOR UPDATE USING (id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
));
-- RLS Policies for users
CREATE POLICY "Users can view their own profile" ON users
FOR SELECT USING (id = auth.uid());
CREATE POLICY "Users can update their own profile" ON users
FOR UPDATE USING (id = auth.uid());
-- RLS Policies for events
CREATE POLICY "Users can view events from their organization" ON events
FOR SELECT USING (organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
));
CREATE POLICY "Users can create events for their organization" ON events
FOR INSERT WITH CHECK (
organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
) AND created_by = auth.uid()
);
CREATE POLICY "Users can update events they created" ON events
FOR UPDATE USING (created_by = auth.uid());
CREATE POLICY "Users can delete events they created" ON events
FOR DELETE USING (created_by = auth.uid());
-- RLS Policies for tickets
CREATE POLICY "Users can view tickets for their organization's events" ON tickets
FOR SELECT USING (event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
CREATE POLICY "Anyone can create tickets" ON tickets
FOR INSERT WITH CHECK (true);
CREATE POLICY "Users can update tickets for their organization's events" ON tickets
FOR UPDATE USING (event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
-- RLS Policies for payouts
CREATE POLICY "Users can view payouts for their organization's events" ON payouts
FOR SELECT USING (event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
CREATE POLICY "Users can create payouts for their organization's events" ON payouts
FOR INSERT WITH CHECK (event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
-- Create indexes for better performance
CREATE INDEX idx_users_organization_id ON users(organization_id);
CREATE INDEX idx_events_organization_id ON events(organization_id);
CREATE INDEX idx_events_created_by ON events(created_by);
CREATE INDEX idx_events_slug ON events(slug);
CREATE INDEX idx_tickets_event_id ON tickets(event_id);
CREATE INDEX idx_tickets_uuid ON tickets(uuid);
CREATE INDEX idx_tickets_purchaser_email ON tickets(purchaser_email);
CREATE INDEX idx_payouts_event_id ON payouts(event_id);

View File

@@ -0,0 +1,61 @@
-- Add fee structure columns to organizations table
ALTER TABLE organizations
ADD COLUMN platform_fee_type VARCHAR(20) DEFAULT 'percentage_plus_fixed',
ADD COLUMN platform_fee_percentage DECIMAL(5,4) DEFAULT 0.0300,
ADD COLUMN platform_fee_fixed INTEGER DEFAULT 30,
ADD COLUMN platform_fee_notes TEXT;
-- Add comments for clarity
COMMENT ON COLUMN organizations.platform_fee_type IS 'Fee type: percentage, fixed, percentage_plus_fixed';
COMMENT ON COLUMN organizations.platform_fee_percentage IS 'Percentage fee (0.03 = 3%)';
COMMENT ON COLUMN organizations.platform_fee_fixed IS 'Fixed fee in cents (30 = $0.30)';
COMMENT ON COLUMN organizations.platform_fee_notes IS 'Notes about the fee structure for this organization';
-- Update existing organizations with default fees
UPDATE organizations
SET
platform_fee_type = 'percentage_plus_fixed',
platform_fee_percentage = 0.0300,
platform_fee_fixed = 30
WHERE platform_fee_type IS NULL;
-- Add fee tracking to tickets table
ALTER TABLE tickets
ADD COLUMN platform_fee_charged INTEGER DEFAULT 0,
ADD COLUMN organizer_net INTEGER DEFAULT 0;
COMMENT ON COLUMN tickets.platform_fee_charged IS 'Platform fee charged in cents';
COMMENT ON COLUMN tickets.organizer_net IS 'Net amount organizer receives in cents';
-- Create fee_structures table for historical tracking and templates
CREATE TABLE fee_structures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
description TEXT,
fee_type VARCHAR(20) NOT NULL DEFAULT 'percentage_plus_fixed',
fee_percentage DECIMAL(5,4) DEFAULT 0.0000,
fee_fixed INTEGER DEFAULT 0,
is_template BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert some common fee structure templates
INSERT INTO fee_structures (name, description, fee_type, fee_percentage, fee_fixed, is_template) VALUES
('Standard Platform Fee', 'Default 3% + $0.30 per transaction', 'percentage_plus_fixed', 0.0300, 30, true),
('Percentage Only 3%', '3% of transaction, no fixed fee', 'percentage', 0.0300, 0, true),
('Percentage Only 2.5%', '2.5% of transaction, no fixed fee', 'percentage', 0.0250, 0, true),
('Fixed Fee Only', '$1.00 flat fee per transaction', 'fixed', 0.0000, 100, true),
('Premium Rate', '3.5% + $0.50 for premium features', 'percentage_plus_fixed', 0.0350, 50, true),
('Volume Discount', '2% + $0.25 for high-volume clients', 'percentage_plus_fixed', 0.0200, 25, true);
-- Enable RLS on fee_structures
ALTER TABLE fee_structures ENABLE ROW LEVEL SECURITY;
-- Anyone can read templates
CREATE POLICY "Anyone can view fee structure templates" ON fee_structures
FOR SELECT USING (is_template = true);
-- Add indexes for performance
CREATE INDEX idx_organizations_platform_fee_type ON organizations(platform_fee_type);
CREATE INDEX idx_tickets_platform_fee ON tickets(platform_fee_charged);
CREATE INDEX idx_fee_structures_template ON fee_structures(is_template);

View File

@@ -0,0 +1,211 @@
-- Add seating and ticket type functionality
-- Create seating maps table
CREATE TABLE seating_maps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
description TEXT,
venue_name TEXT NOT NULL,
layout_data JSONB NOT NULL, -- Store SVG/JSON layout data
total_capacity INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create ticket types table
CREATE TABLE ticket_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID REFERENCES events(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL, -- e.g., "General Admission", "VIP", "Balcony"
description TEXT,
price DECIMAL(10,2) NOT NULL,
quantity_available INTEGER, -- NULL for unlimited
quantity_sold INTEGER DEFAULT 0,
seating_section TEXT, -- Reference to section in seating map
seating_map_id UUID REFERENCES seating_maps(id),
sale_start_time TIMESTAMP WITH TIME ZONE,
sale_end_time TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT valid_quantities CHECK (quantity_sold <= COALESCE(quantity_available, quantity_sold))
);
-- Create seats table for assigned seating
CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
seating_map_id UUID REFERENCES seating_maps(id) ON DELETE CASCADE NOT NULL,
seat_number TEXT NOT NULL, -- e.g., "A1", "B12"
section TEXT NOT NULL, -- e.g., "Orchestra", "Balcony"
row_name TEXT NOT NULL, -- e.g., "A", "B"
seat_in_row INTEGER NOT NULL, -- 1, 2, 3, etc.
x_position DECIMAL(8,3), -- X coordinate for visual positioning
y_position DECIMAL(8,3), -- Y coordinate for visual positioning
is_accessible BOOLEAN DEFAULT false,
seat_type TEXT DEFAULT 'standard', -- standard, wheelchair, companion
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(seating_map_id, seat_number)
);
-- Update tickets table to support ticket types and seats
ALTER TABLE tickets
ADD COLUMN ticket_type_id UUID REFERENCES ticket_types(id),
ADD COLUMN seat_id UUID REFERENCES seats(id),
ADD COLUMN seat_number TEXT, -- For general admission or when seat_id is not used
ADD COLUMN section TEXT; -- For general admission sections
-- Update events table to support seating maps
ALTER TABLE events
ADD COLUMN seating_map_id UUID REFERENCES seating_maps(id),
ADD COLUMN seating_type VARCHAR(20) DEFAULT 'general_admission'; -- 'general_admission', 'assigned_seating', 'mixed'
-- Create indexes for performance
CREATE INDEX idx_ticket_types_event_id ON ticket_types(event_id);
CREATE INDEX idx_ticket_types_active ON ticket_types(is_active);
CREATE INDEX idx_seats_seating_map_id ON seats(seating_map_id);
CREATE INDEX idx_seats_section ON seats(section);
CREATE INDEX idx_tickets_ticket_type_id ON tickets(ticket_type_id);
CREATE INDEX idx_tickets_seat_id ON tickets(seat_id);
-- Enable RLS on new tables
ALTER TABLE seating_maps ENABLE ROW LEVEL SECURITY;
ALTER TABLE ticket_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE seats ENABLE ROW LEVEL SECURITY;
-- RLS Policies for seating_maps
CREATE POLICY "Anyone can view seating maps" ON seating_maps
FOR SELECT USING (true);
CREATE POLICY "Users can create seating maps" ON seating_maps
FOR INSERT WITH CHECK (true);
CREATE POLICY "Users can update their seating maps" ON seating_maps
FOR UPDATE USING (true);
-- RLS Policies for ticket_types
CREATE POLICY "Anyone can view active ticket types" ON ticket_types
FOR SELECT USING (is_active = true OR event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
CREATE POLICY "Users can manage ticket types for their events" ON ticket_types
FOR ALL USING (event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
));
-- RLS Policies for seats
CREATE POLICY "Anyone can view seats" ON seats
FOR SELECT USING (true);
CREATE POLICY "Users can manage seats" ON seats
FOR ALL USING (true);
-- Insert some sample seating maps
INSERT INTO seating_maps (name, description, venue_name, layout_data, total_capacity) VALUES
(
'Small Theater Layout',
'Intimate theater with 100 seats in 10 rows',
'Black Canyon Theater',
'{
"type": "theater",
"sections": [
{
"name": "Orchestra",
"rows": 10,
"seatsPerRow": 10,
"startRow": "A",
"pricing": "standard"
}
],
"dimensions": {"width": 500, "height": 400}
}',
100
),
(
'Concert Hall Layout',
'Large concert hall with multiple sections',
'Mountain View Concert Hall',
'{
"type": "concert_hall",
"sections": [
{
"name": "Floor",
"capacity": 500,
"type": "general_admission"
},
{
"name": "Balcony",
"rows": 8,
"seatsPerRow": 20,
"startRow": "AA",
"pricing": "premium"
}
],
"dimensions": {"width": 800, "height": 600}
}',
660
),
(
'Wedding Reception Layout',
'Round tables for wedding reception',
'Aspen Lodge',
'{
"type": "reception",
"sections": [
{
"name": "Main Floor",
"tables": 12,
"seatsPerTable": 8,
"tableNumbers": [1,2,3,4,5,6,7,8,9,10,11,12]
}
],
"dimensions": {"width": 600, "height": 500}
}',
96
);
-- Create function to automatically update quantity_sold when tickets are created
CREATE OR REPLACE FUNCTION update_ticket_type_quantity()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE ticket_types
SET quantity_sold = quantity_sold + 1
WHERE id = NEW.ticket_type_id;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
UPDATE ticket_types
SET quantity_sold = GREATEST(0, quantity_sold - 1)
WHERE id = OLD.ticket_type_id;
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Create triggers for automatic quantity updates
CREATE TRIGGER ticket_quantity_insert_trigger
AFTER INSERT ON tickets
FOR EACH ROW
WHEN (NEW.ticket_type_id IS NOT NULL)
EXECUTE FUNCTION update_ticket_type_quantity();
CREATE TRIGGER ticket_quantity_delete_trigger
AFTER DELETE ON tickets
FOR EACH ROW
WHEN (OLD.ticket_type_id IS NOT NULL)
EXECUTE FUNCTION update_ticket_type_quantity();
-- Add comments for clarity
COMMENT ON TABLE seating_maps IS 'Venue seating layouts that can be reused across events';
COMMENT ON TABLE ticket_types IS 'Different ticket categories for events (GA, VIP, etc.)';
COMMENT ON TABLE seats IS 'Individual seats for assigned seating venues';
COMMENT ON COLUMN ticket_types.seating_section IS 'References section name in seating_map layout_data';
COMMENT ON COLUMN events.seating_type IS 'general_admission, assigned_seating, or mixed';
COMMENT ON COLUMN tickets.seat_number IS 'Seat identifier for general admission or display purposes';
COMMENT ON COLUMN tickets.section IS 'Section name for organization purposes';

View File

@@ -0,0 +1,199 @@
-- Add admin system with role-based access control
-- Add user roles
ALTER TABLE users
ADD COLUMN role VARCHAR(20) DEFAULT 'organizer',
ADD COLUMN is_active BOOLEAN DEFAULT true,
ADD COLUMN last_login TIMESTAMP WITH TIME ZONE,
ADD COLUMN created_by UUID REFERENCES users(id); -- Track who created this user
-- Update existing users to have organizer role
UPDATE users SET role = 'organizer' WHERE role IS NULL;
-- Create admin_settings table for platform configuration
CREATE TABLE admin_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_key TEXT UNIQUE NOT NULL,
setting_value JSONB NOT NULL,
description TEXT,
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create audit_logs table for tracking admin actions
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id),
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'view'
resource_type TEXT NOT NULL, -- 'user', 'organization', 'event', 'ticket'
resource_id UUID,
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create platform_stats view for admin dashboard
CREATE VIEW platform_stats AS
SELECT
(SELECT COUNT(*) FROM users WHERE role = 'organizer' AND is_active = true) as active_organizers,
(SELECT COUNT(*) FROM users WHERE role = 'admin') as admin_users,
(SELECT COUNT(*) FROM organizations) as total_organizations,
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM events WHERE start_time >= NOW()) as upcoming_events,
(SELECT COUNT(*) FROM tickets) as total_tickets_sold,
(SELECT COALESCE(SUM(price), 0) FROM tickets) as total_revenue,
(SELECT COALESCE(SUM(platform_fee_charged), 0) FROM tickets) as total_platform_fees,
(SELECT COUNT(DISTINCT DATE(created_at)) FROM tickets WHERE created_at >= NOW() - INTERVAL '30 days') as active_days_last_30,
(SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '7 days') as new_users_last_7_days;
-- Update RLS policies for admin access
-- Users table - admins can view all users
CREATE POLICY "Admins can view all users" ON users
FOR SELECT USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
id = auth.uid()
);
CREATE POLICY "Admins can update any user" ON users
FOR UPDATE USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
id = auth.uid()
);
CREATE POLICY "Admins can create users" ON users
FOR INSERT WITH CHECK (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin')
);
-- Organizations table - admins can view all organizations
CREATE POLICY "Admins can view all organizations" ON organizations
FOR SELECT USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
id IN (SELECT organization_id FROM users WHERE id = auth.uid())
);
CREATE POLICY "Admins can update any organization" ON organizations
FOR UPDATE USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
id IN (SELECT organization_id FROM users WHERE id = auth.uid())
);
-- Events table - admins can view all events
CREATE POLICY "Admins can view all events" ON events
FOR SELECT USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
organization_id IN (SELECT organization_id FROM users WHERE id = auth.uid())
);
-- Tickets table - admins can view all tickets
CREATE POLICY "Admins can view all tickets" ON tickets
FOR SELECT USING (
auth.uid() IN (SELECT id FROM users WHERE role = 'admin') OR
event_id IN (
SELECT id FROM events WHERE organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
)
)
);
-- Enable RLS on new tables
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_settings ENABLE ROW LEVEL SECURITY;
-- RLS Policies for audit_logs
CREATE POLICY "Admins can view all audit logs" ON audit_logs
FOR SELECT USING (auth.uid() IN (SELECT id FROM users WHERE role = 'admin'));
CREATE POLICY "System can create audit logs" ON audit_logs
FOR INSERT WITH CHECK (true);
-- RLS Policies for admin_settings
CREATE POLICY "Admins can manage settings" ON admin_settings
FOR ALL USING (auth.uid() IN (SELECT id FROM users WHERE role = 'admin'));
-- Insert default admin settings
INSERT INTO admin_settings (setting_key, setting_value, description) VALUES
('platform_name', '"Black Canyon Tickets"', 'Platform display name'),
('platform_email', '"support@blackcanyontickets.com"', 'Platform support email'),
('default_platform_fee_percentage', '0.03', 'Default platform fee percentage'),
('default_platform_fee_fixed', '30', 'Default platform fee fixed amount in cents'),
('max_events_per_organization', '100', 'Maximum events per organization'),
('email_notifications_enabled', 'true', 'Enable email notifications'),
('maintenance_mode', 'false', 'Platform maintenance mode');
-- Create function to log admin actions
CREATE OR REPLACE FUNCTION log_admin_action(
p_action TEXT,
p_resource_type TEXT,
p_resource_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, old_values, new_values)
VALUES (auth.uid(), p_action, p_resource_type, p_resource_id, p_old_values, p_new_values);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create function to check if user is admin
CREATE OR REPLACE FUNCTION is_admin(user_uuid UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM users
WHERE id = user_uuid AND role = 'admin' AND is_active = true
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create indexes for performance
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_is_active ON users(is_active);
CREATE INDEX idx_users_last_login ON users(last_login);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_resource_type ON audit_logs(resource_type);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_admin_settings_key ON admin_settings(setting_key);
-- Update the auth signup function to handle admin creation
CREATE OR REPLACE FUNCTION handle_auth_signup()
RETURNS TRIGGER AS $$
DECLARE
user_role TEXT := 'organizer';
BEGIN
-- Check if this is the first user (make them admin)
IF NOT EXISTS (SELECT 1 FROM users LIMIT 1) THEN
user_role := 'admin';
END IF;
-- Create user record in users table with error handling
INSERT INTO users (id, email, name, role)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'name', NEW.email),
user_role
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = COALESCE(EXCLUDED.name, users.name),
last_login = NOW();
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Log the error but don't fail the auth process
RAISE WARNING 'Failed to create user record for %: %', NEW.email, SQLERRM;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Add comments for clarity
COMMENT ON COLUMN users.role IS 'User role: admin, organizer, staff';
COMMENT ON COLUMN users.is_active IS 'Whether the user account is active';
COMMENT ON COLUMN users.created_by IS 'Admin user who created this account';
COMMENT ON TABLE audit_logs IS 'Audit trail for admin actions';
COMMENT ON TABLE admin_settings IS 'Platform-wide configuration settings';
COMMENT ON VIEW platform_stats IS 'Aggregated platform statistics for admin dashboard';

View File

@@ -0,0 +1,184 @@
-- Add fee payment model options to organizations table
-- This determines whether customers pay the platform fee on top of the ticket price
-- or if the fee is absorbed into the ticket price
ALTER TABLE organizations
ADD COLUMN platform_fee_model VARCHAR(20) DEFAULT 'customer_pays',
ADD COLUMN absorb_fee_in_price BOOLEAN DEFAULT false;
-- Update fee_structures table to include the fee model
ALTER TABLE fee_structures
ADD COLUMN fee_model VARCHAR(20) DEFAULT 'customer_pays',
ADD COLUMN absorb_fee_in_price BOOLEAN DEFAULT false;
-- Add comments for clarity
COMMENT ON COLUMN organizations.platform_fee_model IS 'Fee payment model: customer_pays, absorbed_in_price';
COMMENT ON COLUMN organizations.absorb_fee_in_price IS 'Whether to hide fee by including it in the displayed ticket price';
COMMENT ON COLUMN fee_structures.fee_model IS 'Fee payment model: customer_pays, absorbed_in_price';
COMMENT ON COLUMN fee_structures.absorb_fee_in_price IS 'Whether to hide fee by including it in the displayed ticket price';
-- Update existing organizations with default fee model and actual BCT rates
UPDATE organizations
SET
platform_fee_model = 'customer_pays',
absorb_fee_in_price = false,
platform_fee_percentage = 0.025, -- 2.5% BCT platform fee
platform_fee_fixed = 150 -- $1.50 BCT platform fee
WHERE platform_fee_model IS NULL;
-- Update existing fee structure templates with actual rates
UPDATE fee_structures
SET
fee_model = 'customer_pays',
absorb_fee_in_price = false
WHERE fee_model IS NULL;
-- Update the default template to reflect actual BCT rates
UPDATE fee_structures
SET
fee_percentage = 0.025,
fee_fixed = 150,
description = 'BCT platform fee: 2.5% + $1.50 per transaction'
WHERE name = 'Standard Platform Fee' AND is_template = true;
-- Add new fee structure templates with absorbed fee model and correct BCT rates
INSERT INTO fee_structures (name, description, fee_type, fee_percentage, fee_fixed, fee_model, absorb_fee_in_price, is_template) VALUES
('All-Inclusive BCT Standard', 'BCT platform fee 2.5% + $1.50 included in ticket price', 'percentage_plus_fixed', 0.025, 150, 'absorbed_in_price', true, true),
('All-Inclusive Percentage Only', '2.5% fee included in ticket price', 'percentage', 0.025, 0, 'absorbed_in_price', true, true),
('All-Inclusive Fixed Only', '$1.50 fee included in ticket price', 'fixed', 0.0000, 150, 'absorbed_in_price', true, true),
('Premium All-Inclusive', 'Premium 3% + $2.00 fee included in ticket price', 'percentage_plus_fixed', 0.030, 200, 'absorbed_in_price', true, true);
-- Add template for Stripe fee structure (for reference/calculation)
INSERT INTO fee_structures (name, description, fee_type, fee_percentage, fee_fixed, fee_model, absorb_fee_in_price, is_template) VALUES
('Stripe Processing Fee', 'Stripe credit card processing: 2.99% + $0.30', 'percentage_plus_fixed', 0.0299, 30, 'customer_pays', false, true);
-- Add function to calculate the display price based on fee model
CREATE OR REPLACE FUNCTION calculate_display_price(
base_price DECIMAL,
fee_percentage DECIMAL DEFAULT 0.03,
fee_fixed INTEGER DEFAULT 30,
fee_type VARCHAR DEFAULT 'percentage_plus_fixed',
fee_model VARCHAR DEFAULT 'customer_pays'
) RETURNS DECIMAL AS $$
DECLARE
platform_fee DECIMAL;
display_price DECIMAL;
BEGIN
-- Calculate platform fee based on fee type
CASE fee_type
WHEN 'percentage' THEN
platform_fee := base_price * fee_percentage;
WHEN 'fixed' THEN
platform_fee := fee_fixed / 100.0; -- Convert cents to dollars
WHEN 'percentage_plus_fixed' THEN
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
ELSE
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
END CASE;
-- Calculate display price based on fee model
CASE fee_model
WHEN 'customer_pays' THEN
-- Customer pays base price + platform fee
display_price := base_price;
WHEN 'absorbed_in_price' THEN
-- Platform fee is absorbed into the display price
-- To maintain the same net revenue for organizer,
-- we need to increase the display price to cover the fee
display_price := base_price + platform_fee;
ELSE
display_price := base_price;
END CASE;
RETURN ROUND(display_price, 2);
END;
$$ LANGUAGE plpgsql;
-- Add function to calculate the total amount customer pays
CREATE OR REPLACE FUNCTION calculate_customer_total(
base_price DECIMAL,
fee_percentage DECIMAL DEFAULT 0.03,
fee_fixed INTEGER DEFAULT 30,
fee_type VARCHAR DEFAULT 'percentage_plus_fixed',
fee_model VARCHAR DEFAULT 'customer_pays'
) RETURNS DECIMAL AS $$
DECLARE
platform_fee DECIMAL;
customer_total DECIMAL;
BEGIN
-- Calculate platform fee based on fee type
CASE fee_type
WHEN 'percentage' THEN
platform_fee := base_price * fee_percentage;
WHEN 'fixed' THEN
platform_fee := fee_fixed / 100.0; -- Convert cents to dollars
WHEN 'percentage_plus_fixed' THEN
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
ELSE
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
END CASE;
-- Calculate total amount customer pays
CASE fee_model
WHEN 'customer_pays' THEN
-- Customer pays base price + platform fee separately
customer_total := base_price + platform_fee;
WHEN 'absorbed_in_price' THEN
-- Customer pays only the display price (fee is included)
customer_total := base_price;
ELSE
customer_total := base_price + platform_fee;
END CASE;
RETURN ROUND(customer_total, 2);
END;
$$ LANGUAGE plpgsql;
-- Add function to calculate organizer net with fee model
CREATE OR REPLACE FUNCTION calculate_organizer_net(
base_price DECIMAL,
fee_percentage DECIMAL DEFAULT 0.03,
fee_fixed INTEGER DEFAULT 30,
fee_type VARCHAR DEFAULT 'percentage_plus_fixed',
fee_model VARCHAR DEFAULT 'customer_pays'
) RETURNS DECIMAL AS $$
DECLARE
platform_fee DECIMAL;
organizer_net DECIMAL;
BEGIN
-- Calculate platform fee based on fee type
CASE fee_type
WHEN 'percentage' THEN
platform_fee := base_price * fee_percentage;
WHEN 'fixed' THEN
platform_fee := fee_fixed / 100.0; -- Convert cents to dollars
WHEN 'percentage_plus_fixed' THEN
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
ELSE
platform_fee := (base_price * fee_percentage) + (fee_fixed / 100.0);
END CASE;
-- Calculate organizer net (what they receive)
organizer_net := base_price - platform_fee;
-- Ensure organizer net is never negative
IF organizer_net < 0 THEN
organizer_net := 0;
END IF;
RETURN ROUND(organizer_net, 2);
END;
$$ LANGUAGE plpgsql;
-- Add indexes for performance
CREATE INDEX idx_organizations_platform_fee_model ON organizations(platform_fee_model);
CREATE INDEX idx_fee_structures_fee_model ON fee_structures(fee_model);
-- Add check constraints to ensure valid fee models
ALTER TABLE organizations
ADD CONSTRAINT check_platform_fee_model
CHECK (platform_fee_model IN ('customer_pays', 'absorbed_in_price'));
ALTER TABLE fee_structures
ADD CONSTRAINT check_fee_model
CHECK (fee_model IN ('customer_pays', 'absorbed_in_price'));

View File

@@ -0,0 +1,246 @@
-- Standardize BCT platform fees across all organizations
-- Organizations can only customize HOW fees are applied, not the fee amounts
-- Remove fee amount columns from organizations table since fees are now standard
-- Keep only the fee application model (how fees are presented to customers)
ALTER TABLE organizations
DROP COLUMN IF EXISTS platform_fee_type,
DROP COLUMN IF EXISTS platform_fee_percentage,
DROP COLUMN IF EXISTS platform_fee_fixed;
-- Rename fee model column for clarity
ALTER TABLE organizations
RENAME COLUMN platform_fee_model TO fee_display_model;
-- Add comment for clarity
COMMENT ON COLUMN organizations.fee_display_model IS 'How fees are displayed to customers: customer_pays (separate line) or absorbed_in_price (included)';
COMMENT ON COLUMN organizations.absorb_fee_in_price IS 'Whether to include BCT fee in displayed ticket price (true) or show separately (false)';
-- Create platform_settings table for system-wide configuration
CREATE TABLE platform_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_key TEXT UNIQUE NOT NULL,
setting_value JSONB NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT false, -- Whether this setting can be viewed by organizers
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert standard BCT fee structure
INSERT INTO platform_settings (setting_key, setting_value, description, is_public) VALUES
('bct_platform_fee_percentage', '0.025', 'BCT platform fee percentage (2.5%)', true),
('bct_platform_fee_fixed', '150', 'BCT platform fee fixed amount in cents ($1.50)', true),
('stripe_fee_percentage', '0.0299', 'Stripe processing fee percentage (2.99%)', true),
('stripe_fee_fixed', '30', 'Stripe processing fee fixed amount in cents ($0.30)', true),
('platform_name', '"Black Canyon Tickets"', 'Platform display name', true),
('platform_email', '"support@blackcanyontickets.com"', 'Platform support email', true),
('max_events_per_organization', '100', 'Maximum events per organization', false);
-- Update all existing organizations to use standard fee display model
UPDATE organizations
SET
fee_display_model = COALESCE(fee_display_model, 'customer_pays'),
absorb_fee_in_price = COALESCE(absorb_fee_in_price, false);
-- Set default fee display model for new organizations
ALTER TABLE organizations
ALTER COLUMN fee_display_model SET DEFAULT 'customer_pays',
ALTER COLUMN absorb_fee_in_price SET DEFAULT false;
-- Create function to get current BCT platform fees
CREATE OR REPLACE FUNCTION get_bct_platform_fees()
RETURNS TABLE(
fee_percentage DECIMAL,
fee_fixed INTEGER
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT (setting_value#>>'{}')::DECIMAL FROM platform_settings WHERE setting_key = 'bct_platform_fee_percentage'),
(SELECT (setting_value#>>'{}')::INTEGER FROM platform_settings WHERE setting_key = 'bct_platform_fee_fixed');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create function to get current Stripe fees
CREATE OR REPLACE FUNCTION get_stripe_fees()
RETURNS TABLE(
fee_percentage DECIMAL,
fee_fixed INTEGER
) AS $$
BEGIN
RETURN QUERY
SELECT
(SELECT (setting_value#>>'{}')::DECIMAL FROM platform_settings WHERE setting_key = 'stripe_fee_percentage'),
(SELECT (setting_value#>>'{}')::INTEGER FROM platform_settings WHERE setting_key = 'stripe_fee_fixed');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update the display price calculation function to use standard fees
CREATE OR REPLACE FUNCTION calculate_display_price_standard(
base_price DECIMAL,
fee_display_model VARCHAR DEFAULT 'customer_pays'
) RETURNS DECIMAL AS $$
DECLARE
bct_fee_percentage DECIMAL;
bct_fee_fixed INTEGER;
platform_fee DECIMAL;
display_price DECIMAL;
BEGIN
-- Get current BCT platform fees
SELECT fee_percentage, fee_fixed INTO bct_fee_percentage, bct_fee_fixed
FROM get_bct_platform_fees();
-- Calculate BCT platform fee
platform_fee := (base_price * bct_fee_percentage) + (bct_fee_fixed / 100.0);
-- Calculate display price based on fee model
CASE fee_display_model
WHEN 'customer_pays' THEN
-- Customer pays base price + platform fee separately
display_price := base_price;
WHEN 'absorbed_in_price' THEN
-- Platform fee is absorbed into the display price
display_price := base_price + platform_fee;
ELSE
display_price := base_price;
END CASE;
RETURN ROUND(display_price, 2);
END;
$$ LANGUAGE plpgsql;
-- Update the customer total calculation function to use standard fees
CREATE OR REPLACE FUNCTION calculate_customer_total_standard(
base_price DECIMAL,
fee_display_model VARCHAR DEFAULT 'customer_pays'
) RETURNS DECIMAL AS $$
DECLARE
bct_fee_percentage DECIMAL;
bct_fee_fixed INTEGER;
platform_fee DECIMAL;
customer_total DECIMAL;
BEGIN
-- Get current BCT platform fees
SELECT fee_percentage, fee_fixed INTO bct_fee_percentage, bct_fee_fixed
FROM get_bct_platform_fees();
-- Calculate BCT platform fee
platform_fee := (base_price * bct_fee_percentage) + (bct_fee_fixed / 100.0);
-- Calculate total amount customer pays
CASE fee_display_model
WHEN 'customer_pays' THEN
-- Customer pays base price + platform fee separately
customer_total := base_price + platform_fee;
WHEN 'absorbed_in_price' THEN
-- Customer pays only the display price (fee is included)
customer_total := base_price;
ELSE
customer_total := base_price + platform_fee;
END CASE;
RETURN ROUND(customer_total, 2);
END;
$$ LANGUAGE plpgsql;
-- Update the organizer net calculation function to use standard fees
CREATE OR REPLACE FUNCTION calculate_organizer_net_standard(
base_price DECIMAL
) RETURNS DECIMAL AS $$
DECLARE
bct_fee_percentage DECIMAL;
bct_fee_fixed INTEGER;
platform_fee DECIMAL;
organizer_net DECIMAL;
BEGIN
-- Get current BCT platform fees
SELECT fee_percentage, fee_fixed INTO bct_fee_percentage, bct_fee_fixed
FROM get_bct_platform_fees();
-- Calculate BCT platform fee
platform_fee := (base_price * bct_fee_percentage) + (bct_fee_fixed / 100.0);
-- Calculate organizer net (what they receive before Stripe fees)
organizer_net := base_price - platform_fee;
-- Ensure organizer net is never negative
IF organizer_net < 0 THEN
organizer_net := 0;
END IF;
RETURN ROUND(organizer_net, 2);
END;
$$ LANGUAGE plpgsql;
-- Enable RLS on platform_settings
ALTER TABLE platform_settings ENABLE ROW LEVEL SECURITY;
-- Public settings can be viewed by authenticated users
CREATE POLICY "Authenticated users can view public platform settings" ON platform_settings
FOR SELECT USING (is_public = true AND auth.role() = 'authenticated');
-- Only admins can manage platform settings
CREATE POLICY "Admins can manage platform settings" ON platform_settings
FOR ALL USING (auth.uid() IN (SELECT id FROM users WHERE role = 'admin'));
-- Add indexes for performance
CREATE INDEX idx_platform_settings_key ON platform_settings(setting_key);
CREATE INDEX idx_platform_settings_public ON platform_settings(is_public);
CREATE INDEX idx_organizations_fee_display_model ON organizations(fee_display_model);
-- Add check constraint to ensure valid fee display models
ALTER TABLE organizations
DROP CONSTRAINT IF EXISTS check_platform_fee_model,
ADD CONSTRAINT check_fee_display_model
CHECK (fee_display_model IN ('customer_pays', 'absorbed_in_price'));
-- Update fee_structures table to remove custom fee amounts (since fees are now standard)
-- Keep only for reference and templates
ALTER TABLE fee_structures
ADD COLUMN is_deprecated BOOLEAN DEFAULT false;
-- Mark old custom fee structures as deprecated
UPDATE fee_structures
SET is_deprecated = true
WHERE is_template = false;
-- Clean up old fee structure templates and add new standard ones
DELETE FROM fee_structures WHERE is_template = true;
INSERT INTO fee_structures (name, description, fee_type, fee_percentage, fee_fixed, fee_model, absorb_fee_in_price, is_template) VALUES
('BCT Standard - Customer Pays', 'Customer pays BCT fee (2.5% + $1.50) as separate line item', 'percentage_plus_fixed', 0.025, 150, 'customer_pays', false, true),
('BCT Standard - All Inclusive', 'BCT fee (2.5% + $1.50) included in ticket price', 'percentage_plus_fixed', 0.025, 150, 'absorbed_in_price', true, true),
('Stripe Processing Fee', 'Stripe credit card processing: 2.99% + $0.30', 'percentage_plus_fixed', 0.0299, 30, 'customer_pays', false, true);
-- Add audit logging for platform settings changes
CREATE OR REPLACE FUNCTION log_platform_settings_change()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, old_values, new_values)
VALUES (
auth.uid(),
'update',
'platform_settings',
NEW.id,
row_to_json(OLD),
row_to_json(NEW)
);
ELSIF TG_OP = 'INSERT' THEN
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, new_values)
VALUES (
auth.uid(),
'create',
'platform_settings',
NEW.id,
row_to_json(NEW)
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER platform_settings_audit_trigger
AFTER INSERT OR UPDATE ON platform_settings
FOR EACH ROW EXECUTE FUNCTION log_platform_settings_change();

View File

@@ -0,0 +1,271 @@
-- Create premium add-ons system for BCT platform
-- This allows monetizing features like seating maps, AI descriptions, etc.
-- Create add_on_types table for available premium features
CREATE TABLE add_on_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE NOT NULL, -- e.g., 'seating-maps', 'ai-description'
name TEXT NOT NULL,
description TEXT NOT NULL,
pricing_type VARCHAR(20) NOT NULL DEFAULT 'per_event', -- 'per_event', 'monthly', 'annual', 'per_ticket'
price_cents INTEGER NOT NULL, -- Price in cents
category VARCHAR(50) NOT NULL DEFAULT 'feature', -- 'feature', 'service', 'analytics', 'marketing'
is_active BOOLEAN DEFAULT true,
requires_setup BOOLEAN DEFAULT false, -- Whether add-on needs admin setup
auto_enable_conditions JSONB, -- Conditions for auto-enabling (e.g., event size)
feature_flags JSONB, -- What features this unlocks
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create event_add_ons table to track purchased add-ons per event
CREATE TABLE event_add_ons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID REFERENCES events(id) ON DELETE CASCADE NOT NULL,
add_on_type_id UUID REFERENCES add_on_types(id) NOT NULL,
organization_id UUID REFERENCES organizations(id) NOT NULL,
purchase_price_cents INTEGER NOT NULL, -- Price paid (may differ from current price)
status VARCHAR(20) DEFAULT 'active', -- 'active', 'cancelled', 'expired'
purchased_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE, -- For time-limited add-ons
metadata JSONB, -- Add-on specific configuration
stripe_payment_intent_id TEXT, -- Link to Stripe payment
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create organization_subscriptions for monthly/annual add-ons
CREATE TABLE organization_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
add_on_type_id UUID REFERENCES add_on_types(id) NOT NULL,
status VARCHAR(20) DEFAULT 'active', -- 'active', 'cancelled', 'expired', 'past_due'
current_period_start TIMESTAMP WITH TIME ZONE NOT NULL,
current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
stripe_subscription_id TEXT,
stripe_customer_id TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Insert default premium add-ons
INSERT INTO add_on_types (slug, name, description, pricing_type, price_cents, category, requires_setup, feature_flags, sort_order) VALUES
-- Event Setup & Management (Automated Features - Low Cost)
('ai-event-description', 'AI Event Description', 'Professional AI-generated event descriptions optimized for your venue and audience', 'per_event', 500, 'service', false, '{"ai_description": true}', 1),
('premium-setup-service', 'Premium Setup Service', 'Dedicated onboarding specialist helps create and optimize your event', 'per_event', 5000, 'service', true, '{"priority_support": true, "setup_assistance": true}', 2),
('custom-event-branding', 'Custom Event Branding', 'Custom colors, styling, and logo integration for your ticket pages', 'per_event', 1000, 'feature', false, '{"custom_branding": true}', 3),
-- Advanced Features (Automated - Low Cost)
('seating-maps', 'Visual Seating Management', 'Interactive venue maps with seat selection, table assignments, and VIP sections', 'per_event', 1500, 'feature', false, '{"seating_maps": true, "seat_selection": true}', 4),
('guest-list-pro', 'Guest List Pro', 'Advanced attendee management with check-in app, VIP flagging, and notes', 'per_event', 1000, 'feature', false, '{"advanced_guest_management": true, "checkin_app": true}', 5),
('premium-analytics', 'Premium Analytics', 'Advanced sales forecasting, customer insights, and marketing performance tracking', 'per_event', 1000, 'analytics', false, '{"advanced_analytics": true, "forecasting": true, "demographics": true}', 6),
('ticket-scanner', 'Professional Ticket Scanner', 'Advanced QR code scanning with offline support, guest check-in tracking, and real-time reports', 'per_event', 500, 'feature', false, '{"ticket_scanner": true, "offline_scanning": true, "checkin_reports": true}', 7),
-- Marketing & Promotion (Mostly Automated - Moderate Cost)
('email-marketing-suite', 'Email Marketing Suite', 'Professional email templates, automated sequences, and post-event follow-up', 'per_event', 2000, 'marketing', false, '{"email_marketing": true, "automated_sequences": true}', 8),
('social-media-package', 'Social Media Package', 'Auto-generated posts, Instagram templates, and Facebook event integration', 'per_event', 1500, 'marketing', false, '{"social_media_tools": true, "auto_posts": true}', 9),
-- White-Glove Services
('concierge-management', 'Concierge Event Management', 'Dedicated event manager with day-of coordination and real-time support', 'per_event', 50000, 'service', true, '{"dedicated_manager": true, "day_of_support": true}', 10),
('premium-support', 'Premium Customer Support', 'Priority phone/chat support with dedicated account manager', 'per_event', 20000, 'service', false, '{"priority_support": true, "dedicated_manager": true}', 11),
-- Subscriptions
('bct-pro-monthly', 'BCT Pro Monthly', 'All premium features included for unlimited events', 'monthly', 19900, 'subscription', false, '{"all_features": true, "unlimited_events": true}', 12),
('enterprise-package', 'Enterprise Package', 'Multi-venue management, white-label options, and custom development', 'monthly', 99900, 'subscription', true, '{"multi_venue": true, "white_label": true, "api_access": true}', 13);
-- Function to check if organization has specific add-on for event
CREATE OR REPLACE FUNCTION has_event_addon(p_event_id UUID, p_addon_slug TEXT)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM event_add_ons ea
JOIN add_on_types at ON ea.add_on_type_id = at.id
WHERE ea.event_id = p_event_id
AND at.slug = p_addon_slug
AND ea.status = 'active'
AND (ea.expires_at IS NULL OR ea.expires_at > NOW())
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to check if organization has subscription add-on
CREATE OR REPLACE FUNCTION has_subscription_addon(p_organization_id UUID, p_addon_slug TEXT)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM organization_subscriptions os
JOIN add_on_types at ON os.add_on_type_id = at.id
WHERE os.organization_id = p_organization_id
AND at.slug = p_addon_slug
AND os.status = 'active'
AND os.current_period_end > NOW()
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to check if organization/event has any add-on with specific feature flag
CREATE OR REPLACE FUNCTION has_feature_access(p_organization_id UUID, p_event_id UUID, p_feature_flag TEXT)
RETURNS BOOLEAN AS $$
BEGIN
-- Check subscription add-ons
IF EXISTS (
SELECT 1
FROM organization_subscriptions os
JOIN add_on_types at ON os.add_on_type_id = at.id
WHERE os.organization_id = p_organization_id
AND os.status = 'active'
AND os.current_period_end > NOW()
AND at.feature_flags ? p_feature_flag
) THEN
RETURN TRUE;
END IF;
-- Check event-specific add-ons
IF p_event_id IS NOT NULL AND EXISTS (
SELECT 1
FROM event_add_ons ea
JOIN add_on_types at ON ea.add_on_type_id = at.id
WHERE ea.event_id = p_event_id
AND ea.status = 'active'
AND (ea.expires_at IS NULL OR ea.expires_at > NOW())
AND at.feature_flags ? p_feature_flag
) THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get available add-ons for organization/event
CREATE OR REPLACE FUNCTION get_available_addons(p_organization_id UUID, p_event_id UUID DEFAULT NULL)
RETURNS TABLE(
addon_id UUID,
slug TEXT,
name TEXT,
description TEXT,
pricing_type TEXT,
price_cents INTEGER,
category TEXT,
has_access BOOLEAN,
purchased_at TIMESTAMP WITH TIME ZONE
) AS $$
BEGIN
RETURN QUERY
SELECT
at.id as addon_id,
at.slug,
at.name,
at.description,
at.pricing_type,
at.price_cents,
at.category,
CASE
WHEN has_subscription_addon(p_organization_id, at.slug) THEN TRUE
WHEN p_event_id IS NOT NULL AND has_event_addon(p_event_id, at.slug) THEN TRUE
ELSE FALSE
END as has_access,
COALESCE(
(SELECT ea.purchased_at FROM event_add_ons ea WHERE ea.event_id = p_event_id AND ea.add_on_type_id = at.id AND ea.status = 'active' LIMIT 1),
(SELECT os.created_at FROM organization_subscriptions os WHERE os.organization_id = p_organization_id AND os.add_on_type_id = at.id AND os.status = 'active' LIMIT 1)
) as purchased_at
FROM add_on_types at
WHERE at.is_active = true
ORDER BY at.sort_order, at.name;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Enable RLS
ALTER TABLE add_on_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE event_add_ons ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_subscriptions ENABLE ROW LEVEL SECURITY;
-- RLS Policies for add_on_types (everyone can read active add-ons)
CREATE POLICY "Anyone can view active add-on types" ON add_on_types
FOR SELECT USING (is_active = true);
-- Admins can manage add-on types
CREATE POLICY "Admins can manage add-on types" ON add_on_types
FOR ALL USING (auth.uid() IN (SELECT id FROM users WHERE role = 'admin'));
-- RLS Policies for event_add_ons
CREATE POLICY "Users can view their organization's event add-ons" ON event_add_ons
FOR SELECT USING (
organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
) OR auth.uid() IN (SELECT id FROM users WHERE role = 'admin')
);
CREATE POLICY "Users can purchase add-ons for their events" ON event_add_ons
FOR INSERT WITH CHECK (
event_id IN (
SELECT id FROM events WHERE created_by = auth.uid()
)
);
-- RLS Policies for organization_subscriptions
CREATE POLICY "Users can view their organization's subscriptions" ON organization_subscriptions
FOR SELECT USING (
organization_id IN (
SELECT organization_id FROM users WHERE id = auth.uid()
) OR auth.uid() IN (SELECT id FROM users WHERE role = 'admin')
);
-- Indexes for performance
CREATE INDEX idx_add_on_types_slug ON add_on_types(slug);
CREATE INDEX idx_add_on_types_active ON add_on_types(is_active);
CREATE INDEX idx_add_on_types_category ON add_on_types(category);
CREATE INDEX idx_event_add_ons_event_id ON event_add_ons(event_id);
CREATE INDEX idx_event_add_ons_organization_id ON event_add_ons(organization_id);
CREATE INDEX idx_event_add_ons_status ON event_add_ons(status);
CREATE INDEX idx_organization_subscriptions_org_id ON organization_subscriptions(organization_id);
CREATE INDEX idx_organization_subscriptions_status ON organization_subscriptions(status);
-- Audit logging for add-on purchases
CREATE OR REPLACE FUNCTION log_addon_purchase()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, new_values)
VALUES (
auth.uid(),
'purchase',
'event_add_on',
NEW.id,
row_to_json(NEW)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER event_add_ons_audit_trigger
AFTER INSERT ON event_add_ons
FOR EACH ROW EXECUTE FUNCTION log_addon_purchase();
-- Add add-on revenue tracking to platform_stats view
DROP VIEW IF EXISTS platform_stats;
CREATE VIEW platform_stats AS
SELECT
(SELECT COUNT(*) FROM users WHERE role = 'organizer' AND is_active = true) as active_organizers,
(SELECT COUNT(*) FROM users WHERE role = 'admin') as admin_users,
(SELECT COUNT(*) FROM organizations) as total_organizations,
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM events WHERE start_time >= NOW()) as upcoming_events,
(SELECT COUNT(*) FROM tickets) as total_tickets_sold,
(SELECT COALESCE(SUM(price), 0) FROM tickets) as total_revenue,
(SELECT COALESCE(SUM(platform_fee_charged), 0) FROM tickets) as total_platform_fees,
(SELECT COALESCE(SUM(purchase_price_cents), 0) FROM event_add_ons WHERE status = 'active') as total_addon_revenue,
(SELECT COUNT(*) FROM event_add_ons WHERE status = 'active') as active_addons,
(SELECT COUNT(*) FROM organization_subscriptions WHERE status = 'active') as active_subscriptions,
(SELECT COUNT(DISTINCT DATE(created_at)) FROM tickets WHERE created_at >= NOW() - INTERVAL '30 days') as active_days_last_30,
(SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '7 days') as new_users_last_7_days;
-- Comments for documentation
COMMENT ON TABLE add_on_types IS 'Available premium add-ons and their pricing';
COMMENT ON TABLE event_add_ons IS 'Purchased add-ons for specific events';
COMMENT ON TABLE organization_subscriptions IS 'Monthly/annual subscriptions for organizations';
COMMENT ON FUNCTION has_event_addon IS 'Check if event has specific add-on purchased';
COMMENT ON FUNCTION has_subscription_addon IS 'Check if organization has subscription add-on';
COMMENT ON FUNCTION has_feature_access IS 'Check if organization/event has access to feature flag';

View File

@@ -0,0 +1,32 @@
-- Add fields to support featured events and public calendar integration
ALTER TABLE events
ADD COLUMN IF NOT EXISTS end_time TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS image_url TEXT,
ADD COLUMN IF NOT EXISTS category TEXT DEFAULT 'general',
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS is_published BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS external_source TEXT; -- Track if event is from scraper
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_events_is_featured ON events(is_featured);
CREATE INDEX IF NOT EXISTS idx_events_is_public ON events(is_public);
CREATE INDEX IF NOT EXISTS idx_events_category ON events(category);
CREATE INDEX IF NOT EXISTS idx_events_start_time ON events(start_time);
CREATE INDEX IF NOT EXISTS idx_events_external_source ON events(external_source);
-- Add RLS policy for public events (anyone can view public events)
CREATE POLICY IF NOT EXISTS "Anyone can view public published events" ON events
FOR SELECT USING (is_public = true AND is_published = true);
-- Update existing events to be public by default for backward compatibility
UPDATE events SET is_public = true, is_published = true WHERE is_public IS NULL;
-- Add comments for clarity
COMMENT ON COLUMN events.end_time IS 'Event end time - optional, derived from start_time if not provided';
COMMENT ON COLUMN events.image_url IS 'Featured image for the event';
COMMENT ON COLUMN events.category IS 'Event category (music, arts, community, business, food, sports, etc.)';
COMMENT ON COLUMN events.is_featured IS 'Whether event should be featured prominently';
COMMENT ON COLUMN events.is_public IS 'Whether event appears in public calendar';
COMMENT ON COLUMN events.is_published IS 'Whether event is published and visible';
COMMENT ON COLUMN events.external_source IS 'Source of external events (e.g., "scraper", "manual")';

View File

@@ -0,0 +1,157 @@
-- Add printed tickets support
-- This migration adds support for printed tickets with barcodes
-- Create printed_tickets table
CREATE TABLE printed_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
barcode_number TEXT NOT NULL UNIQUE,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
ticket_type_id UUID NOT NULL REFERENCES ticket_types(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'valid' CHECK (status IN ('valid', 'used', 'invalid')),
batch_number TEXT,
notes TEXT,
issued_by UUID REFERENCES users(id),
checked_in_at TIMESTAMP WITH TIME ZONE,
scanned_by UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create scan_attempts table for audit logging
CREATE TABLE scan_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
barcode_number TEXT NOT NULL,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
scanned_by UUID REFERENCES users(id),
result TEXT NOT NULL CHECK (result IN ('SUCCESS', 'INVALID_BARCODE', 'WRONG_EVENT', 'ALREADY_USED', 'NOT_VALID')),
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add indexes for performance
CREATE INDEX idx_printed_tickets_barcode ON printed_tickets(barcode_number);
CREATE INDEX idx_printed_tickets_event_id ON printed_tickets(event_id);
CREATE INDEX idx_printed_tickets_org_id ON printed_tickets(organization_id);
CREATE INDEX idx_scan_attempts_barcode ON scan_attempts(barcode_number);
CREATE INDEX idx_scan_attempts_event_id ON scan_attempts(event_id);
CREATE INDEX idx_scan_attempts_created_at ON scan_attempts(created_at);
-- Add RLS policies for multi-tenant security
ALTER TABLE printed_tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE scan_attempts ENABLE ROW LEVEL SECURITY;
-- Policies for printed_tickets
CREATE POLICY "Users can view printed tickets in their organization" ON printed_tickets
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = printed_tickets.organization_id
)
);
CREATE POLICY "Users can insert printed tickets in their organization" ON printed_tickets
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = printed_tickets.organization_id
)
);
CREATE POLICY "Users can update printed tickets in their organization" ON printed_tickets
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = printed_tickets.organization_id
)
);
-- Policies for scan_attempts
CREATE POLICY "Users can view scan attempts in their organization" ON scan_attempts
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = (
SELECT organization_id FROM events WHERE events.id = scan_attempts.event_id
)
)
);
CREATE POLICY "Users can insert scan attempts in their organization" ON scan_attempts
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = (
SELECT organization_id FROM events WHERE events.id = scan_attempts.event_id
)
)
);
-- Admin override policies (for users with admin privileges)
CREATE POLICY "Admin can view all printed tickets" ON printed_tickets
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id IS NULL
)
);
CREATE POLICY "Admin can manage all printed tickets" ON printed_tickets
FOR ALL USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id IS NULL
)
);
CREATE POLICY "Admin can view all scan attempts" ON scan_attempts
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id IS NULL
)
);
-- Create function to automatically set organization_id when inserting printed tickets
CREATE OR REPLACE FUNCTION set_printed_ticket_organization_id()
RETURNS TRIGGER AS $$
BEGIN
-- Set organization_id from the event
NEW.organization_id = (
SELECT organization_id
FROM events
WHERE id = NEW.event_id
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to automatically set organization_id
CREATE TRIGGER set_printed_ticket_organization_id_trigger
BEFORE INSERT ON printed_tickets
FOR EACH ROW
EXECUTE FUNCTION set_printed_ticket_organization_id();
-- Create function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_printed_ticket_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to update updated_at
CREATE TRIGGER update_printed_ticket_updated_at_trigger
BEFORE UPDATE ON printed_tickets
FOR EACH ROW
EXECUTE FUNCTION update_printed_ticket_updated_at();

View File

@@ -0,0 +1,144 @@
-- Add scanner lock functionality to events table
-- This migration adds support for locking scanner devices to scan-only mode
-- Add scanner lock fields to events table
ALTER TABLE events ADD COLUMN scanner_lock_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE events ADD COLUMN scanner_pin_hash TEXT;
ALTER TABLE events ADD COLUMN scanner_lock_created_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE events ADD COLUMN scanner_lock_created_by UUID REFERENCES users(id);
-- Create scanner_unlock_attempts table for audit logging
CREATE TABLE scanner_unlock_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
attempted_by UUID REFERENCES users(id),
attempt_result TEXT NOT NULL CHECK (attempt_result IN ('SUCCESS', 'FAILED', 'INVALID_PIN')),
ip_address TEXT,
user_agent TEXT,
device_info TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add indexes for performance
CREATE INDEX idx_scanner_unlock_attempts_event_id ON scanner_unlock_attempts(event_id);
CREATE INDEX idx_scanner_unlock_attempts_created_at ON scanner_unlock_attempts(created_at);
CREATE INDEX idx_scanner_unlock_attempts_result ON scanner_unlock_attempts(attempt_result);
-- Add RLS policies for scanner_unlock_attempts
ALTER TABLE scanner_unlock_attempts ENABLE ROW LEVEL SECURITY;
-- Policies for scanner_unlock_attempts
CREATE POLICY "Users can view scanner unlock attempts in their organization" ON scanner_unlock_attempts
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = (
SELECT organization_id FROM events WHERE events.id = scanner_unlock_attempts.event_id
)
)
);
CREATE POLICY "Users can insert scanner unlock attempts in their organization" ON scanner_unlock_attempts
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id = (
SELECT organization_id FROM events WHERE events.id = scanner_unlock_attempts.event_id
)
)
);
-- Admin override policies (for users with admin privileges)
CREATE POLICY "Admin can view all scanner unlock attempts" ON scanner_unlock_attempts
FOR SELECT USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id IS NULL
)
);
CREATE POLICY "Admin can manage all scanner unlock attempts" ON scanner_unlock_attempts
FOR ALL USING (
EXISTS (
SELECT 1 FROM users
WHERE users.id = auth.uid()
AND users.organization_id IS NULL
)
);
-- Create function to clean up scanner lock when event ends
CREATE OR REPLACE FUNCTION cleanup_expired_scanner_locks()
RETURNS void AS $$
BEGIN
-- Disable scanner locks for events that ended more than 24 hours ago
UPDATE events
SET
scanner_lock_enabled = FALSE,
scanner_pin_hash = NULL,
scanner_lock_created_at = NULL,
scanner_lock_created_by = NULL
WHERE
scanner_lock_enabled = TRUE
AND start_time < NOW() - INTERVAL '24 hours';
END;
$$ LANGUAGE plpgsql;
-- Create a function to be called when setting up scanner lock
CREATE OR REPLACE FUNCTION setup_scanner_lock(
p_event_id UUID,
p_pin_hash TEXT
) RETURNS BOOLEAN AS $$
BEGIN
-- Update the event with scanner lock settings
UPDATE events
SET
scanner_lock_enabled = TRUE,
scanner_pin_hash = p_pin_hash,
scanner_lock_created_at = NOW(),
scanner_lock_created_by = auth.uid()
WHERE id = p_event_id;
-- Return true if update was successful
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
-- Create a function to verify scanner PIN
CREATE OR REPLACE FUNCTION verify_scanner_pin(
p_event_id UUID,
p_pin_hash TEXT
) RETURNS BOOLEAN AS $$
DECLARE
stored_hash TEXT;
BEGIN
-- Get the stored PIN hash
SELECT scanner_pin_hash INTO stored_hash
FROM events
WHERE id = p_event_id AND scanner_lock_enabled = TRUE;
-- Return true if hashes match
RETURN stored_hash = p_pin_hash;
END;
$$ LANGUAGE plpgsql;
-- Create a function to disable scanner lock
CREATE OR REPLACE FUNCTION disable_scanner_lock(
p_event_id UUID
) RETURNS BOOLEAN AS $$
BEGIN
-- Update the event to disable scanner lock
UPDATE events
SET
scanner_lock_enabled = FALSE,
scanner_pin_hash = NULL,
scanner_lock_created_at = NULL,
scanner_lock_created_by = NULL
WHERE id = p_event_id;
-- Return true if update was successful
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;