Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:

services:
postgres:
image: postgres
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
15 changes: 0 additions & 15 deletions .github/workflows/deploy.yml

This file was deleted.

2 changes: 1 addition & 1 deletion app/controllers/admin/blog_posts_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/blog_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 15 additions & 9 deletions app/controllers/stems_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 21 additions & 13 deletions app/controllers/tts_batches_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions app/jobs/gpu_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions app/models/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
19 changes: 13 additions & 6 deletions app/models/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/models/story.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion app/models/story_paragraph.rb
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions db/migrate/20260317000001_add_missing_indexes.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions db/migrate/20260317000002_add_story_paragraphs_count_to_stories.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading