From 02303ad1b5e1c4c2317828be33526b6f03b6a920 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 10:03:39 -0700 Subject: [PATCH 01/13] Add Eventlite exporter Adds support for exporting Eventlite sites to the convention JSON format, one export file per Eventlite event. - New `rake export:eventlite` task (requires EVENTLITE_DB_URL; optional DOMAIN_SUFFIX, TIMEZONE, FILE_BASE_URL) - Exports users (Devise bcrypt passthrough, names inferred from tickets), user_con_profiles, tickets (active only), store_items, store_orders, CMS layouts/pages/files/navigation - Schema extensions: cms_layouts top-level collection, cms_layout_name on cms_page, provides_ticket_type_name on store_item, eventlite source_system value - PostgreSQL support via pg gem - Integration test suite (skipped without EVENTLITE_TEST_DATABASE_URL) - CI: adds PostgreSQL 16 service alongside existing MySQL Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 13 + Gemfile | 1 + Gemfile.lock | 15 ++ convention-export.schema.json | 24 +- lib/intercode_import/eventlite.rb | 13 + lib/intercode_import/eventlite/exporter.rb | 227 ++++++++++++++++++ lib/intercode_import/eventlite/table.rb | 19 ++ lib/intercode_import/eventlite/tables.rb | 8 + .../eventlite/tables/cms_files.rb | 73 ++++++ .../eventlite/tables/cms_layouts.rb | 32 +++ .../eventlite/tables/navigation_items.rb | 36 +++ .../eventlite/tables/pages.rb | 41 ++++ .../eventlite/tables/tickets.rb | 41 ++++ .../eventlite/tables/users.rb | 60 +++++ tasks/export.rake | 24 ++ .../eventlite/cms_layouts_db_test.rb | 69 ++++++ .../eventlite/navigation_items_db_test.rb | 98 ++++++++ .../eventlite/pages_db_test.rb | 97 ++++++++ .../eventlite/tickets_db_test.rb | 83 +++++++ .../eventlite/users_db_test.rb | 138 +++++++++++ test/support/eventlite_db_test_helper.rb | 39 +++ test/test_helper.rb | 2 + 22 files changed, 1149 insertions(+), 4 deletions(-) create mode 100644 lib/intercode_import/eventlite.rb create mode 100644 lib/intercode_import/eventlite/exporter.rb create mode 100644 lib/intercode_import/eventlite/table.rb create mode 100644 lib/intercode_import/eventlite/tables.rb create mode 100644 lib/intercode_import/eventlite/tables/cms_files.rb create mode 100644 lib/intercode_import/eventlite/tables/cms_layouts.rb create mode 100644 lib/intercode_import/eventlite/tables/navigation_items.rb create mode 100644 lib/intercode_import/eventlite/tables/pages.rb create mode 100644 lib/intercode_import/eventlite/tables/tickets.rb create mode 100644 lib/intercode_import/eventlite/tables/users.rb create mode 100644 test/intercode_import/eventlite/cms_layouts_db_test.rb create mode 100644 test/intercode_import/eventlite/navigation_items_db_test.rb create mode 100644 test/intercode_import/eventlite/pages_db_test.rb create mode 100644 test/intercode_import/eventlite/tickets_db_test.rb create mode 100644 test/intercode_import/eventlite/users_db_test.rb create mode 100644 test/support/eventlite_db_test_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7eb8f3..3a3774b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest env: TEST_DATABASE_URL: mysql2://root:mysql@127.0.0.1:3306/intercode_import_test + EVENTLITE_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/eventlite_test services: mysql: image: mysql:8 @@ -30,6 +31,18 @@ jobs: --health-retries 5 ports: - 3306:3306 + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: eventlite_test + options: >- + --health-cmd "pg_isready" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v6 - name: Set up Ruby diff --git a/Gemfile b/Gemfile index cf9bf7e..76c15db 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ ruby File.read(File.expand_path('.ruby-version', __dir__)).strip gem 'activesupport' gem 'bcrypt' gem 'mysql2', '~> 0.5.3' +gem 'pg' gem 'nokogiri' gem 'parallel' gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock index ad73c51..7fda5b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -53,6 +53,13 @@ GEM nokogiri (1.19.3-x86_64-linux-musl) racc (~> 1.4) parallel (2.1.0) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) prism (1.9.0) racc (1.8.1) rake (13.4.2) @@ -84,6 +91,7 @@ DEPENDENCIES mysql2 (~> 0.5.3) nokogiri parallel + pg rake reverse_markdown sequel @@ -113,6 +121,13 @@ CHECKSUMS nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 nokogiri (1.19.3-x86_64-linux-musl) sha256=248c906d2166eca5efb56d52fdee5f9a1f51d69a72e2b64fdac647b4ce39ea3f parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 diff --git a/convention-export.schema.json b/convention-export.schema.json index 3e9add4..142f7bf 100644 --- a/convention-export.schema.json +++ b/convention-export.schema.json @@ -8,7 +8,7 @@ "additionalProperties": false, "properties": { "version": { "type": "string", "const": "1" }, - "source_system": { "type": "string", "enum": ["intercode1", "procon", "other"] }, + "source_system": { "type": "string", "enum": ["intercode1", "procon", "eventlite", "other"] }, "organization_name": { "type": ["string", "null"] }, "cms_content_set": { "type": ["string", "null"], @@ -28,7 +28,8 @@ "cms_files": { "type": "array", "items": { "$ref": "#/$defs/cms_file" } }, "cms_pages": { "type": "array", "items": { "$ref": "#/$defs/cms_page" } }, "cms_partials": { "type": "array", "items": { "$ref": "#/$defs/cms_partial" } }, - "cms_navigation_items": { "type": "array", "items": { "$ref": "#/$defs/cms_navigation_section" } } + "cms_navigation_items": { "type": "array", "items": { "$ref": "#/$defs/cms_navigation_section" } }, + "cms_layouts": { "type": "array", "items": { "$ref": "#/$defs/cms_layout" } } }, "$defs": { @@ -296,7 +297,11 @@ "name": { "type": "string" }, "description": { "type": ["string", "null"] }, "available": { "type": "boolean" }, - "price": { "$ref": "#/$defs/money" } + "price": { "$ref": "#/$defs/money" }, + "provides_ticket_type_name": { + "type": ["string", "null"], + "description": "Name of the ticket_type this product provides when purchased" + } } }, @@ -379,7 +384,18 @@ "properties": { "name": { "type": "string" }, "slug": { "type": "string" }, - "content": { "type": "string", "description": "Liquid template content" } + "content": { "type": "string", "description": "Liquid template content" }, + "cms_layout_name": { "type": ["string", "null"], "description": "Name of the cms_layout to use for this page" } + } + }, + + "cms_layout": { + "type": "object", + "required": ["name", "content"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "content": { "type": "string", "description": "Liquid template for the layout" } } }, diff --git a/lib/intercode_import/eventlite.rb b/lib/intercode_import/eventlite.rb new file mode 100644 index 0000000..3512d1e --- /dev/null +++ b/lib/intercode_import/eventlite.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'eventlite/table' +require_relative 'eventlite/tables' +require_relative 'eventlite/exporter' + +module IntercodeImport + module Eventlite + def self.logger + IntercodeImport::Logger.instance + end + end +end diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb new file mode 100644 index 0000000..689acd8 --- /dev/null +++ b/lib/intercode_import/eventlite/exporter.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'sequel' +require 'set' + +module IntercodeImport + module Eventlite + class Exporter + def initialize(db_url, domain_suffix: 'example.com', timezone: 'UTC', file_base_url: nil) + logger.info 'Connecting to Eventlite database' + @connection = Sequel.connect(db_url) + @domain_suffix = domain_suffix + @timezone = timezone + @file_base_url = file_base_url + end + + def export + site_settings = @connection[:site_settings].first + + users_table = Tables::Users.new(@connection) + all_users = users_table.export! + user_email_by_id = users_table.id_map + + @connection[:events].order(:id).map do |event_row| + export_event(event_row, site_settings, all_users, user_email_by_id) + end + end + + private + + def export_event(event_row, site_settings, all_users, user_email_by_id) + event_id = event_row[:id] + event_name = event_row[:name] + event_slug = event_row[:slug].presence || event_name.to_s.downcase.gsub(/\s+/, '-') + + layouts_table = Tables::CmsLayouts.new(@connection, event_id) + cms_layouts = layouts_table.export! + layout_name_by_id = layouts_table.id_map + + default_layout_content = nil + if event_row[:default_cms_layout_id] + default_row = @connection[:cms_layouts].where(id: event_row[:default_cms_layout_id]).first + default_layout_content = default_row&.fetch(:content, nil) + end + + ticket_type_rows = @connection[:ticket_types].where(event_id: event_id).order(:id).all + ticket_type_name_by_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = r[:name] } + + ticket_types = ticket_type_rows.map do |tt| + { + name: tt[:name], + allows_event_signups: true, + counts_towards_convention_maximum: true + } + end + + tickets_table = Tables::Tickets.new(@connection, event_id, ticket_type_name_by_id, user_email_by_id) + tickets = tickets_table.export! + + ticket_emails = Set.new(tickets.map { |t| t[:user_email] }) + event_users = all_users.select { |u| ticket_emails.include?(u[:email]) } + user_con_profiles = build_profiles(event_id, user_email_by_id) + + store_items = build_store_items(ticket_type_rows) + store_orders = build_store_orders(event_id, ticket_type_name_by_id, user_email_by_id, ticket_type_rows) + + cms_pages = Tables::Pages.new(@connection, event_id, layout_name_by_id).export! + cms_files = Tables::CmsFiles.new(@connection, event_id, @file_base_url).export! + cms_nav_items = Tables::NavigationItems.new(@connection, event_id).export! + + convention = { + name: site_settings&.fetch(:site_title, nil).presence || event_name, + domain: "#{event_slug}.#{@domain_suffix}", + timezone_name: @timezone, + site_mode: 'single_event', + ticket_mode: 'required_for_signup', + ticket_types: ticket_types, + event_categories: [default_event_category], + rooms: [], + staff_positions: admin_staff_positions(event_users) + } + + if event_row[:start_time] + convention[:starts_at] = event_row[:start_time].iso8601 + if event_row[:length_seconds] + convention[:ends_at] = (event_row[:start_time] + event_row[:length_seconds]).iso8601 + end + end + + convention[:default_layout_content] = default_layout_content if default_layout_content + + event_record = { id: event_id.to_s, title: event_name, event_category_name: 'Event', status: 'active' } + event_record[:length_seconds] = event_row[:length_seconds] if event_row[:length_seconds] + + { + version: '1', + source_system: 'eventlite', + convention: convention, + users: event_users, + user_con_profiles: user_con_profiles, + events: [event_record], + runs: [], + signups: [], + team_members: [], + tickets: tickets, + store_items: store_items, + store_orders: store_orders, + cms_layouts: cms_layouts, + cms_pages: cms_pages, + cms_files: cms_files, + cms_navigation_items: cms_nav_items + } + end + + def build_store_items(ticket_type_rows) + ticket_type_rows.map do |tt| + item = { + name: tt[:name], + available: true, + provides_ticket_type_name: tt[:name] + } + item[:price] = { fractional: tt[:price_cents], currency_code: 'USD' } if tt[:price_cents] + item + end + end + + def build_store_orders(event_id, ticket_type_name_by_id, user_email_by_id, ticket_type_rows) + price_by_ticket_type_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = r[:price_cents] } + + ticket_rows = @connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => event_id) + .select_all(:tickets) + .all + + ticket_rows.filter_map do |row| + user_email = user_email_by_id[row[:user_id]] + next unless user_email + + ticket_type_name = ticket_type_name_by_id[row[:ticket_type_id]] + next unless ticket_type_name + + entry = { store_item_name: ticket_type_name, quantity: 1 } + + list_price_cents = price_by_ticket_type_id[row[:ticket_type_id]] + entry[:price_per_item] = { fractional: list_price_cents, currency_code: 'USD' } if list_price_cents + + order = { + user_email: user_email, + status: row[:canceled_at] ? 'cancelled' : 'paid', + entries: [entry] + } + + if row[:payment_amount_cents] + order[:payment_amount] = { fractional: row[:payment_amount_cents], currency_code: 'USD' } + end + + order + end + end + + def build_profiles(event_id, user_email_by_id) + ticket_rows = @connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => event_id) + .where(Sequel[:tickets][:canceled_at] => nil) + .select_all(:tickets) + .order(Sequel[:tickets][:id]) + .all + + seen = {} + ticket_rows.each_with_object([]) do |row, profiles| + user_email = user_email_by_id[row[:user_id]] + next unless user_email + next if seen[user_email] + + seen[user_email] = true + first_name, last_name = split_name(row[:name].to_s.strip) + + profile = { user_email: user_email } + profile[:first_name] = first_name if first_name.present? + profile[:last_name] = last_name if last_name.present? + profile[:phone] = row[:phone].presence + + profiles << profile.compact + end + end + + def admin_staff_positions(event_users) + admin_emails = @connection[:users] + .where(admin: true) + .select(:email) + .map { |r| r[:email].to_s.downcase.strip } + .select { |e| e.present? && event_users.any? { |u| u[:email] == e } } + + return [] if admin_emails.empty? + + [{ + name: 'Admin', + visible: false, + permissions: ['update_convention', 'update_cms_content', 'update_event_proposals', + 'read_event_proposals', 'update_events', 'update_rooms', 'update_schedule'], + user_emails: admin_emails + }] + end + + def default_event_category + { + name: 'Event', + team_member_name: 'Organizer', + scheduling_ui: 'single_run', + event_form_title: 'Event Proposal Form' + } + end + + def split_name(full_name) + return ['', ''] if full_name.blank? + parts = full_name.split(' ', 2) + [parts[0] || '', parts[1] || ''] + end + + def logger + Eventlite.logger + end + end + end +end diff --git a/lib/intercode_import/eventlite/table.rb b/lib/intercode_import/eventlite/table.rb new file mode 100644 index 0000000..575e422 --- /dev/null +++ b/lib/intercode_import/eventlite/table.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + class Table < IntercodeImport::Table + def table_name + self.class.name.demodulize.underscore.to_sym + end + + private + + def row_id(row) = row[:id] + + def logger + IntercodeImport::Eventlite.logger + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables.rb b/lib/intercode_import/eventlite/tables.rb new file mode 100644 index 0000000..3bd2e3c --- /dev/null +++ b/lib/intercode_import/eventlite/tables.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative 'tables/users' +require_relative 'tables/tickets' +require_relative 'tables/cms_layouts' +require_relative 'tables/pages' +require_relative 'tables/cms_files' +require_relative 'tables/navigation_items' diff --git a/lib/intercode_import/eventlite/tables/cms_files.rb b/lib/intercode_import/eventlite/tables/cms_files.rb new file mode 100644 index 0000000..c61f89e --- /dev/null +++ b/lib/intercode_import/eventlite/tables/cms_files.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'net/http' +require 'base64' +require 'uri' + +module IntercodeImport + module Eventlite + module Tables + class CmsFiles < Eventlite::Table + def initialize(connection, event_id, file_base_url) + super(connection) + @event_id = event_id + @file_base_url = file_base_url + end + + def dataset + connection[:cms_files].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + unless @file_base_url + logger.warn 'FILE_BASE_URL not set; skipping CMS file export' + return [] + end + + logger.info "Exporting CmsFiles for event #{@event_id}" + results = [] + + dataset.each do |row| + filename = row[:file].to_s + next if filename.blank? + + # CarrierWave store_dir: uploads/cms_file/file/{id}/{filename} + file_path = "uploads/cms_file/file/#{row[:id]}/#{filename}" + url = @file_base_url.chomp('/') + '/' + file_path + + content, content_type = fetch_file(url) + next unless content + + results << { + filename: filename, + content_base64: Base64.strict_encode64(content), + content_type: content_type || 'application/octet-stream' + } + end + + results + end + + private + + def fetch_file(url) + uri = URI.parse(url) + response = Net::HTTP.get_response(uri) + + unless response.is_a?(Net::HTTPSuccess) + logger.warn "Failed to fetch #{url}: #{response.code} #{response.message}" + return nil + end + + content_type = response['Content-Type']&.split(';')&.first&.strip + [response.body, content_type] + rescue StandardError => e + logger.warn "Error fetching #{url}: #{e.message}" + nil + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/cms_layouts.rb b/lib/intercode_import/eventlite/tables/cms_layouts.rb new file mode 100644 index 0000000..a90d239 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/cms_layouts.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class CmsLayouts < Eventlite::Table + def initialize(connection, event_id) + super(connection) + @event_id = event_id + end + + def dataset + connection[:cms_layouts].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + logger.info "Exporting CmsLayouts for event #{@event_id}" + results = [] + + dataset.each do |row| + id_map[row[:id]] = row[:name] + results << { name: row[:name], content: row[:content] || '' } + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/navigation_items.rb b/lib/intercode_import/eventlite/tables/navigation_items.rb new file mode 100644 index 0000000..e4d4eb0 --- /dev/null +++ b/lib/intercode_import/eventlite/tables/navigation_items.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class NavigationItems < Eventlite::Table + def initialize(connection, event_id) + super(connection) + @event_id = event_id + end + + def dataset + connection[:navigation_items].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ).order(:position) + end + + def export! + logger.info "Exporting NavigationItems for event #{@event_id}" + + page_name_by_id = connection[:pages].select(:id, :name).all.each_with_object({}) do |row, h| + h[row[:id]] = row[:name] + end + + links = dataset.filter_map do |row| + page_name = page_name_by_id[row[:page_id]] + next unless page_name + { title: row[:title], page_name: page_name } + end + + links.empty? ? [] : [{ title: 'Navigation', links: links }] + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/pages.rb b/lib/intercode_import/eventlite/tables/pages.rb new file mode 100644 index 0000000..36e2f3c --- /dev/null +++ b/lib/intercode_import/eventlite/tables/pages.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Pages < Eventlite::Table + def initialize(connection, event_id, layout_name_by_id) + super(connection) + @event_id = event_id + @layout_name_by_id = layout_name_by_id + end + + def dataset + connection[:pages].where( + Sequel.lit('(parent_type = ? AND parent_id = ?) OR parent_type IS NULL', 'Event', @event_id) + ) + end + + def export! + logger.info "Exporting Pages for event #{@event_id}" + results = [] + + dataset.each do |row| + record = { + name: row[:name], + slug: row[:slug], + content: row[:content] || '' + } + + layout_name = @layout_name_by_id[row[:cms_layout_id]] + record[:cms_layout_name] = layout_name if layout_name + + results << record + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/tickets.rb b/lib/intercode_import/eventlite/tables/tickets.rb new file mode 100644 index 0000000..93b615d --- /dev/null +++ b/lib/intercode_import/eventlite/tables/tickets.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Tickets < Eventlite::Table + def initialize(connection, event_id, ticket_type_name_by_id, user_email_by_id) + super(connection) + @event_id = event_id + @ticket_type_name_by_id = ticket_type_name_by_id + @user_email_by_id = user_email_by_id + end + + def dataset + connection[:tickets] + .join(:ticket_types, id: :ticket_type_id) + .where(Sequel[:ticket_types][:event_id] => @event_id) + .where(Sequel[:tickets][:canceled_at] => nil) + .select_all(:tickets) + end + + def export! + logger.info "Exporting Tickets for event #{@event_id}" + results = [] + + dataset.each do |row| + user_email = @user_email_by_id[row[:user_id]] + next unless user_email + + ticket_type_name = @ticket_type_name_by_id[row[:ticket_type_id]] + next unless ticket_type_name + + results << { user_email: user_email, ticket_type_name: ticket_type_name } + end + + results + end + end + end + end +end diff --git a/lib/intercode_import/eventlite/tables/users.rb b/lib/intercode_import/eventlite/tables/users.rb new file mode 100644 index 0000000..a52f08b --- /dev/null +++ b/lib/intercode_import/eventlite/tables/users.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module IntercodeImport + module Eventlite + module Tables + class Users < Eventlite::Table + def initialize(connection) + super + @names_by_user_id = load_names_from_tickets + end + + def export! + logger.info 'Exporting Users' + results = [] + seen_emails = {} + + dataset.each do |row| + email = row[:email].to_s.downcase.strip + next if email.blank? + next if seen_emails[email] + + seen_emails[email] = true + first_name, last_name = parse_name(@names_by_user_id[row[:id]]) + + record = { + email: email, + first_name: first_name, + last_name: last_name + } + + if row[:encrypted_password].present? + record[:password_hash] = row[:encrypted_password] + record[:password_hash_type] = 'bcrypt' + end + + id_map[row[:id]] = email + results << record + end + + results + end + + private + + def load_names_from_tickets + connection[:tickets].select(:user_id, :name).all.each_with_object({}) do |row, h| + next if h[row[:user_id]] || row[:name].to_s.blank? + h[row[:user_id]] = row[:name].to_s.strip + end + end + + def parse_name(full_name) + return ['', ''] if full_name.blank? + parts = full_name.strip.split(' ', 2) + [parts[0] || '', parts[1] || ''] + end + end + end + end +end diff --git a/tasks/export.rake b/tasks/export.rake index 8933907..d2f084d 100644 --- a/tasks/export.rake +++ b/tasks/export.rake @@ -5,6 +5,7 @@ require 'intercode_import' require 'intercode_import/intercode1' require 'intercode_import/procon' require 'intercode_import/illyan' +require 'intercode_import/eventlite' def fetch_env!(name) value = ENV[name].presence @@ -58,6 +59,29 @@ namespace :export do end end + desc 'Export Eventlite events to convention JSON format (one file per event)' + task :eventlite do + exporter = IntercodeImport::Eventlite::Exporter.new( + fetch_env!('EVENTLITE_DB_URL'), + domain_suffix: ENV['DOMAIN_SUFFIX'] || 'example.com', + timezone: ENV['TIMEZONE'] || 'UTC', + file_base_url: ENV['FILE_BASE_URL'] + ) + exports = exporter.export + exports.each do |data| + domain = data[:convention][:domain].tr('.', '-') + default_name = "convention-export-#{domain}.json" + output_file = exports.size == 1 ? (ENV['OUTPUT_FILE'] || default_name) : default_name + json = JSON.pretty_generate(data) + if output_file == '-' + puts json + else + File.write(output_file, json) + puts "Wrote #{output_file} (#{data[:convention][:name]})" + end + end + end + desc 'Export users from an Illyan database' task :illyan do emails = fetch_env!('EMAILS').strip.split(/\s+/) diff --git a/test/intercode_import/eventlite/cms_layouts_db_test.rb b/test/intercode_import/eventlite/cms_layouts_db_test.rb new file mode 100644 index 0000000..f1868e0 --- /dev/null +++ b/test/intercode_import/eventlite/cms_layouts_db_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteCmsLayoutsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:cms_layouts) do + primary_key :id + String :name + String :content + Integer :parent_id + String :parent_type + end + end + + def self.teardown_db(db) + db.drop_table?(:cms_layouts) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:cms_layouts].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + end + + def export_layouts(event_id = @event_id) + IntercodeImport::Eventlite::Tables::CmsLayouts.new(@db, event_id).export! + end + + def test_event_scoped_layout_exported + @db[:cms_layouts].insert(name: 'Default', content: '{{ content_for_layout }}', + parent_type: 'Event', parent_id: @event_id) + layouts = export_layouts + assert_equal 1, layouts.size + assert_equal 'Default', layouts.first[:name] + assert_equal '{{ content_for_layout }}', layouts.first[:content] + end + + def test_global_layout_included + @db[:cms_layouts].insert(name: 'Global Layout', content: 'global', parent_type: nil, parent_id: nil) + assert_equal 1, export_layouts.size + end + + def test_other_event_layout_excluded + other_event_id = @db[:events].insert(name: 'Other Event') + @db[:cms_layouts].insert(name: 'Other Layout', content: 'other', + parent_type: 'Event', parent_id: other_event_id) + assert_empty export_layouts + end + + def test_id_map_keyed_by_layout_id + lid = @db[:cms_layouts].insert(name: 'My Layout', content: '', + parent_type: 'Event', parent_id: @event_id) + table = IntercodeImport::Eventlite::Tables::CmsLayouts.new(@db, @event_id) + table.export! + assert_equal 'My Layout', table.id_map[lid] + end +end diff --git a/test/intercode_import/eventlite/navigation_items_db_test.rb b/test/intercode_import/eventlite/navigation_items_db_test.rb new file mode 100644 index 0000000..c45a00d --- /dev/null +++ b/test/intercode_import/eventlite/navigation_items_db_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteNavigationItemsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:pages) do + primary_key :id + String :name + String :slug + Integer :parent_id + String :parent_type + end + db.create_table!(:navigation_items) do + primary_key :id + String :title + Integer :page_id + Integer :parent_id + String :parent_type + Integer :position + end + end + + def self.teardown_db(db) + db.drop_table?(:navigation_items) + db.drop_table?(:pages) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:navigation_items].delete + db[:pages].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + @page_id = @db[:pages].insert(name: 'Home', slug: 'home', + parent_type: 'Event', parent_id: @event_id) + end + + def export_nav + IntercodeImport::Eventlite::Tables::NavigationItems.new(@db, @event_id).export! + end + + def test_returns_empty_when_no_items + assert_empty export_nav + end + + def test_single_item_produces_one_section_with_one_link + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Navigation', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] + end + + def test_items_ordered_by_position + page2_id = @db[:pages].insert(name: 'About', slug: 'about', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'About', page_id: page2_id, position: 2, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + links = export_nav.first[:links] + assert_equal ['Home', 'About'], links.map { |l| l[:title] } + end + + def test_global_nav_items_included + global_page_id = @db[:pages].insert(name: 'Info', slug: 'info', parent_type: nil, parent_id: nil) + @db[:navigation_items].insert(title: 'Info', page_id: global_page_id, position: 1, + parent_type: nil, parent_id: nil) + assert_equal 1, export_nav.first[:links].size + end + + def test_other_event_items_excluded + other_id = @db[:events].insert(name: 'Other') + other_page_id = @db[:pages].insert(name: 'Other Home', slug: 'other-home', + parent_type: 'Event', parent_id: other_id) + @db[:navigation_items].insert(title: 'Other Home', page_id: other_page_id, position: 1, + parent_type: 'Event', parent_id: other_id) + assert_empty export_nav + end + + def test_item_with_missing_page_skipped + @db[:navigation_items].insert(title: 'Broken', page_id: 99999, position: 1, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end +end diff --git a/test/intercode_import/eventlite/pages_db_test.rb b/test/intercode_import/eventlite/pages_db_test.rb new file mode 100644 index 0000000..797cd58 --- /dev/null +++ b/test/intercode_import/eventlite/pages_db_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventlitePagesDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:cms_layouts) do + primary_key :id + String :name + String :content + Integer :parent_id + String :parent_type + end + db.create_table!(:pages) do + primary_key :id + String :name + String :slug + String :content + Integer :cms_layout_id + Integer :parent_id + String :parent_type + end + end + + def self.teardown_db(db) + db.drop_table?(:pages) + db.drop_table?(:cms_layouts) + db.drop_table?(:events) + end + + def truncate_tables(db) + db[:pages].delete + db[:cms_layouts].delete + db[:events].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + end + + def export_pages(layout_name_by_id = {}) + IntercodeImport::Eventlite::Tables::Pages.new(@db, @event_id, layout_name_by_id).export! + end + + def test_event_scoped_page_exported + @db[:pages].insert(name: 'Home', slug: 'home', content: 'Welcome!', + parent_type: 'Event', parent_id: @event_id) + pages = export_pages + assert_equal 1, pages.size + p = pages.first + assert_equal 'Home', p[:name] + assert_equal 'home', p[:slug] + assert_equal 'Welcome!', p[:content] + end + + def test_global_page_included + @db[:pages].insert(name: 'About', slug: 'about', content: 'About us', + parent_type: nil, parent_id: nil) + assert_equal 1, export_pages.size + end + + def test_other_event_page_excluded + other_id = @db[:events].insert(name: 'Other') + @db[:pages].insert(name: 'Other Page', slug: 'other', content: '', + parent_type: 'Event', parent_id: other_id) + assert_empty export_pages + end + + def test_cms_layout_name_included_when_mapped + lid = @db[:cms_layouts].insert(name: 'Default', content: '', + parent_type: 'Event', parent_id: @event_id) + @db[:pages].insert(name: 'Home', slug: 'home', content: '', + cms_layout_id: lid, parent_type: 'Event', parent_id: @event_id) + pages = export_pages(lid => 'Default') + assert_equal 'Default', pages.first[:cms_layout_name] + end + + def test_cms_layout_name_absent_when_not_mapped + @db[:pages].insert(name: 'Home', slug: 'home', content: '', + parent_type: 'Event', parent_id: @event_id) + pages = export_pages + refute pages.first.key?(:cms_layout_name) + end + + def test_nil_content_exported_as_empty_string + @db[:pages].insert(name: 'Empty', slug: 'empty', content: nil, + parent_type: 'Event', parent_id: @event_id) + assert_equal '', export_pages.first[:content] + end +end diff --git a/test/intercode_import/eventlite/tickets_db_test.rb b/test/intercode_import/eventlite/tickets_db_test.rb new file mode 100644 index 0000000..aa35d0e --- /dev/null +++ b/test/intercode_import/eventlite/tickets_db_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteTicketsDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:users) do + primary_key :id + String :email + end + db.create_table!(:events) do + primary_key :id + String :name + end + db.create_table!(:ticket_types) do + primary_key :id + Integer :event_id + String :name + end + db.create_table!(:tickets) do + primary_key :id + Integer :ticket_type_id + Integer :user_id + DateTime :canceled_at + end + end + + def self.teardown_db(db) + db.drop_table?(:tickets) + db.drop_table?(:ticket_types) + db.drop_table?(:events) + db.drop_table?(:users) + end + + def truncate_tables(db) + db[:tickets].delete + db[:ticket_types].delete + db[:events].delete + db[:users].delete + end + + def setup + super + @event_id = @db[:events].insert(name: 'Test Event') + @tt_id = @db[:ticket_types].insert(event_id: @event_id, name: 'General') + @user_id = @db[:users].insert(email: 'alice@example.com') + @ticket_type_names = { @tt_id => 'General' } + @user_emails = { @user_id => 'alice@example.com' } + end + + def export_tickets + IntercodeImport::Eventlite::Tables::Tickets.new( + @db, @event_id, @ticket_type_names, @user_emails + ).export! + end + + def test_basic_ticket_exported + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: @user_id) + tickets = export_tickets + assert_equal 1, tickets.size + assert_equal 'alice@example.com', tickets.first[:user_email] + assert_equal 'General', tickets.first[:ticket_type_name] + end + + def test_canceled_ticket_excluded + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: @user_id, canceled_at: Time.now) + assert_empty export_tickets + end + + def test_ticket_for_different_event_excluded + other_event_id = @db[:events].insert(name: 'Other Event') + other_tt_id = @db[:ticket_types].insert(event_id: other_event_id, name: 'VIP') + @db[:tickets].insert(ticket_type_id: other_tt_id, user_id: @user_id) + assert_empty export_tickets + end + + def test_unknown_user_id_skipped + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: 9999) + assert_empty export_tickets + end +end diff --git a/test/intercode_import/eventlite/users_db_test.rb b/test/intercode_import/eventlite/users_db_test.rb new file mode 100644 index 0000000..ce5b58b --- /dev/null +++ b/test/intercode_import/eventlite/users_db_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EventliteUsersDbTest < Minitest::Test + include EventliteDbTestHelper + + def self.setup_db(db) + db.create_table!(:users) do + primary_key :id + String :email + String :encrypted_password + TrueClass :admin, default: false + end + db.create_table!(:events) do + primary_key :id + String :name + String :slug + end + db.create_table!(:ticket_types) do + primary_key :id + Integer :event_id + String :name + end + db.create_table!(:tickets) do + primary_key :id + Integer :ticket_type_id + Integer :user_id + String :name + String :email + String :phone + end + end + + def self.teardown_db(db) + db.drop_table?(:tickets) + db.drop_table?(:ticket_types) + db.drop_table?(:events) + db.drop_table?(:users) + end + + def truncate_tables(db) + db[:tickets].delete + db[:ticket_types].delete + db[:events].delete + db[:users].delete + end + + def insert_user(overrides = {}) + defaults = { email: 'alice@example.com', encrypted_password: '$2a$12$fakehash', admin: false } + @db[:users].insert(defaults.merge(overrides)) + end + + def insert_event(overrides = {}) + @db[:events].insert({ name: 'Test Event', slug: 'test-event' }.merge(overrides)) + end + + def insert_ticket_type(overrides = {}) + @db[:ticket_types].insert({ name: 'General' }.merge(overrides)) + end + + def insert_ticket(overrides = {}) + @db[:tickets].insert(overrides) + end + + def export_users + IntercodeImport::Eventlite::Tables::Users.new(@db).export! + end + + def test_basic_user_exported + insert_user(email: 'bob@example.com', encrypted_password: '$2a$12$somehash') + users = export_users + assert_equal 1, users.size + assert_equal 'bob@example.com', users.first[:email] + assert_equal '$2a$12$somehash', users.first[:password_hash] + assert_equal 'bcrypt', users.first[:password_hash_type] + end + + def test_blank_email_skipped + insert_user(email: '') + assert_empty export_users + end + + def test_email_normalised_to_lowercase + insert_user(email: 'BOB@EXAMPLE.COM') + assert_equal 'bob@example.com', export_users.first[:email] + end + + def test_duplicate_email_exported_once + insert_user(id: 1, email: 'dupe@example.com') + insert_user(id: 2, email: 'dupe@example.com') + assert_equal 1, export_users.size + end + + def test_name_inferred_from_ticket + uid = insert_user(email: 'jane@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Jane Smith') + users = export_users + assert_equal 'Jane', users.first[:first_name] + assert_equal 'Smith', users.first[:last_name] + end + + def test_single_word_name_uses_empty_last_name + uid = insert_user(email: 'mono@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Madonna') + users = export_users + assert_equal 'Madonna', users.first[:first_name] + assert_equal '', users.first[:last_name] + end + + def test_multi_word_last_name_preserved + uid = insert_user(email: 'multi@example.com') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: uid, name: 'Jean-Luc Picard Smith') + users = export_users + assert_equal 'Jean-Luc', users.first[:first_name] + assert_equal 'Picard Smith', users.first[:last_name] + end + + def test_empty_first_and_last_name_when_no_ticket + insert_user(email: 'notix@example.com') + users = export_users + assert_equal '', users.first[:first_name] + assert_equal '', users.first[:last_name] + end + + def test_id_map_keyed_by_database_id + uid = insert_user(email: 'map@example.com') + table = IntercodeImport::Eventlite::Tables::Users.new(@db) + table.export! + assert_equal 'map@example.com', table.id_map[uid] + end +end diff --git a/test/support/eventlite_db_test_helper.rb b/test/support/eventlite_db_test_helper.rb new file mode 100644 index 0000000..4871bf8 --- /dev/null +++ b/test/support/eventlite_db_test_helper.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Mixin for Eventlite integration tests that need a live PostgreSQL connection. +# Set EVENTLITE_TEST_DATABASE_URL to a postgres:// URL to run these tests. +module EventliteDbTestHelper + UNSET = Object.new.freeze + private_constant :UNSET + + def self.included(base) + base.instance_variable_set(:@_db_conn, UNSET) + base.extend(ClassMethods) + end + + module ClassMethods + def db_connection + if @_db_conn.equal?(UNSET) + @_db_conn = + if ENV['EVENTLITE_TEST_DATABASE_URL'] + db = Sequel.connect(ENV['EVENTLITE_TEST_DATABASE_URL']) + setup_db(db) + Minitest.after_run { teardown_db(db); db.disconnect } + db + end + end + @_db_conn + end + + def setup_db(_db) = nil + def teardown_db(_db) = nil + end + + def setup + skip 'Set EVENTLITE_TEST_DATABASE_URL to run Eventlite DB integration tests' unless self.class.db_connection + @db = self.class.db_connection + truncate_tables(@db) + end + + def truncate_tables(_db) = nil +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8f2e79e..c141f20 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,7 +8,9 @@ require 'intercode_import' require 'intercode_import/intercode1' require 'intercode_import/illyan' +require 'intercode_import/eventlite' require_relative 'support/db_test_helper' +require_relative 'support/eventlite_db_test_helper' if ENV['CI'].present? Minitest::Reporters.use!( From fb549d812b2989f5f3fa522c4205b37f0434739b Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 10:46:38 -0700 Subject: [PATCH 02/13] Handle tickets with direct email/name instead of user_id FK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In some Eventlite databases (including production), tickets were created without a linked user account — the attendee name and email are stored directly on the ticket (tickets.email, tickets.name) and user_id is NULL. The previous exporter assumed all tickets had a user_id FK and silently skipped any that didn't, producing empty exports against real data. - Tables::Tickets: fall back to row[:email] when user_id is nil - Exporter#build_store_orders: same fallback - Exporter#build_profiles: same fallback - Tables::Users: emit synthetic user records for ticket emails that have no user_id and no matching user account; deduplicates against the users-table pass Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 14 +++++++-- .../eventlite/tables/tickets.rb | 7 ++++- .../eventlite/tables/users.rb | 13 ++++++++ .../eventlite/tickets_db_test.rb | 13 ++++++++ .../eventlite/users_db_test.rb | 31 +++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 689acd8..89cd566 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -134,7 +134,12 @@ def build_store_orders(event_id, ticket_type_name_by_id, user_email_by_id, ticke .all ticket_rows.filter_map do |row| - user_email = user_email_by_id[row[:user_id]] + user_email = + if row[:user_id] + user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end next unless user_email ticket_type_name = ticket_type_name_by_id[row[:ticket_type_id]] @@ -170,7 +175,12 @@ def build_profiles(event_id, user_email_by_id) seen = {} ticket_rows.each_with_object([]) do |row, profiles| - user_email = user_email_by_id[row[:user_id]] + user_email = + if row[:user_id] + user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end next unless user_email next if seen[user_email] diff --git a/lib/intercode_import/eventlite/tables/tickets.rb b/lib/intercode_import/eventlite/tables/tickets.rb index 93b615d..7e29c78 100644 --- a/lib/intercode_import/eventlite/tables/tickets.rb +++ b/lib/intercode_import/eventlite/tables/tickets.rb @@ -24,7 +24,12 @@ def export! results = [] dataset.each do |row| - user_email = @user_email_by_id[row[:user_id]] + user_email = + if row[:user_id] + @user_email_by_id[row[:user_id]] + else + row[:email].to_s.downcase.strip.presence + end next unless user_email ticket_type_name = @ticket_type_name_by_id[row[:ticket_type_id]] diff --git a/lib/intercode_import/eventlite/tables/users.rb b/lib/intercode_import/eventlite/tables/users.rb index a52f08b..2c777e4 100644 --- a/lib/intercode_import/eventlite/tables/users.rb +++ b/lib/intercode_import/eventlite/tables/users.rb @@ -37,6 +37,15 @@ def export! results << record end + ticket_only_rows.each do |row| + email = row[:email].to_s.downcase.strip + next if email.blank? || seen_emails[email] + + seen_emails[email] = true + first_name, last_name = parse_name(row[:name]) + results << { email: email, first_name: first_name, last_name: last_name } + end + results end @@ -49,6 +58,10 @@ def load_names_from_tickets end end + def ticket_only_rows + connection[:tickets].where(user_id: nil).exclude(email: nil).select(:email, :name).all + end + def parse_name(full_name) return ['', ''] if full_name.blank? parts = full_name.strip.split(' ', 2) diff --git a/test/intercode_import/eventlite/tickets_db_test.rb b/test/intercode_import/eventlite/tickets_db_test.rb index aa35d0e..233bd73 100644 --- a/test/intercode_import/eventlite/tickets_db_test.rb +++ b/test/intercode_import/eventlite/tickets_db_test.rb @@ -23,6 +23,7 @@ def self.setup_db(db) primary_key :id Integer :ticket_type_id Integer :user_id + String :email DateTime :canceled_at end end @@ -80,4 +81,16 @@ def test_unknown_user_id_skipped @db[:tickets].insert(ticket_type_id: @tt_id, user_id: 9999) assert_empty export_tickets end + + def test_ticket_with_direct_email_exported + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: nil, email: 'guest@example.com') + tickets = export_tickets + assert_equal 1, tickets.size + assert_equal 'guest@example.com', tickets.first[:user_email] + end + + def test_ticket_with_nil_user_id_and_nil_email_skipped + @db[:tickets].insert(ticket_type_id: @tt_id, user_id: nil, email: nil) + assert_empty export_tickets + end end diff --git a/test/intercode_import/eventlite/users_db_test.rb b/test/intercode_import/eventlite/users_db_test.rb index ce5b58b..24fd0b1 100644 --- a/test/intercode_import/eventlite/users_db_test.rb +++ b/test/intercode_import/eventlite/users_db_test.rb @@ -135,4 +135,35 @@ def test_id_map_keyed_by_database_id table.export! assert_equal 'map@example.com', table.id_map[uid] end + + def test_ticket_only_user_included + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'guest@example.com', name: 'Guest User') + users = export_users + assert_equal 1, users.size + u = users.first + assert_equal 'guest@example.com', u[:email] + assert_equal 'Guest', u[:first_name] + assert_equal 'User', u[:last_name] + refute u.key?(:password_hash) + end + + def test_ticket_only_user_not_duplicated_when_user_account_exists + insert_user(email: 'overlap@example.com', encrypted_password: '$2a$12$hash') + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'overlap@example.com', name: 'Overlap User') + users = export_users + assert_equal 1, users.size + assert users.first[:password_hash] + end + + def test_ticket_only_users_deduplicated_across_tickets + eid = insert_event + ttid = insert_ticket_type(event_id: eid) + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'dupe@example.com', name: 'Dupe One') + insert_ticket(ticket_type_id: ttid, user_id: nil, email: 'dupe@example.com', name: 'Dupe Two') + assert_equal 1, export_users.size + end end From 2c236c4dc3a098678deb0a4e5e77eab15b92a3f6 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 10:52:11 -0700 Subject: [PATCH 03/13] Add run and signups to Eventlite export Each exported event now includes a single Run (as expected for a single_event convention) and a confirmed signup for every ticket holder. The event also gets a registration_policy with one unlimited 'attendees' bucket so signups have a valid bucket_key to reference on import. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 29 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 89cd566..6bfb34c 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -89,9 +89,32 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) convention[:default_layout_content] = default_layout_content if default_layout_content - event_record = { id: event_id.to_s, title: event_name, event_category_name: 'Event', status: 'active' } + event_record = { + id: event_id.to_s, + title: event_name, + event_category_name: 'Event', + status: 'active', + registration_policy: { + buckets: [{ key: 'attendees', name: 'Attendees', slots_limited: false, anything: false }], + prevent_no_preference_signups: false + } + } event_record[:length_seconds] = event_row[:length_seconds] if event_row[:length_seconds] + run = { event_id: event_id.to_s, room_names: [] } + run[:starts_at] = event_row[:start_time].iso8601 if event_row[:start_time] + + signups = tickets.map do |ticket| + { + event_id: event_id.to_s, + run_index: 0, + user_email: ticket[:user_email], + state: 'confirmed', + bucket_key: 'attendees', + counted: true + } + end + { version: '1', source_system: 'eventlite', @@ -99,8 +122,8 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) users: event_users, user_con_profiles: user_con_profiles, events: [event_record], - runs: [], - signups: [], + runs: [run], + signups: signups, team_members: [], tickets: tickets, store_items: store_items, From 699934bf156f837245ec329f119abf4750cbd91d Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 10:58:05 -0700 Subject: [PATCH 04/13] Use ticket types as registration policy buckets for multi-type events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an event has multiple ticket types (e.g. male/female character splits), map each ticket type to a registration_policy bucket instead of the generic single 'attendees' bucket. Bucket slots come from ticket_type.number_available when set. For attendees who bought multiple ticket types (e.g. a character ticket and a cabin add-on), one signup is created per person using the ticket type with the most available slots — so a cabin ticket (1 slot) never wins over a character ticket (15-25 slots). Single-ticket-type events continue to use a single unlimited 'attendees' bucket as before. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 58 ++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 6bfb34c..fb74a41 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -94,26 +94,14 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) title: event_name, event_category_name: 'Event', status: 'active', - registration_policy: { - buckets: [{ key: 'attendees', name: 'Attendees', slots_limited: false, anything: false }], - prevent_no_preference_signups: false - } + registration_policy: build_registration_policy(ticket_type_rows) } event_record[:length_seconds] = event_row[:length_seconds] if event_row[:length_seconds] run = { event_id: event_id.to_s, room_names: [] } run[:starts_at] = event_row[:start_time].iso8601 if event_row[:start_time] - signups = tickets.map do |ticket| - { - event_id: event_id.to_s, - run_index: 0, - user_email: ticket[:user_email], - state: 'confirmed', - bucket_key: 'attendees', - counted: true - } - end + signups = build_signups(event_id.to_s, tickets, ticket_type_rows) { version: '1', @@ -135,6 +123,48 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) } end + def build_registration_policy(ticket_type_rows) + if ticket_type_rows.size <= 1 + return { + buckets: [{ key: 'attendees', name: 'Attendees', slots_limited: false, anything: false }], + prevent_no_preference_signups: false + } + end + + buckets = ticket_type_rows.map do |tt| + bucket = { key: bucket_key_for(tt[:name]), name: tt[:name], anything: false } + if tt[:number_available] + bucket.merge!(slots_limited: true, total_slots: tt[:number_available], + minimum_slots: 0, preferred_slots: tt[:number_available]) + else + bucket[:slots_limited] = false + end + bucket + end + { buckets: buckets, prevent_no_preference_signups: false } + end + + def build_signups(event_id_str, tickets, ticket_type_rows) + if ticket_type_rows.size <= 1 + return tickets.map do |ticket| + { event_id: event_id_str, run_index: 0, user_email: ticket[:user_email], + state: 'confirmed', bucket_key: 'attendees', counted: true } + end + end + + slots_by_name = ticket_type_rows.each_with_object({}) { |r, h| h[r[:name]] = r[:number_available] || 0 } + + tickets.group_by { |t| t[:user_email] }.map do |user_email, user_tickets| + best = user_tickets.max_by { |t| slots_by_name[t[:ticket_type_name]] || 0 } + { event_id: event_id_str, run_index: 0, user_email: user_email, + state: 'confirmed', bucket_key: bucket_key_for(best[:ticket_type_name]), counted: true } + end + end + + def bucket_key_for(name) + name.to_s.downcase.gsub(/\s+/, '_').gsub(/[^\w]/, '') + end + def build_store_items(ticket_type_rows) ticket_type_rows.map do |tt| item = { From cfe9cbd20c7a67a5394a6bf90d61e280ada4e14e Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:07:04 -0700 Subject: [PATCH 05/13] Fix ticket_mode: use ticket_per_event for single_event sites required_for_signup only applies to convention-mode sites. Single-event sites must use ticket_per_event instead. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index fb74a41..9561851 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -73,7 +73,7 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) domain: "#{event_slug}.#{@domain_suffix}", timezone_name: @timezone, site_mode: 'single_event', - ticket_mode: 'required_for_signup', + ticket_mode: 'ticket_per_event', ticket_types: ticket_types, event_categories: [default_event_category], rooms: [], From 82c2669e0117fbd96f83650c29126aa845f5e1a6 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:08:59 -0700 Subject: [PATCH 06/13] Load single_event CMS content set and fix event form title The single_event content set provides the 'Regular event form' that EventCategory requires. Without it the import fails with a validation error on event_form. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 9561851..8363985 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -106,6 +106,7 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) { version: '1', source_system: 'eventlite', + cms_content_set: 'single_event', convention: convention, users: event_users, user_con_profiles: user_con_profiles, @@ -272,7 +273,7 @@ def default_event_category name: 'Event', team_member_name: 'Organizer', scheduling_ui: 'single_run', - event_form_title: 'Event Proposal Form' + event_form_title: 'Regular event form' } end From 5dce85d553f99da821afbab6b971aef2a91e535d Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:11:04 -0700 Subject: [PATCH 07/13] Skip Eventlite pages that use Eventlite-only Liquid tags Pages using {% ticket_form %} (Eventlite's registration form tag) can't be imported into Intercode since that tag doesn't exist there. These pages serve Eventlite-specific functionality that Intercode handles natively, so skipping them is the right behaviour. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/tables/pages.rb | 17 +++++++++++------ .../intercode_import/eventlite/pages_db_test.rb | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/intercode_import/eventlite/tables/pages.rb b/lib/intercode_import/eventlite/tables/pages.rb index 36e2f3c..e5c67be 100644 --- a/lib/intercode_import/eventlite/tables/pages.rb +++ b/lib/intercode_import/eventlite/tables/pages.rb @@ -16,20 +16,25 @@ def dataset ) end + # Tags that exist in Eventlite but not in Intercode's Liquid environment. + # Pages using these tags are skipped rather than exported with broken content. + EVENTLITE_ONLY_TAGS = %w[ticket_form].freeze + def export! logger.info "Exporting Pages for event #{@event_id}" results = [] dataset.each do |row| - record = { - name: row[:name], - slug: row[:slug], - content: row[:content] || '' - } + content = row[:content] || '' + + if EVENTLITE_ONLY_TAGS.any? { |tag| content.include?("{%") && content.match?(/\{%-?\s*#{tag}[\s%}]/) } + logger.info "Skipping page '#{row[:slug]}' (contains Eventlite-only tag)" + next + end + record = { name: row[:name], slug: row[:slug], content: content } layout_name = @layout_name_by_id[row[:cms_layout_id]] record[:cms_layout_name] = layout_name if layout_name - results << record end diff --git a/test/intercode_import/eventlite/pages_db_test.rb b/test/intercode_import/eventlite/pages_db_test.rb index 797cd58..e394dc8 100644 --- a/test/intercode_import/eventlite/pages_db_test.rb +++ b/test/intercode_import/eventlite/pages_db_test.rb @@ -94,4 +94,18 @@ def test_nil_content_exported_as_empty_string parent_type: 'Event', parent_id: @event_id) assert_equal '', export_pages.first[:content] end + + def test_page_with_ticket_form_tag_skipped + @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '

