Skip to content

Fix SolidQueue Worker, Add Welcome Mail, Tests Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Reaktiviere die SolidQueue-Job-Verarbeitung in Production/Preview, füge eine Welcome-Mail nach Email-Verifizierung hinzu, recovere die hängenden Mail-Jobs, und sichere alles mit Tests ab, die das Problem reproduzieren würden, wenn der Worker wieder ausfällt.

Architecture: Puma lädt das solid_queue-Plugin → der Job-Worker läuft im selben Container-Prozess wie der Webserver (Rails-8-Standard für kleine bis mittlere Setups, vermeidet zusätzlichen Container). Welcome-Mail wird im EmailVerificationsController#verify getriggert, nachdem verify_email! erfolgreich war. Tests bestehen aus drei Schichten: (1) Mailer-Spec (Inhalt/Headers), (2) Request-Spec mit perform_enqueued_jobs (Trigger-Logik), (3) Post-Deploy Smoketest-Spec, die gegen das laufende System prüft, dass SolidQueue::Process-Heartbeats existieren.

Tech Stack: Rails 8.1, SolidQueue, Puma 6, RSpec, FactoryBot, ActiveJob TestHelper


File Structure

Modify:

  • web/config/puma.rb — Plugin :solid_queue aktivieren
  • web/config/environments/production.rb — wirkungslose solid_queue.supervisor-Zeile entfernen
  • web/config/environments/preview.rb — dito
  • web/app/mailers/user_mailer.rb — neue welcome-Methode
  • web/app/controllers/email_verifications_controller.rb — Welcome-Mail nach Verifizierung enqueuen
  • web/spec/rails_helper.rbActiveJob::TestHelper einbinden

Create:

  • web/app/views/user_mailer/welcome.html.erb
  • web/app/views/user_mailer/welcome.text.erb
  • web/spec/mailers/user_mailer_spec.rb
  • web/spec/requests/email_verifications_spec.rb
  • web/spec/system/solid_queue_worker_spec.rb — bootet ein Puma in-process und prüft, dass Plugin geladen wird
  • web/lib/tasks/mail_recovery.rake — One-Shot zum Aufräumen der hängenden Jobs + frische Codes für nicht-verifizierte User

Production-Hygiene (separates Skript, kein Code-File):

  • Rails Console Snippet zum Verifizieren der Worker-Heartbeats nach Deployment

Task 1: Puma-Plugin für SolidQueue aktivieren

Files:

  • Modify: web/config/puma.rb

  • Modify: web/config/environments/production.rb:56-59

  • Modify: web/config/environments/preview.rb:46-48

  • [ ] Step 1: Prüfen, dass solid_queue Gem im Gemfile ist

Run:

bash
cd web && grep "solid_queue" Gemfile Gemfile.lock | head -5

Expected: mindestens eine Zeile mit solid_queue (sonst muss erst bundle add solid_queue laufen — bei Rails 8 ist das aber Default).

  • [ ] Step 2: Plugin in web/config/puma.rb einfügen

Existing content:

ruby
plugin :tmp_restart

Replace with:

ruby
plugin :tmp_restart

# Run SolidQueue worker in the same process as Puma (Rails-8 standard for
# single-container deployments). Without this plugin, jobs enqueued via
# deliver_later / perform_later sit in solid_queue_ready_executions forever
# and no mail ever leaves the system.
plugin :solid_queue if %w[production preview].include?(ENV.fetch("RAILS_ENV", "development"))

Rationale für die if-Condition: In Tests soll Puma den Worker nicht starten, weil RSpec den ActiveJob-Test-Adapter nutzt. In Development läuft das Bin-Script bin/dev mit foreman, das den Worker bei Bedarf separat hochzieht.

  • [ ] Step 3: Wirkungslose Zeile aus production.rb entfernen

In web/config/environments/production.rb:56-59:

Replace:

ruby
  # Jobs
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }
  config.solid_queue.supervisor = true # Run worker in same process

With:

