diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6d13ef..70319b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: services: postgres: - image: postgres + image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -67,7 +67,7 @@ jobs: options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 redis: - image: redis + image: redis:7 ports: - 6379:6379 options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -99,3 +99,13 @@ jobs: name: screenshots path: ${{ github.workspace }}/tmp/screenshots if-no-files-found: ignore + + deploy: + runs-on: ubuntu-latest + needs: [scan_ruby, scan_js, lint, test] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + - uses: hatchboxio/github-hatchbox-deploy-action@v2 + with: + deploy_key: ${{ secrets.HATCHBOX_DEPLOY_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index ec9b51e..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Deploy to Hatchbox - -on: - push: - branches: - - main - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: hatchboxio/github-hatchbox-deploy-action@v2 - with: - deploy_key: ${{ secrets.HATCHBOX_DEPLOY_KEY }} diff --git a/app/controllers/admin/blog_posts_controller.rb b/app/controllers/admin/blog_posts_controller.rb index e567373..b50c27a 100644 --- a/app/controllers/admin/blog_posts_controller.rb +++ b/app/controllers/admin/blog_posts_controller.rb @@ -1,7 +1,7 @@ module Admin class BlogPostsController < BaseController def index - @blog_posts = BlogPost.all.sort_by { |p| p.published_at || p.created_at }.reverse + @blog_posts = BlogPost.order(Arel.sql("COALESCE(published_at, created_at) DESC")) @total_count = BlogPost.count @r2_configured = R2ImageService.configured? end diff --git a/app/controllers/blog_controller.rb b/app/controllers/blog_controller.rb index 9ef17a2..3d8eea1 100644 --- a/app/controllers/blog_controller.rb +++ b/app/controllers/blog_controller.rb @@ -1,6 +1,6 @@ class BlogController < ApplicationController def index - @blog_posts = BlogPost.all.sort_by(&:created_at).reverse + @blog_posts = BlogPost.order(created_at: :desc) end def show diff --git a/app/controllers/stems_controller.rb b/app/controllers/stems_controller.rb index 20713b3..065debc 100644 --- a/app/controllers/stems_controller.rb +++ b/app/controllers/stems_controller.rb @@ -117,19 +117,25 @@ def download_all original = result_data["original_filename"] || "audio.mp3" basename = File.basename(original, ".*") + # Stream zip to avoid loading all decoded stems into memory at once require "zip" - zip_data = Zip::OutputStream.write_buffer do |zip| - result_data["stems"].each do |stem_name, stem_base64| - zip.put_next_entry("#{basename}_#{stem_name}.flac") - zip.write(Base64.decode64(stem_base64)) + temp_zip = Tempfile.new([ "stems", ".zip" ]) + begin + Zip::OutputStream.open(temp_zip.path) do |zip| + result_data["stems"].each do |stem_name, stem_base64| + zip.put_next_entry("#{basename}_#{stem_name}.flac") + zip.write(Base64.decode64(stem_base64)) + end end - end - send_data zip_data.string, - type: "application/zip", - disposition: "attachment", - filename: "#{basename}_stems.zip" + send_file temp_zip.path, + type: "application/zip", + disposition: "attachment", + filename: "#{basename}_stems.zip" + ensure + temp_zip.close + end else render json: { error: "Result not found or expired" }, status: :not_found end diff --git a/app/controllers/tts_batches_controller.rb b/app/controllers/tts_batches_controller.rb index 45f037d..8a38907 100644 --- a/app/controllers/tts_batches_controller.rb +++ b/app/controllers/tts_batches_controller.rb @@ -102,23 +102,31 @@ def download_all return end - # Create a zip file in memory + # Stream zip to avoid loading all audio data into memory at once require "zip" - zip_data = Zip::OutputStream.write_buffer do |zip| - completed_items.each do |item| - filename = "#{item.position.to_s.rjust(3, '0')}-#{item.voice_preset || 'default'}.wav" - zip.put_next_entry(filename) - zip.write(Base64.decode64(item.audio_data)) + zip_filename = "batch-#{batch.id}-#{batch.name.parameterize}.zip" + response.headers["Content-Type"] = "application/zip" + response.headers["Content-Disposition"] = "attachment; filename=\"#{zip_filename}\"" + + # Use a streaming approach: write zip to a temp file, then stream it + temp_zip = Tempfile.new([ "tts_batch", ".zip" ]) + begin + Zip::OutputStream.open(temp_zip.path) do |zip| + completed_items.find_each do |item| + filename = "#{item.position.to_s.rjust(3, '0')}-#{item.voice_preset || 'default'}.wav" + zip.put_next_entry(filename) + zip.write(Base64.decode64(item.audio_data)) + end end - end - - zip_data.rewind - send_data zip_data.read, - type: "application/zip", - disposition: "attachment", - filename: "batch-#{batch.id}-#{batch.name.parameterize}.zip" + send_file temp_zip.path, + type: "application/zip", + disposition: "attachment", + filename: zip_filename + ensure + temp_zip.close + end end private diff --git a/app/jobs/gpu_job.rb b/app/jobs/gpu_job.rb index 0b3ecbe..670ec9e 100644 --- a/app/jobs/gpu_job.rb +++ b/app/jobs/gpu_job.rb @@ -4,6 +4,15 @@ class GpuJob < ApplicationJob # Always use the GPU queue for these jobs queue_as :gpu + # Shared Redis connection — avoids creating a new connection per call + def self.redis + @redis ||= Redis.new(url: Rails.application.config_for(:redis)["url"]) + end + + def redis + self.class.redis + end + # Track which service this job is for (override in subclasses) class_attribute :gpu_service_name, default: nil @@ -18,7 +27,6 @@ class GpuJob < ApplicationJob # Ensure only one GPU job runs at a time by using Redis lock around_perform do |job, block| - redis = Redis.new(url: Rails.application.config_for(:redis)["url"]) lock_key = "gpu_lock" lock_timeout = job.class.gpu_lock_timeout @@ -31,8 +39,8 @@ class GpuJob < ApplicationJob block.call ensure # Only release if we still own the lock - if redis.get(lock_key) == job.job_id - redis.del(lock_key) + if job.redis.get(lock_key) == job.job_id + job.redis.del(lock_key) Rails.logger.info "GPU job #{job.class.name} (#{job.job_id}) released GPU lock" end end @@ -45,13 +53,11 @@ class GpuJob < ApplicationJob # Helper method to store results in Redis def store_result(key, value, ttl: 3600) - redis = Redis.new(url: Rails.application.config_for(:redis)["url"]) redis.setex(key, ttl, value.to_json) end # Helper method to get result from Redis def self.get_result(key) - redis = Redis.new(url: Rails.application.config_for(:redis)["url"]) result = redis.get(key) JSON.parse(result) if result rescue JSON::ParserError diff --git a/app/models/client.rb b/app/models/client.rb index bfacd2f..ab4b903 100644 --- a/app/models/client.rb +++ b/app/models/client.rb @@ -15,6 +15,8 @@ def full_address parts.join("\n") end + # NOTE: These instance methods trigger individual queries per client. + # Do NOT call them in list views — use Client.with_invoice_totals instead. def total_invoiced_cents invoices.sum(:total_cents) end @@ -26,4 +28,14 @@ def total_paid_cents def outstanding_cents invoices.where(status: %w[sent viewed overdue]).sum(:total_cents) end + + # Efficient scope for list views — calculates all totals in a single query + def self.with_invoice_totals + select( + "clients.*", + "COALESCE(SUM(invoices.total_cents), 0) AS computed_total_invoiced_cents", + "COALESCE(SUM(CASE WHEN invoices.status = 'paid' THEN invoices.total_cents ELSE 0 END), 0) AS computed_total_paid_cents", + "COALESCE(SUM(CASE WHEN invoices.status IN ('sent', 'viewed', 'overdue') THEN invoices.total_cents ELSE 0 END), 0) AS computed_outstanding_cents" + ).left_joins(:invoices).group("clients.id") + end end diff --git a/app/models/invoice.rb b/app/models/invoice.rb index e559b6e..7854240 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -44,13 +44,20 @@ def set_invoice_number return if invoice_number.present? year = (issue_date || Date.current).year - last_invoice = Invoice.where("invoice_number LIKE ?", "INV-#{year}-%").order(:invoice_number).last - next_num = if last_invoice - last_invoice.invoice_number.split("-").last.to_i + 1 - else - 1 + + # Use advisory lock to prevent race conditions when generating invoice numbers + ActiveRecord::Base.connection.execute("SELECT pg_advisory_lock(#{year})") + begin + last_invoice = Invoice.where("invoice_number LIKE ?", "INV-#{year}-%").order(:invoice_number).last + next_num = if last_invoice + last_invoice.invoice_number.split("-").last.to_i + 1 + else + 1 + end + self.invoice_number = "INV-#{year}-#{next_num.to_s.rjust(4, '0')}" + ensure + ActiveRecord::Base.connection.execute("SELECT pg_advisory_unlock(#{year})") end - self.invoice_number = "INV-#{year}-#{next_num.to_s.rjust(4, '0')}" end def calculate_totals diff --git a/app/models/story.rb b/app/models/story.rb index e55ae4c..c7aa343 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -26,8 +26,9 @@ def total_pages(per_page: PARAGRAPHS_PER_PAGE) [ (story_paragraphs.count.to_f / per_page).ceil, 1 ].max end + # Uses counter_cache column when available, falls back to COUNT query def paragraph_count - story_paragraphs.count + story_paragraphs_count || story_paragraphs.count end def started? diff --git a/app/models/story_paragraph.rb b/app/models/story_paragraph.rb index ed26722..68caa4d 100644 --- a/app/models/story_paragraph.rb +++ b/app/models/story_paragraph.rb @@ -1,5 +1,5 @@ class StoryParagraph < ApplicationRecord - belongs_to :story + belongs_to :story, counter_cache: true validates :content, presence: true validates :paragraph_number, presence: true, uniqueness: { scope: :story_id } diff --git a/db/migrate/20260317000001_add_missing_indexes.rb b/db/migrate/20260317000001_add_missing_indexes.rb new file mode 100644 index 0000000..7140b28 --- /dev/null +++ b/db/migrate/20260317000001_add_missing_indexes.rb @@ -0,0 +1,8 @@ +class AddMissingIndexes < ActiveRecord::Migration[8.0] + def change + add_index :images, :published + add_index :images, :position + add_index :review_sections, :status + add_index :reviews, :pr_url + end +end diff --git a/db/migrate/20260317000002_add_story_paragraphs_count_to_stories.rb b/db/migrate/20260317000002_add_story_paragraphs_count_to_stories.rb new file mode 100644 index 0000000..bec564f --- /dev/null +++ b/db/migrate/20260317000002_add_story_paragraphs_count_to_stories.rb @@ -0,0 +1,15 @@ +class AddStoryParagraphsCountToStories < ActiveRecord::Migration[8.0] + def up + add_column :stories, :story_paragraphs_count, :integer, default: 0, null: false + + # Backfill existing counts + Story.reset_column_information + Story.find_each do |story| + Story.update_counters(story.id, story_paragraphs_count: story.story_paragraphs.count) + end + end + + def down + remove_column :stories, :story_paragraphs_count + end +end diff --git a/db/schema.rb b/db/schema.rb index 9c03728..2b541f8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_11_120000) do +ActiveRecord::Schema[8.0].define(version: 2026_03_17_000002) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -69,20 +69,6 @@ t.index ["slug"], name: "index_blog_posts_on_slug", unique: true end - create_table "clients", force: :cascade do |t| - t.string "name", null: false - t.string "email", null: false - t.string "address_line1", null: false - t.string "address_line2" - t.string "city", null: false - t.string "state", null: false - t.string "zip", null: false - t.text "notes" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["email"], name: "index_clients_on_email" - end - create_table "bookings", force: :cascade do |t| t.bigint "availability_id", null: false t.date "booked_date", null: false @@ -104,6 +90,20 @@ t.index ["status"], name: "index_bookings_on_status" end + create_table "clients", force: :cascade do |t| + t.string "name", null: false + t.string "email", null: false + t.string "address_line1", null: false + t.string "address_line2" + t.string "city", null: false + t.string "state", null: false + t.string "zip", null: false + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_clients_on_email" + end + create_table "daw_patterns", force: :cascade do |t| t.string "name", null: false t.text "description" @@ -131,12 +131,23 @@ t.index ["service_name"], name: "index_gpu_health_statuses_on_service_name", unique: true end + create_table "images", force: :cascade do |t| + t.string "title" + t.text "description" + t.integer "position" + t.boolean "published" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["position"], name: "index_images_on_position" + t.index ["published"], name: "index_images_on_published" + end + create_table "invoice_line_items", force: :cascade do |t| t.bigint "invoice_id", null: false t.string "description", null: false - t.decimal "quantity", precision: 10, scale: 2, null: false, default: "1.0" - t.integer "unit_price_cents", null: false, default: 0 - t.integer "total_cents", null: false, default: 0 + t.decimal "quantity", precision: 10, scale: 2, default: "1.0", null: false + t.integer "unit_price_cents", default: 0, null: false + t.integer "total_cents", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["invoice_id"], name: "index_invoice_line_items_on_invoice_id" @@ -147,12 +158,12 @@ t.string "invoice_number", null: false t.date "issue_date", null: false t.date "due_date", null: false - t.string "status", null: false, default: "draft" + t.string "status", default: "draft", null: false t.text "notes" - t.integer "subtotal_cents", null: false, default: 0 - t.decimal "tax_rate", precision: 5, scale: 2, null: false, default: "0.0" - t.integer "tax_cents", null: false, default: 0 - t.integer "total_cents", null: false, default: 0 + t.integer "subtotal_cents", default: 0, null: false + t.decimal "tax_rate", precision: 5, scale: 2, default: "0.0", null: false + t.integer "tax_cents", default: 0, null: false + t.integer "total_cents", default: 0, null: false t.datetime "paid_at" t.datetime "sent_at" t.datetime "created_at", null: false @@ -163,15 +174,6 @@ t.index ["status"], name: "index_invoices_on_status" end - create_table "images", force: :cascade do |t| - t.string "title" - t.text "description" - t.integer "position" - t.boolean "published" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "review_sections", force: :cascade do |t| t.bigint "review_id", null: false t.string "filename", null: false @@ -185,6 +187,7 @@ t.datetime "updated_at", null: false t.text "patch_text" t.index ["review_id"], name: "index_review_sections_on_review_id" + t.index ["status"], name: "index_review_sections_on_status" end create_table "reviews", force: :cascade do |t| @@ -199,6 +202,7 @@ t.integer "warnings", default: 0 t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["pr_url"], name: "index_reviews_on_pr_url" t.index ["status"], name: "index_reviews_on_status" end @@ -209,6 +213,7 @@ t.jsonb "metadata", default: {} t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "story_paragraphs_count", default: 0, null: false t.index ["active"], name: "index_stories_on_active" end