Register

{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + assert_empty export_pages + end + + def test_page_with_normal_liquid_tag_included + @db[:pages].insert(name: 'Home', slug: 'home', + content: '{% if user %}Hello{% endif %}', + parent_type: 'Event', parent_id: @event_id) + assert_equal 1, export_pages.size + end end From 9a4d6f292b1c9f8f592d278e584e94257980e5ed Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:28:17 -0700 Subject: [PATCH 08/13] Filter nav links pointing to Eventlite-only pages Pages skipped due to Eventlite-specific tags (e.g. ticket_form) now also have their navigation links removed. Both Pages and NavigationItems share the tag-detection logic via a new eventlite_only_content? helper on the base Table class. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/table.rb | 8 +++++++ .../eventlite/tables/navigation_items.rb | 3 ++- .../eventlite/tables/pages.rb | 6 +---- .../eventlite/navigation_items_db_test.rb | 23 +++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/lib/intercode_import/eventlite/table.rb b/lib/intercode_import/eventlite/table.rb index 575e422..3709a9d 100644 --- a/lib/intercode_import/eventlite/table.rb +++ b/lib/intercode_import/eventlite/table.rb @@ -3,6 +3,9 @@ module IntercodeImport module Eventlite class Table < IntercodeImport::Table + # Liquid tags that exist in Eventlite but not in Intercode's environment. + EVENTLITE_ONLY_TAGS = %w[ticket_form].freeze + def table_name self.class.name.demodulize.underscore.to_sym end @@ -14,6 +17,11 @@ def row_id(row) = row[:id] def logger IntercodeImport::Eventlite.logger end + + def eventlite_only_content?(content) + return false unless content&.include?('{%') + EVENTLITE_ONLY_TAGS.any? { |tag| content.match?(/\{%-?\s*#{tag}[\s%}]/) } + end end end end diff --git a/lib/intercode_import/eventlite/tables/navigation_items.rb b/lib/intercode_import/eventlite/tables/navigation_items.rb index e4d4eb0..bf06a7d 100644 --- a/lib/intercode_import/eventlite/tables/navigation_items.rb +++ b/lib/intercode_import/eventlite/tables/navigation_items.rb @@ -18,7 +18,8 @@ def dataset def export! logger.info "Exporting NavigationItems for event #{@event_id}" - page_name_by_id = connection[:pages].select(:id, :name).all.each_with_object({}) do |row, h| + page_name_by_id = connection[:pages].select(:id, :name, :content).all.each_with_object({}) do |row, h| + next if eventlite_only_content?(row[:content]) h[row[:id]] = row[:name] end diff --git a/lib/intercode_import/eventlite/tables/pages.rb b/lib/intercode_import/eventlite/tables/pages.rb index e5c67be..eb1e0e4 100644 --- a/lib/intercode_import/eventlite/tables/pages.rb +++ b/lib/intercode_import/eventlite/tables/pages.rb @@ -16,10 +16,6 @@ def dataset ) end - # Tags that exist in Eventlite but not in Intercode's Liquid environment. - # Pages using these tags are skipped rather than exported with broken content. - EVENTLITE_ONLY_TAGS = %w[ticket_form].freeze - def export! logger.info "Exporting Pages for event #{@event_id}" results = [] @@ -27,7 +23,7 @@ def export! dataset.each do |row| content = row[:content] || '' - if EVENTLITE_ONLY_TAGS.any? { |tag| content.include?("{%") && content.match?(/\{%-?\s*#{tag}[\s%}]/) } + if eventlite_only_content?(content) logger.info "Skipping page '#{row[:slug]}' (contains Eventlite-only tag)" next end diff --git a/test/intercode_import/eventlite/navigation_items_db_test.rb b/test/intercode_import/eventlite/navigation_items_db_test.rb index c45a00d..db4422f 100644 --- a/test/intercode_import/eventlite/navigation_items_db_test.rb +++ b/test/intercode_import/eventlite/navigation_items_db_test.rb @@ -14,6 +14,7 @@ def self.setup_db(db) primary_key :id String :name String :slug + String :content Integer :parent_id String :parent_type end @@ -95,4 +96,26 @@ def test_item_with_missing_page_skipped parent_type: 'Event', parent_id: @event_id) assert_empty export_nav end + + def test_nav_link_to_eventlite_only_page_skipped + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '

Register

{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end + + def test_nav_link_to_normal_page_kept_alongside_skipped_page + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 2, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + parent_type: 'Event', parent_id: @event_id) + links = export_nav.first[:links] + assert_equal 1, links.size + assert_equal 'Home', links.first[:title] + end end From 9c6fe3333d50a106b329cd50bc04db4be7125dbe Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:36:58 -0700 Subject: [PATCH 09/13] Slugify ticket type names to satisfy Intercode's \A\w+\z validation TicketType.name must be a word-character identifier. Eventlite names like "Female character" and "2-occupancy cabin" fail that validation. - slug_for() converts names to lowercase underscored identifiers - Ticket types export with name: slug and description: original_name - ticket.ticket_type_name, store_item.provides_ticket_type_name, and signup.bucket_key all use the slug - store_item.name and store_order.store_item_name keep the human- readable original name since they're display text, not identifiers Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 8363985..600c5c5 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -45,16 +45,18 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) ticket_type_rows = @connection[:ticket_types].where(event_id: event_id).order(:id).all ticket_type_name_by_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = r[:name] } + ticket_type_slug_by_id = ticket_type_rows.each_with_object({}) { |r, h| h[r[:id]] = slug_for(r[:name]) } ticket_types = ticket_type_rows.map do |tt| { - name: tt[:name], + name: slug_for(tt[:name]), + description: tt[:name], allows_event_signups: true, counts_towards_convention_maximum: true } end - tickets_table = Tables::Tickets.new(@connection, event_id, ticket_type_name_by_id, user_email_by_id) + tickets_table = Tables::Tickets.new(@connection, event_id, ticket_type_slug_by_id, user_email_by_id) tickets = tickets_table.export! ticket_emails = Set.new(tickets.map { |t| t[:user_email] }) @@ -133,7 +135,7 @@ def build_registration_policy(ticket_type_rows) end buckets = ticket_type_rows.map do |tt| - bucket = { key: bucket_key_for(tt[:name]), name: tt[:name], anything: false } + bucket = { key: slug_for(tt[:name]), name: tt[:name], anything: false } if tt[:number_available] bucket.merge!(slots_limited: true, total_slots: tt[:number_available], minimum_slots: 0, preferred_slots: tt[:number_available]) @@ -153,17 +155,18 @@ def build_signups(event_id_str, tickets, ticket_type_rows) end end - slots_by_name = ticket_type_rows.each_with_object({}) { |r, h| h[r[:name]] = r[:number_available] || 0 } + # tickets already carry slug-form ticket_type_name; key by slug for slot lookup + slots_by_slug = ticket_type_rows.each_with_object({}) { |r, h| h[slug_for(r[:name])] = r[:number_available] || 0 } tickets.group_by { |t| t[:user_email] }.map do |user_email, user_tickets| - best = user_tickets.max_by { |t| slots_by_name[t[:ticket_type_name]] || 0 } + best = user_tickets.max_by { |t| slots_by_slug[t[:ticket_type_name]] || 0 } { event_id: event_id_str, run_index: 0, user_email: user_email, - state: 'confirmed', bucket_key: bucket_key_for(best[:ticket_type_name]), counted: true } + state: 'confirmed', bucket_key: best[:ticket_type_name], counted: true } end end - def bucket_key_for(name) - name.to_s.downcase.gsub(/\s+/, '_').gsub(/[^\w]/, '') + def slug_for(name) + name.to_s.downcase.gsub(/[-\s]+/, '_').gsub(/[^\w]/, '') end def build_store_items(ticket_type_rows) @@ -171,7 +174,7 @@ def build_store_items(ticket_type_rows) item = { name: tt[:name], available: true, - provides_ticket_type_name: tt[:name] + provides_ticket_type_name: slug_for(tt[:name]) } item[:price] = { fractional: tt[:price_cents], currency_code: 'USD' } if tt[:price_cents] item From a2cadf5317ee64115331e67cf8500b459b46c8a4 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:49:34 -0700 Subject: [PATCH 10/13] Fix blank user names and duplicate tickets per user in Eventlite export Users with no name data now fall back to their email prefix as first_name, satisfying Intercode's Name presence validation. For multi-ticket-type events, tickets are deduplicated to one per user (best by available slots), preventing the unique user_con_profile constraint violation. Store items no longer carry provides_ticket_type_name since tickets are handled explicitly via the tickets array; losing ticket purchases remain as store orders only. Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 16 ++++++++++------ lib/intercode_import/eventlite/tables/users.rb | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index 600c5c5..abfe721 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -57,7 +57,7 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) end tickets_table = Tables::Tickets.new(@connection, event_id, ticket_type_slug_by_id, user_email_by_id) - tickets = tickets_table.export! + tickets = deduplicate_tickets(tickets_table.export!, ticket_type_rows) ticket_emails = Set.new(tickets.map { |t| t[:user_email] }) event_users = all_users.select { |u| ticket_emails.include?(u[:email]) } @@ -169,13 +169,17 @@ def slug_for(name) name.to_s.downcase.gsub(/[-\s]+/, '_').gsub(/[^\w]/, '') end + def deduplicate_tickets(tickets, ticket_type_rows) + return tickets if ticket_type_rows.size <= 1 + slots_by_slug = ticket_type_rows.each_with_object({}) { |r, h| h[slug_for(r[:name])] = r[:number_available] || 0 } + tickets.group_by { |t| t[:user_email] }.map do |_email, user_tickets| + user_tickets.max_by { |t| slots_by_slug[t[:ticket_type_name]] || 0 } + end + end + def build_store_items(ticket_type_rows) ticket_type_rows.map do |tt| - item = { - name: tt[:name], - available: true, - provides_ticket_type_name: slug_for(tt[:name]) - } + item = { name: tt[:name], available: true } item[:price] = { fractional: tt[:price_cents], currency_code: 'USD' } if tt[:price_cents] item end diff --git a/lib/intercode_import/eventlite/tables/users.rb b/lib/intercode_import/eventlite/tables/users.rb index 2c777e4..7cbdf9b 100644 --- a/lib/intercode_import/eventlite/tables/users.rb +++ b/lib/intercode_import/eventlite/tables/users.rb @@ -21,6 +21,7 @@ def export! seen_emails[email] = true first_name, last_name = parse_name(@names_by_user_id[row[:id]]) + first_name = email_prefix(email) if first_name.blank? && last_name.blank? record = { email: email, @@ -43,6 +44,7 @@ def export! seen_emails[email] = true first_name, last_name = parse_name(row[:name]) + first_name = email_prefix(email) if first_name.blank? && last_name.blank? results << { email: email, first_name: first_name, last_name: last_name } end @@ -67,6 +69,10 @@ def parse_name(full_name) parts = full_name.strip.split(' ', 2) [parts[0] || '', parts[1] || ''] end + + def email_prefix(email) + email.to_s.split('@').first.to_s + end end end end From 383566392fdcb66dc71aeba0dac8f6617e47ec8f Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 11:59:25 -0700 Subject: [PATCH 11/13] Fix CMS wiring: default layout, root page, nav hierarchy, convention name - Export default_layout_name and root_page_slug instead of raw layout content so the import service can wire up convention.default_layout and convention.root_page after CMS content is imported - Use event name directly as the convention name (was falling back to site_settings.site_title which gives the org name, not the event name) - NavigationItems: use navigation_section_id for proper section/link grouping instead of flattening everything into one "Navigation" section; standalone top-level links each become their own single-link section Co-Authored-By: Claude Sonnet 4.6 --- lib/intercode_import/eventlite/exporter.rb | 15 ++++--- .../eventlite/tables/navigation_items.rb | 25 ++++++++--- .../eventlite/navigation_items_db_test.rb | 44 +++++++++++++++---- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/lib/intercode_import/eventlite/exporter.rb b/lib/intercode_import/eventlite/exporter.rb index abfe721..f166d18 100644 --- a/lib/intercode_import/eventlite/exporter.rb +++ b/lib/intercode_import/eventlite/exporter.rb @@ -37,10 +37,12 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) cms_layouts = layouts_table.export! layout_name_by_id = layouts_table.id_map - default_layout_content = nil - if event_row[:default_cms_layout_id] - default_row = @connection[:cms_layouts].where(id: event_row[:default_cms_layout_id]).first - default_layout_content = default_row&.fetch(:content, nil) + default_layout_name = layout_name_by_id[event_row[:default_cms_layout_id]] + + root_page_slug = nil + if event_row[:root_page_id] + root_page_row = @connection[:pages].where(id: event_row[:root_page_id]).first + root_page_slug = root_page_row&.fetch(:slug, nil) end ticket_type_rows = @connection[:ticket_types].where(event_id: event_id).order(:id).all @@ -71,7 +73,7 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) cms_nav_items = Tables::NavigationItems.new(@connection, event_id).export! convention = { - name: site_settings&.fetch(:site_title, nil).presence || event_name, + name: event_name, domain: "#{event_slug}.#{@domain_suffix}", timezone_name: @timezone, site_mode: 'single_event', @@ -89,7 +91,8 @@ def export_event(event_row, site_settings, all_users, user_email_by_id) end end - convention[:default_layout_content] = default_layout_content if default_layout_content + convention[:default_layout_name] = default_layout_name if default_layout_name + convention[:root_page_slug] = root_page_slug if root_page_slug event_record = { id: event_id.to_s, diff --git a/lib/intercode_import/eventlite/tables/navigation_items.rb b/lib/intercode_import/eventlite/tables/navigation_items.rb index bf06a7d..bf35a50 100644 --- a/lib/intercode_import/eventlite/tables/navigation_items.rb +++ b/lib/intercode_import/eventlite/tables/navigation_items.rb @@ -23,13 +23,28 @@ def export! h[row[:id]] = row[:name] end - links = dataset.filter_map do |row| - page_name = page_name_by_id[row[:page_id]] - next unless page_name - { title: row[:title], page_name: page_name } + all_items = dataset.all + top_level = all_items.select { |r| r[:navigation_section_id].nil? } + children_by_section = all_items.group_by { |r| r[:navigation_section_id] } + children_by_section.delete(nil) + + sections = [] + top_level.each do |item| + if item[:page_id].nil? + links = (children_by_section[item[:id]] || []).filter_map do |child| + page_name = page_name_by_id[child[:page_id]] + next unless page_name + { title: child[:title], page_name: page_name } + end + sections << { title: item[:title], links: links } unless links.empty? + else + page_name = page_name_by_id[item[:page_id]] + next unless page_name + sections << { title: item[:title], links: [{ title: item[:title], page_name: page_name }] } + end end - links.empty? ? [] : [{ title: 'Navigation', links: links }] + sections end end end diff --git a/test/intercode_import/eventlite/navigation_items_db_test.rb b/test/intercode_import/eventlite/navigation_items_db_test.rb index db4422f..f09b467 100644 --- a/test/intercode_import/eventlite/navigation_items_db_test.rb +++ b/test/intercode_import/eventlite/navigation_items_db_test.rb @@ -22,6 +22,7 @@ def self.setup_db(db) primary_key :id String :title Integer :page_id + Integer :navigation_section_id Integer :parent_id String :parent_type Integer :position @@ -55,31 +56,55 @@ def test_returns_empty_when_no_items assert_empty export_nav end - def test_single_item_produces_one_section_with_one_link + def test_standalone_link_produces_own_section @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, parent_type: 'Event', parent_id: @event_id) sections = export_nav assert_equal 1, sections.size - assert_equal 'Navigation', sections.first[:title] + assert_equal 'Home', sections.first[:title] assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] end - def test_items_ordered_by_position + def test_section_header_with_children + section_id = @db[:navigation_items].insert(title: 'Info', page_id: nil, position: 1, + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, + navigation_section_id: section_id, + parent_type: 'Event', parent_id: @event_id) + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Info', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] + end + + def test_section_header_with_no_valid_children_excluded + section_id = @db[:navigation_items].insert(title: 'Info', page_id: nil, position: 1, + parent_type: 'Event', parent_id: @event_id) + reg_page_id = @db[:pages].insert(name: 'Registration', slug: 'registration', + content: '{% ticket_form %}', + parent_type: 'Event', parent_id: @event_id) + @db[:navigation_items].insert(title: 'Register', page_id: reg_page_id, position: 1, + navigation_section_id: section_id, + parent_type: 'Event', parent_id: @event_id) + assert_empty export_nav + end + + def test_standalone_links_ordered_by_position page2_id = @db[:pages].insert(name: 'About', slug: 'about', parent_type: 'Event', parent_id: @event_id) @db[:navigation_items].insert(title: 'About', page_id: page2_id, position: 2, parent_type: 'Event', parent_id: @event_id) @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, parent_type: 'Event', parent_id: @event_id) - links = export_nav.first[:links] - assert_equal ['Home', 'About'], links.map { |l| l[:title] } + sections = export_nav + assert_equal ['Home', 'About'], sections.map { |s| s[:title] } end def test_global_nav_items_included global_page_id = @db[:pages].insert(name: 'Info', slug: 'info', parent_type: nil, parent_id: nil) @db[:navigation_items].insert(title: 'Info', page_id: global_page_id, position: 1, parent_type: nil, parent_id: nil) - assert_equal 1, export_nav.first[:links].size + assert_equal 1, export_nav.size end def test_other_event_items_excluded @@ -114,8 +139,9 @@ def test_nav_link_to_normal_page_kept_alongside_skipped_page parent_type: 'Event', parent_id: @event_id) @db[:navigation_items].insert(title: 'Home', page_id: @page_id, position: 1, parent_type: 'Event', parent_id: @event_id) - links = export_nav.first[:links] - assert_equal 1, links.size - assert_equal 'Home', links.first[:title] + sections = export_nav + assert_equal 1, sections.size + assert_equal 'Home', sections.first[:title] + assert_equal [{ title: 'Home', page_name: 'Home' }], sections.first[:links] end end From 8d080694bda37c95f1a49491df9eb1e623f2c6fc Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 13:26:50 -0700 Subject: [PATCH 12/13] Update test to expect email prefix fallback for nameless users Reflects the fix that uses the email local part as first_name when both names are blank, so Intercode's name presence validation doesn't fail. Co-Authored-By: Claude Sonnet 4.6 --- test/intercode_import/eventlite/users_db_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/intercode_import/eventlite/users_db_test.rb b/test/intercode_import/eventlite/users_db_test.rb index 24fd0b1..853a110 100644 --- a/test/intercode_import/eventlite/users_db_test.rb +++ b/test/intercode_import/eventlite/users_db_test.rb @@ -122,10 +122,10 @@ def test_multi_word_last_name_preserved assert_equal 'Picard Smith', users.first[:last_name] end - def test_empty_first_and_last_name_when_no_ticket + def test_email_prefix_used_as_first_name_when_no_ticket insert_user(email: 'notix@example.com') users = export_users - assert_equal '', users.first[:first_name] + assert_equal 'notix', users.first[:first_name] assert_equal '', users.first[:last_name] end From c381d82714b4d3a950d446c2830f87e68f54325f Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Mon, 22 Jun 2026 13:27:51 -0700 Subject: [PATCH 13/13] Add Eventlite exporter documentation to README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c9d6238..847262a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Ruby toolkit for exporting legacy convention management system data into the [Intercode](https://github.com/neinteractiveliterature/intercode) convention import format. -Three source systems are supported: **Intercode 1** (PHP/MySQL), **ProCon** (MySQL), and **Illyan** (the user authentication database used alongside ProCon). +Four source systems are supported: **Intercode 1** (PHP/MySQL), **ProCon** (MySQL), **Illyan** (the user authentication database used alongside ProCon), and **Eventlite** (Rails/PostgreSQL). ## Output format @@ -66,6 +66,27 @@ ORGANIZATION_NAME='Arisia' \ | `ORGANIZATION_NAME` | Yes | Organization name written into each export file. | | `OUTPUT_FILE` | No | Output path. Only used when exactly one convention matches; otherwise files are named `convention-export-.json`. Pass `-` to print to stdout. | +### Eventlite + +Exports one JSON file per event from an Eventlite PostgreSQL database. Each event becomes a separate single-event Intercode convention. + +```sh +EVENTLITE_DB_URL=postgres://user:pass@host/eventlite_db \ +DOMAIN_SUFFIX=example.com \ +TIMEZONE=America/New_York \ + bundle exec rake export:eventlite +``` + +| Variable | Required | Description | +|---|---|---| +| `EVENTLITE_DB_URL` | Yes | Sequel-compatible connection URL for the Eventlite PostgreSQL database. | +| `DOMAIN_SUFFIX` | No | Domain suffix appended to each event's slug to form the convention domain (default: `example.com`). | +| `TIMEZONE` | No | IANA timezone name applied to all conventions (default: `UTC`). | +| `FILE_BASE_URL` | No | Base URL for CMS file attachments stored in S3 (e.g. `https://my-bucket.s3.amazonaws.com/`). | +| `OUTPUT_FILE` | No | Output path. Only used when exactly one event is found; otherwise files are named `convention-export-.json`. Pass `-` to print to stdout. | + +Each Eventlite event is exported as a `single_event` Intercode convention using the `ticket_per_event` ticket mode. Ticket types become Intercode ticket types and store items; users who purchased multiple ticket types get one ticket (the type with the most available slots wins) and additional store order entries for the rest. Pages containing Eventlite-specific Liquid tags (e.g. `{% ticket_form %}`) are omitted, as is any navigation pointing to those pages. + ### Illyan (standalone user export) Exports a set of users from the Illyan database by email address. Useful for migrating user accounts without a full convention export.