ruby
  # Jobs — worker runs via the `solid_queue` Puma plugin (see config/puma.rb).
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }
  • [ ] Step 4: Dasselbe für preview.rb

In web/config/environments/preview.rb:46-48:

Replace:

ruby
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }
  config.solid_queue.supervisor = true # Run worker in same process

With:

ruby
  # Jobs — worker runs via the `solid_queue` Puma plugin (see config/puma.rb).
  config.active_job.queue_adapter = :solid_queue
  config.solid_queue.connects_to = { database: { writing: :queue } }
  • [ ] Step 5: Bestätigen, dass Puma das Plugin lokal lädt

Run:

bash
cd web && RAILS_ENV=production bundle exec puma -C config/puma.rb --dry-run 2>&1 | grep -iE "(plugin|solid_queue)" || echo "puma --dry-run hat keine plugin-Ausgabe"

Falls --dry-run keine Plugin-Info druckt: stattdessen kurz mit RAILS_ENV=preview bundle exec puma -C config/puma.rb -p 4567 starten, in den Logs SolidQueue suchen, Strg-C. Expected: Log-Zeile wie SolidQueue-X.Y.Z started supervisor.

  • [ ] Step 6: Commit
bash
cd web
git add config/puma.rb config/environments/production.rb config/environments/preview.rb
git commit -m "fix: load solid_queue plugin in puma for production/preview

Without the plugin no worker process exists in the web container, so all
deliver_later / perform_later jobs accumulate in solid_queue_ready_executions
and no mail leaves the system. config.solid_queue.supervisor = true is not a
real Rails 8 option — removed.

Refs: 4 mail jobs pending since 2026-04-30 in prod."

Task 2: WelcomeMailer-Methode und Views

Files:

  • Modify: web/app/mailers/user_mailer.rb

  • Create: web/app/views/user_mailer/welcome.html.erb

  • Create: web/app/views/user_mailer/welcome.text.erb

  • Create: web/spec/mailers/user_mailer_spec.rb

  • [ ] Step 1: Failing Mailer-Spec schreiben

Create web/spec/mailers/user_mailer_spec.rb:

ruby
# frozen_string_literal: true

require "rails_helper"

RSpec.describe UserMailer, type: :mailer do
  describe "welcome" do
    let(:user) { create(:user, email: "new@example.com") }
    let(:mail) { described_class.welcome(user) }

    it "addresses the verified user" do
      expect(mail.to).to eq(["new@example.com"])
      expect(mail.from).to eq(["noreply@thelawin.dev"])
      expect(mail.reply_to).to eq(["hello@thelawin.dev"])
    end

    it "uses a welcoming subject" do
      expect(mail.subject).to match(/welcome/i)
      expect(mail.subject).to include("thelawin.dev")
    end

    it "links to the dashboard and the docs in the HTML body" do
      body = mail.body.parts.find { |p| p.content_type.start_with?("text/html") }.body.encoded
      expect(body).to include("dashboard")
      expect(body).to match(/docs\.thelawin\.dev|\/docs/)
    end

    it "renders a plain-text alternative" do
      text = mail.body.parts.find { |p| p.content_type.start_with?("text/plain") }.body.encoded
      expect(text).to include("thelawin.dev")
      expect(text).to include(user.email.split("@").first)
    end

    it "mentions the first API call as the next step" do
      body = mail.body.encoded
      expect(body).to match(/api[ -]?key/i)
      expect(body).to match(/curl|POST|\/v1\/generate/i)
    end
  end

  describe "verification_code (regression)" do
    let(:user) { create(:user, email: "verify@example.com") }
    let(:mail) { described_class.verification_code(user, "123456") }

    it "embeds the 6-digit code in the subject" do
      expect(mail.subject).to include("123456")
      expect(mail.to).to eq(["verify@example.com"])
    end
  end
end
  • [ ] Step 2: Run spec, confirm it fails

Run:

bash
cd web && bundle exec rspec spec/mailers/user_mailer_spec.rb

Expected: 5 Failures (alle welcome-Tests scheitern wegen NoMethodError: undefined method 'welcome'). Verification-Code-Test sollte schon grün sein.

  • [ ] Step 3: welcome-Methode im UserMailer ergänzen

In web/app/mailers/user_mailer.rb, vor dem end der Klasse, nach verification_code (line 37):

ruby
  def welcome(user)
    @user = user
    @first_name = user.email.split("@").first
    @dashboard_url = dashboard_url
    @api_keys_url = api_keys_url
    @docs_url = ENV.fetch("DOCS_BASE_URL", "https://docs.thelawin.dev")

    mail(to: @user.email, subject: "Welcome to thelawin.dev — your first API call awaits")
  end
  • [ ] Step 4: HTML-View erstellen

Create web/app/views/user_mailer/welcome.html.erb:

erb
<!DOCTYPE html>
<html>
  <body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px; color: #1f2937;">
    <h1 style="font-size: 22px; margin: 0 0 16px;">Hey <%= @first_name %>,</h1>

    <p>Your email is verified — your thelawin.dev account is live. Here is the 60-second tour:</p>

    <ol style="line-height: 1.7;">
      <li>Grab an API key from <a href="<%= @api_keys_url %>" style="color: #f59e0b;">your dashboard</a>. Sandbox keys are free; live keys need a plan.</li>
      <li>Send your first invoice JSON to <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">POST /v1/generate</code>. The response contains a ZUGFeRD/Factur-X PDF as Base64.</li>
      <li>Read the format reference at <a href="<%= @docs_url %>" style="color: #f59e0b;"><%= @docs_url %></a> if you need XRechnung, Peppol, UBL, or FatturaPA.</li>
    </ol>

    <p style="margin-top: 24px;">
      <a href="<%= @dashboard_url %>" style="display: inline-block; background: #f59e0b; color: #fff; padding: 10px 20px; border-radius: 6px; text-decoration: none;">Open dashboard</a>
    </p>

    <p style="margin-top: 32px; font-size: 14px; color: #6b7280;">
      Stuck? Reply to this mail — it goes to a human.<br>
      — Stephan
    </p>
  </body>
</html>
  • [ ] Step 5: Plain-Text-View erstellen

Create web/app/views/user_mailer/welcome.text.erb:

erb
Hey <%= @first_name %>,

Your email is verified — your thelawin.dev account is live. Here is the
60-second tour:

  1. Grab an API key in your dashboard: <%= @api_keys_url %>
     Sandbox keys are free; live keys need a plan.

  2. Send your first invoice JSON to POST /v1/generate. The response
     contains a ZUGFeRD/Factur-X PDF as Base64.

     Example:
       curl -X POST https://api.thelawin.dev/v1/generate \
         -H "Authorization: Bearer YOUR_API_KEY" \
         -H "Content-Type: application/json" \
         -d @invoice.json

  3. Need XRechnung, Peppol, UBL, or FatturaPA? See the format reference:
     <%= @docs_url %>

Open the dashboard: <%= @dashboard_url %>

Stuck? Reply to this mail — it goes to a human.

— Stephan
  • [ ] Step 6: Spec erneut laufen lassen

Run:

bash
cd web && bundle exec rspec spec/mailers/user_mailer_spec.rb

Expected: alle 6 Examples grün.

  • [ ] Step 7: User-sichtbaren Text durch humanizer-Skill schicken

Nutzer-sichtbare Texte (Welcome-Mail HTML + Text + Subject) sind per User-Feedback feedback_ux_copy_humanizer.md durch das humanizer-Skill zu schicken. Skill aufrufen mit Argument: Pfade app/views/user_mailer/welcome.html.erb, app/views/user_mailer/welcome.text.erb, und das Subject aus user_mailer.rb. Vorgeschlagene Änderungen prüfen, anwenden, dann Spec erneut laufen.

Run:

bash
cd web && bundle exec rspec spec/mailers/user_mailer_spec.rb

Expected: weiterhin grün.

  • [ ] Step 8: Commit
bash
cd web
git add app/mailers/user_mailer.rb app/views/user_mailer/welcome.html.erb app/views/user_mailer/welcome.text.erb spec/mailers/user_mailer_spec.rb
git commit -m "feat(mailer): add welcome mail with first-API-call onboarding"

Task 3: Welcome-Mail nach Verifizierung triggern

Files:

  • Modify: web/app/controllers/email_verifications_controller.rb:11-18

  • Create: web/spec/requests/email_verifications_spec.rb

  • [ ] Step 1: Failing Request-Spec schreiben

Create web/spec/requests/email_verifications_spec.rb:

ruby
# frozen_string_literal: true

require "rails_helper"

RSpec.describe "Email verification flow", type: :request do
  include ActiveJob::TestHelper

  let(:user) { create(:user, email_verified_at: nil) }
  let(:code) { user.generate_verification_code! }

  before { sign_in user }

  describe "POST /users/verify_email" do
    context "with a valid code" do
      it "verifies the user and enqueues a welcome mail" do
        code # generate
        expect {
          post verify_email_path, params: { code: code }
        }.to change { ActionMailer::Base.deliveries.size }.by(0) # deliver_later only enqueues

        expect(response).to redirect_to(dashboard_path)
        expect(user.reload).to be_email_verified

        # The welcome job is the only one that should have been enqueued by /verify_email.
        welcome_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |j|
          j[:args].first == "UserMailer" && j[:args].second == "welcome"
        end
        expect(welcome_jobs.size).to eq(1)
      end

      it "actually delivers the welcome mail when the queue runs" do
        code

        perform_enqueued_jobs do
          post verify_email_path, params: { code: code }
        end

        sent = ActionMailer::Base.deliveries.map(&:subject)
        expect(sent).to include(a_string_matching(/welcome/i))
      end
    end

    context "with an invalid code" do
      it "does not enqueue a welcome mail" do
        code # generate the real one
        expect {
          post verify_email_path, params: { code: "000000" }
        }.not_to(change { ActiveJob::Base.queue_adapter.enqueued_jobs.size })

        expect(user.reload).not_to be_email_verified
      end
    end

    context "with an expired code" do
      it "does not verify and does not send a welcome mail" do
        code
        travel_to(15.minutes.from_now) do
          expect {
            post verify_email_path, params: { code: code }
          }.not_to(change { ActiveJob::Base.queue_adapter.enqueued_jobs.size })
        end
        expect(user.reload).not_to be_email_verified
      end
    end
  end
end
  • [ ] Step 2: Sicherstellen, dass der test-Adapter Queue-Inspection erlaubt

Open web/spec/rails_helper.rb and check that ActiveJob::TestHelper is included for request specs. If not present, add inside RSpec.configure do |config| block (around line 50, near other includes):

ruby
  config.include ActiveJob::TestHelper, type: :request
  config.include ActiveJob::TestHelper, type: :mailer

Also verify queue adapter for tests. In web/config/environments/test.rb (read it first; it should already say config.active_job.queue_adapter = :test). If it doesn't, add:

ruby
  config.active_job.queue_adapter = :test
  • [ ] Step 3: Spec laufen lassen, Failure beobachten

Run:

bash
cd web && bundle exec rspec spec/requests/email_verifications_spec.rb

Expected: Mindestens das "enqueues a welcome mail"-Example failed mit expected size 1, got 0 (Controller triggert noch nichts).

  • [ ] Step 4: Controller erweitern

In web/app/controllers/email_verifications_controller.rb, replace the verify method (lines 11-18):

ruby
  def verify
    if current_user.verify_email!(params[:code].to_s.strip)
      UserMailer.welcome(current_user).deliver_later
      redirect_to dashboard_path, notice: I18n.t("verification.success", default: "Email verified.")
    else
      flash.now[:alert] = I18n.t("verification.invalid_code", default: "Invalid or expired code. Please try again.")
      render :show, status: :unprocessable_entity
    end
  end
  • [ ] Step 5: Spec erneut laufen lassen

Run:

bash
cd web && bundle exec rspec spec/requests/email_verifications_spec.rb

Expected: alle 4 Examples grün.

  • [ ] Step 6: Commit
bash
cd web
git add app/controllers/email_verifications_controller.rb spec/requests/email_verifications_spec.rb spec/rails_helper.rb config/environments/test.rb
git commit -m "feat(verification): send welcome mail after successful email verification"

Task 4: E2E-Test: Puma-Plugin lädt SolidQueue

Files:

  • Create: web/spec/system/solid_queue_worker_spec.rb

Hintergrund: Reine Unit-Tests können nicht beweisen, dass das Puma-Plugin tatsächlich geladen wird, weil das erst beim Puma-Boot passiert. Wir booten in einem System-Spec einen echten Puma-Prozess in einem Subshell mit RAILS_ENV=preview, lesen die Logs, und prüfen auf die SolidQueue Boot-Zeile sowie auf einen SolidQueue::Process-Heartbeat-Eintrag.

  • [ ] Step 1: Spec schreiben

Create web/spec/system/solid_queue_worker_spec.rb:

ruby
# frozen_string_literal: true

require "rails_helper"
require "socket"
require "timeout"

# This spec verifies that the puma config actually loads the solid_queue plugin
# in environments where we expect it to. We boot a real puma process pointed at
# the test database and watch its logs for the SolidQueue supervisor banner and
# a process heartbeat row in solid_queue_processes.
#
# It is deliberately tagged :slow so it can be excluded with `--tag '~slow'`
# during fast TDD loops.
RSpec.describe "SolidQueue Puma plugin", :slow, type: :system do
  def free_port
    server = TCPServer.new("127.0.0.1", 0)
    port = server.addr[1]
    server.close
    port
  end

  it "starts a SolidQueue supervisor inside the Puma process when RAILS_ENV=preview" do
    port = free_port
    log = Tempfile.new(["puma", ".log"])
    pid = nil

    Dir.chdir(Rails.root) do
      pid = Process.spawn(
        { "RAILS_ENV" => "preview", "PORT" => port.to_s, "DATABASE_URL" => ENV["DATABASE_URL"].to_s },
        "bundle", "exec", "puma", "-C", "config/puma.rb",
        out: log.path, err: log.path
      )
    end

    # Wait up to 20s for the SolidQueue banner.
    deadline = Time.now + 20
    booted = false
    while Time.now < deadline
      output = File.read(log.path)
      if output.match?(/SolidQueue.*started/i) || output.match?(/solid_queue.*supervisor/i)
        booted = true
        break
      end
      sleep 0.5
    end

    expect(booted).to eq(true), -> { "Puma never logged a SolidQueue boot line. Log:\n#{File.read(log.path)}" }

    # Cross-check: SolidQueue should also register a heartbeat in the DB.
    deadline = Time.now + 10
    has_process = false
    while Time.now < deadline
      has_process = SolidQueue::Process.where("last_heartbeat_at > ?", 30.seconds.ago).exists?
      break if has_process
      sleep 0.5
    end
    expect(has_process).to eq(true)
  ensure
    if pid
      Process.kill("TERM", pid) rescue nil
      Process.wait(pid) rescue nil
    end
    log&.close
    log&.unlink
  end
end
  • [ ] Step 2: Sicherstellen, dass die preview-Konfig in Test-DB läuft

RAILS_ENV=preview braucht eine Datenbank. Check web/config/database.yml — der preview:-Block sollte eine url: <%= ENV["DATABASE_URL"] %>-Form haben. Wenn nicht, im Spec ein Stub-Setup verwenden: vor Process.spawn einfach ENV["DATABASE_URL"] ||= "postgresql://localhost/thelawin_test" setzen und auf db:test:prepare vertrauen.

Run:

bash
cd web && bundle exec rspec spec/system/solid_queue_worker_spec.rb

Expected: grün. Falls flaky wegen DB-Setup: das Spec mit :integration oder :slow taggen und in CI nur nightly laufen lassen — der eigentliche Schutz liegt bereits im Smoketest (Task 6).

  • [ ] Step 3: Commit
bash
cd web
git add spec/system/solid_queue_worker_spec.rb
git commit -m "test: verify puma actually boots the solid_queue supervisor"

Task 5: One-Shot-Rake-Task für Mail-Recovery

Files:

  • Create: web/lib/tasks/mail_recovery.rake

Hintergrund: Production hat 4 hängende Jobs in solid_queue_ready_executions. Wenn der Worker wieder läuft, würden die alten Verification-Codes (Elena 30.04., Patrick 15.05.) ausgeführt — aber die Codes sind abgelaufen (10-Min-TTL), also nutzlos. Bessere Strategie: hängende Jobs droppen, frische Codes für die zwei nicht-verifizierten User generieren, und für die bereits verifizierten User (steviee77, deltacity) keine Welcome-Mail nachreichen (sie haben den Account schon seit Wochen aktiv).

  • [ ] Step 1: Rake-Task schreiben

Create web/lib/tasks/mail_recovery.rake:

ruby
# frozen_string_literal: true

namespace :mail_recovery do
  desc "Drop stuck verification-mail jobs from solid_queue and re-issue fresh codes to unverified users"
  task purge_and_reissue: :environment do
    pending = SolidQueue::ReadyExecution.joins(:job).where("solid_queue_jobs.class_name LIKE ?", "%MailDeliveryJob%")
    puts "Pending mail-delivery executions: #{pending.count}"

    pending.find_each do |exec|
      job = exec.job
      puts "  - dropping job ##{job.id} created_at=#{job.created_at} args=#{job.arguments.to_s[0..120]}"
      exec.destroy
      job.destroy
    end

    unverified = User.where(email_verified_at: nil)
    puts "Unverified users: #{unverified.count}"
    unverified.find_each do |user|
      code = user.generate_verification_code!
      UserMailer.verification_code(user, code).deliver_later
      puts "  - reissued code for #{user.email}"
    end

    puts "Done. Run `SolidQueue::Process.where(\"last_heartbeat_at > ?\", 1.minute.ago).count` to confirm worker is alive."
  end
end
  • [ ] Step 2: Task lokal testen (auf einer Test-DB)

Run:

bash
cd web && RAILS_ENV=test bin/rails db:test:prepare
cd web && RAILS_ENV=test bin/rails mail_recovery:purge_and_reissue

Expected: kein Crash, Ausgabe Pending mail-delivery executions: 0 und Unverified users: 0 (Test-DB ist leer).

  • [ ] Step 3: Commit
bash
cd web
git add lib/tasks/mail_recovery.rake
git commit -m "chore(mail): rake task to purge stuck mail jobs and reissue verification codes"

Task 6: Post-Deploy Smoketest erweitern

Files:

  • Read: durchsuche das Repo nach existierender Smoketest-Logik

  • [ ] Step 1: Bestehenden Smoketest finden

Run:

bash
grep -rn "smoke\|curl.*health\|status.*200" /Users/stephan/dev/thelawin.dev --include="*.sh" --include="*.rb" --include="*.yml" 2>/dev/null | grep -vE "(node_modules|tmp|\.git)" | head -10

Erwartet: vermutlich ein Bash-Skript oder ein RSpec-File, das nach Preview/Prod-Deploy gegen die Endpoints curlt (gemäß feedback_post_deploy_smoketest.md).

  • [ ] Step 2: Smoketest um Worker-Check erweitern

Wenn Smoketest in Bash existiert, einen zusätzlichen Schritt anfügen, der via SSH den Worker-Heartbeat prüft:

bash
ssh debian@51.83.102.39 "docker exec prod-web-1 bin/rails runner '
  age = (Time.current - SolidQueue::Process.maximum(:last_heartbeat_at).to_time) rescue 9999
  if age > 60
    puts \"WORKER STALE (heartbeat #{age.to_i}s old)\"
    exit 1
  else
    puts \"worker ok, heartbeat ${age.to_i}s old\"
  end
'"

Wenn der Smoketest in Ruby/RSpec ist, ein zusätzliches Example hinzufügen, das via gh api oder direkt via Rails-Health-Endpoint prüft. Falls kein Health-Endpoint dafür existiert (vermutlich): einen /up/queue Health-Endpoint im Web-App ergänzen, der SolidQueue::Process.where("last_heartbeat_at > ?", 60.seconds.ago).count > 0 zurückgibt.

Pragmatisch: Erstmal das SSH-Bash-Check anhängen, weil das ohne neue Public-Endpoint funktioniert. Wenn der Smoketest schon ein Ruby-Skript ist, das Net::HTTP benutzt, dann ist eher ein neuer Health-Endpoint sauber. Entscheidung an dem Punkt nach Lesen des Files treffen.

  • [ ] Step 3: Lokal manuell verifizieren

Run das SSH-Snippet oben gegen Preview (nach Preview-Deployment). Expected: worker ok, heartbeat <60s old.

  • [ ] Step 4: Commit
bash
cd web  # oder Repo-Root, je nachdem wo der Smoketest liegt
git add <smoketest-files>
git commit -m "chore(ops): add solid_queue worker heartbeat check to post-deploy smoketest"

Task 7: Deployment & Production-Recovery

  • [ ] Step 1: Vor dem Push DEPLOYMENT.md konsultieren

Per User-Memory feedback_docs_maintenance.md. Falls Änderungen an Docker-Compose oder VPS-Pfaden nötig sind: dort prüfen und ggf. updaten.

Run:

bash
cd /Users/stephan/dev/thelawin.dev && grep -iE "(solid_queue|worker|jobs)" DEPLOYMENT.md CLAUDE.md

Wenn nichts gefunden wird, in CLAUDE.md einen kurzen Absatz unter "Wichtige Befehle" hinzufügen:

markdown
### SolidQueue Worker

Der Worker läuft als Puma-Plugin im `web`-Container. Status-Check:

\`\`\`bash
docker exec prod-web-1 bin/rails runner 'puts SolidQueue::Process.pluck(:kind, :last_heartbeat_at)'
\`\`\`
  • [ ] Step 2: Auf main pushen → Preview deployt automatisch
bash
git push origin main

Watchtower aktualisiert den Preview-Stack innerhalb von 1-2 Minuten.

  • [ ] Step 3: Preview-Smoketest

Per feedback_post_deploy_smoketest.md:

bash
curl -sI https://preview.thelawin.dev | head -1
curl -sI https://api.preview.thelawin.dev/health | head -1
curl -sI https://docs.preview.thelawin.dev | head -1

Plus Worker-Check:

bash
ssh debian@51.83.102.39 "docker exec preview-web-1 bin/rails runner 'p SolidQueue::Process.pluck(:kind, :last_heartbeat_at)'"

Expected: last_heartbeat_at < 60 Sekunden alt.

  • [ ] Step 4: Mail-Recovery auf Preview testen
bash
ssh debian@51.83.102.39 "docker exec preview-web-1 bin/rails mail_recovery:purge_and_reissue"

Auf Preview ist die Anzahl Pending-Jobs vermutlich anders als auf Prod — das ist OK. Wichtig: kein Crash, Worker konsumiert die neu enqueuten Jobs sofort (heartbeat-DB-Eintrag bestätigt, dass Worker läuft).

  • [ ] Step 5: Merge auf production
bash
git checkout production
git merge --no-ff main
git push origin production
git checkout main

Watchtower zieht :latest Images, Prod-Stack aktualisiert.

  • [ ] Step 6: Prod-Smoketest
bash
curl -sI https://thelawin.dev | head -1
curl -sI https://api.thelawin.dev/health | head -1
curl -sI https://docs.thelawin.dev | head -1
ssh debian@51.83.102.39 "docker exec prod-web-1 bin/rails runner 'p SolidQueue::Process.pluck(:kind, :last_heartbeat_at)'"
  • [ ] Step 7: Production Mail-Recovery ausführen
bash
ssh debian@51.83.102.39 "docker exec prod-web-1 bin/rails mail_recovery:purge_and_reissue"

Expected-Output (laut prior Investigation):

  • Pending mail-delivery executions: 4 (die Pre-Worker-Jobs, plus eventuell die Mattermost-Notification-Jobs)

  • Unverified users: 2 — Elena (elena@ultra-lab.net), Patrick (patrick.koelle@kspmaschinen.de)

  • Beide kriegen frischen 6-Digit-Code und Welcome wird durch sie selbst nach Verifikation getriggert

  • [ ] Step 8: Bestätigung sammeln

bash
ssh debian@51.83.102.39 "docker exec prod-web-1 bin/rails runner '
puts SolidQueue::Job.where(finished_at: nil).count.to_s + \" jobs pending\"
puts SolidQueue::Process.pluck(:kind, :last_heartbeat_at).inspect
puts User.where(email_verified_at: nil).pluck(:email, :email_verification_sent_at).inspect
'"

Expected: 0 oder 1 pending (die frischen verification-mails werden in unter 30s konsumiert), heartbeat aktuell, beide unverified User haben email_verification_sent_at von gerade eben.

  • [ ] Step 9: HISTORY.md updaten

Per CLAUDE.md-Konvention:

markdown
## 2026-05-19

- fix: solid_queue plugin in puma config — worker war seit Wochen tot, alle deliver_later/perform_later Jobs verpufft
- feat: welcome mail nach erfolgreicher Email-Verifizierung
- ops: mail_recovery rake task; reissue verification codes für Elena + Patrick (Sign-ups vom 30.04. und 15.05. die nie eine Mail erhalten hatten)
- test: mailer + request + system specs für Welcome-Flow

Run:

bash
cd /Users/stephan/dev/thelawin.dev
git add HISTORY.md
git commit -m "docs: history entry for mail worker fix + welcome mail"
git push origin main

Self-Review

Spec coverage:

  • ✅ Worker reparieren → Task 1
  • ✅ Welcome-Mail bauen → Task 2 + 3
  • ✅ E2E-Tests die das prüfen → Task 4 (Puma-Plugin lädt SolidQueue), Task 3 (Welcome-Trigger), Task 6 (Post-Deploy)
  • ✅ Recovery der hängenden Mails → Task 5 + Task 7 Step 7

Placeholder scan: keine TBDs, alle Code-Snippets vollständig, kein "ähnlich wie Task N".

Type consistency: UserMailer.welcome(user) durchgängig; SolidQueue::Process.last_heartbeat_at durchgängig.

Mögliche Lücken:

  1. Mattermost-Notification-Jobs: track_signup callback (User-Model line 234) enqueued MattermostNotificationJob.perform_later — d.h. auch Mattermost-Signups wurden nie versendet. Der Rake-Task in Task 5 zielt nur auf MailDeliveryJob. Workaround: nach Worker-Boot werden auch die hängenden Mattermost-Jobs automatisch konsumiert. Falls die als Sanity-Test nervig sind: zusätzlich in Task 5 die MattermostNotificationJob-Entries droppen, da die Notifications mehrere Wochen alt sind.
  2. Development-Mode: Das Puma-Plugin wird in Dev nicht geladen (mit Absicht — bin/dev/foreman handhabt das). Falls Dev-Tests scheitern, weil Mails enqueued aber nie versendet werden: stattdessen config.active_job.queue_adapter = :inline in config/environments/development.rb setzen (separate Entscheidung, nicht in diesem Plan).

Plan complete and saved to docs/superpowers/plans/2026-05-19-fix-mail-worker-and-welcome.md.

Two execution options:

  1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration
  2. Inline Execution — execute tasks in this session using executing-plans, batch with checkpoints

Which approach?

ZUGFeRD 2.4 & Factur-X 1.0.8 compliant