Skip to content

Commit c69389f

Browse files
authored
Add audit tables and orchestrator for batch upload v2 (#244)
* Add audit tables and orchestrator for batch upload v2 Story 4: Add chunk-level audit logs and error tracking - Create certification_batch_upload_audit_logs table with status tracking - Create certification_batch_upload_errors table for failed records - Add audit_logs and upload_errors associations to CertificationBatchUpload - Create-then-update pattern: audit logs start as "started", update to "completed"/"failed" - Duration calculated from timestamps (created_at to updated_at) - Clear distinction: record failures (validation) vs chunk failures (system errors) Story 5: Create CertificationBatchUploadOrchestrator - Single entry point for all upload sources (UI, API, storage events) - Validates file exists in cloud storage before creating record - Enqueues ProcessCertificationBatchUploadJob for async processing - Returns batch upload record for status tracking Design decisions: - Errors belong to batch upload (not chunks) - user-facing, not implementation detail - Audit logs track chunk-level outcomes with aggregated counts - Status enum: started → completed (success) or failed (system error) - Test helpers in spec/support/batch_upload_helpers.rb Resolves #203, #204 * Add strict_loading to audit_logs and upload_errors associations
1 parent 31b068e commit c69389f

13 files changed

Lines changed: 588 additions & 1 deletion

reporting-app/app/models/certification_batch_upload.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ class CertificationBatchUpload < ApplicationRecord
1616

1717
belongs_to :uploader, class_name: "User"
1818
has_one_attached :file
19+
has_many :audit_logs,
20+
class_name: "CertificationBatchUploadAuditLog",
21+
strict_loading: true,
22+
dependent: :destroy
23+
has_many :upload_errors,
24+
class_name: "CertificationBatchUploadError",
25+
strict_loading: true,
26+
dependent: :destroy
1927

2028
validates :filename, presence: true
2129
validate :file_or_storage_key_present, on: :create
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
class CertificationBatchUploadAuditLog < ApplicationRecord
4+
belongs_to :certification_batch_upload
5+
6+
attribute :status, :string, default: "started"
7+
enum :status, {
8+
started: "started", # Chunk processing began
9+
completed: "completed", # Chunk succeeded (individual records may have failed validation)
10+
failed: "failed" # Chunk job crashed (system error)
11+
}
12+
13+
validates :chunk_number, presence: true, numericality: { greater_than: 0 }
14+
validates :succeeded_count, numericality: { greater_than_or_equal_to: 0 }
15+
validates :failed_count, numericality: { greater_than_or_equal_to: 0 }
16+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
class CertificationBatchUploadError < ApplicationRecord
4+
belongs_to :certification_batch_upload
5+
6+
validates :row_number, presence: true, numericality: { greater_than: 0 }
7+
validates :error_code, presence: true
8+
validates :error_message, presence: true
9+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
class CertificationBatchUploadOrchestrator
4+
class FileNotFoundError < StandardError; end
5+
6+
def initialize(storage_adapter: nil)
7+
@storage = storage_adapter || Rails.application.config.storage_adapter
8+
end
9+
10+
# Initiate batch upload processing
11+
# @param source_type [Symbol] How file was uploaded (:ui, :api, :storage_event)
12+
# @param filename [String] Original filename
13+
# @param storage_key [String] Cloud storage object key
14+
# @param uploader [User] User who initiated the upload
15+
# @param metadata [Hash] Optional metadata (reserved for future use)
16+
# @return [CertificationBatchUpload] Created batch upload record
17+
# @raise [FileNotFoundError] if file doesn't exist in storage
18+
def initiate(source_type:, filename:, storage_key:, uploader:, metadata: {})
19+
# Validate file exists in cloud storage before creating DB record
20+
unless @storage.object_exists?(key: storage_key)
21+
raise FileNotFoundError, "File not found in storage: #{storage_key}"
22+
end
23+
24+
# Create batch upload record
25+
batch_upload = CertificationBatchUpload.create!(
26+
source_type: source_type,
27+
filename: filename,
28+
storage_key: storage_key,
29+
uploader: uploader,
30+
status: :pending
31+
)
32+
33+
# Enqueue processing job for async execution
34+
ProcessCertificationBatchUploadJob.perform_later(batch_upload.id)
35+
36+
batch_upload
37+
end
38+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
# Migration to support batch upload v2 chunk-level audit logging
4+
# Tracks processing status for each 1,000-record chunk:
5+
# - status: started/completed/failed (chunk-level outcome)
6+
# - succeeded_count/failed_count: record-level results (when completed)
7+
# - timestamps: created_at = start, updated_at = completion (for duration calculation)
8+
class CreateCertificationBatchUploadAuditLogs < ActiveRecord::Migration[7.2]
9+
def change
10+
create_table :certification_batch_upload_audit_logs, id: :uuid do |t|
11+
t.references :certification_batch_upload, type: :uuid, foreign_key: true, null: false, index: true
12+
t.integer :chunk_number, null: false
13+
t.string :status, null: false, default: "started"
14+
t.integer :succeeded_count, default: 0
15+
t.integer :failed_count, default: 0
16+
t.timestamps
17+
18+
t.index [ :certification_batch_upload_id, :chunk_number ],
19+
name: "idx_audit_logs_on_upload_chunk"
20+
t.index :status, name: "idx_audit_logs_on_status"
21+
end
22+
end
23+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
# Migration to support batch upload v2 error tracking
4+
# Stores individual failed CSV records for analysis and retry:
5+
# - row_number: Line number in original CSV file (user-facing reference)
6+
# - error_code: Structured code for categorization (e.g., VAL_001)
7+
# - error_message: Human-readable description
8+
# - row_data: Full CSV row as JSONB for retry attempts
9+
class CreateCertificationBatchUploadErrors < ActiveRecord::Migration[7.2]
10+
def change
11+
create_table :certification_batch_upload_errors, id: :uuid do |t|
12+
t.references :certification_batch_upload, type: :uuid, foreign_key: true, null: false, index: true
13+
t.integer :row_number, null: false
14+
t.string :error_code, null: false
15+
t.string :error_message, null: false
16+
t.jsonb :row_data
17+
t.timestamps
18+
19+
t.index [ :certification_batch_upload_id, :error_code ],
20+
name: "idx_upload_errors_on_upload_code"
21+
end
22+
end
23+
end

reporting-app/db/schema.rb

Lines changed: 28 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :certification_batch_upload_audit_log, aliases: [ :audit_log ] do
5+
association :certification_batch_upload
6+
chunk_number { 1 }
7+
status { :started }
8+
succeeded_count { 0 }
9+
failed_count { 0 }
10+
11+
trait :completed do
12+
status { :completed }
13+
succeeded_count { 1000 }
14+
failed_count { 0 }
15+
end
16+
17+
trait :failed do
18+
status { :failed }
19+
end
20+
end
21+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
FactoryBot.define do
4+
factory :certification_batch_upload_error, aliases: [ :upload_error ] do
5+
association :certification_batch_upload
6+
row_number { 1 }
7+
error_code { "VAL_001" }
8+
error_message { "Missing required field" }
9+
row_data { { "member_id" => "M001" } }
10+
end
11+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe CertificationBatchUploadAuditLog, type: :model do
6+
let(:user) { create(:user) }
7+
let(:batch_upload) { create(:certification_batch_upload, uploader: user, storage_key: "test-key") }
8+
9+
describe "validations" do
10+
it "requires chunk_number" do
11+
log = described_class.new(certification_batch_upload: batch_upload)
12+
expect(log).not_to be_valid
13+
expect(log.errors[:chunk_number]).to be_present
14+
end
15+
16+
it "requires chunk_number to be positive" do
17+
log = described_class.new(
18+
certification_batch_upload: batch_upload,
19+
chunk_number: 0
20+
)
21+
expect(log).not_to be_valid
22+
expect(log.errors[:chunk_number]).to include("must be greater than 0")
23+
end
24+
25+
it "requires succeeded_count to be non-negative" do
26+
log = described_class.new(
27+
certification_batch_upload: batch_upload,
28+
chunk_number: 1,
29+
succeeded_count: -1
30+
)
31+
expect(log).not_to be_valid
32+
end
33+
34+
it "requires failed_count to be non-negative" do
35+
log = described_class.new(
36+
certification_batch_upload: batch_upload,
37+
chunk_number: 1,
38+
failed_count: -1
39+
)
40+
expect(log).not_to be_valid
41+
end
42+
end
43+
44+
describe "status enum" do
45+
let(:log) { create(:audit_log, certification_batch_upload: batch_upload, chunk_number: 1) }
46+
47+
it "defaults to started" do
48+
expect(log.status).to eq("started")
49+
expect(log).to be_started
50+
end
51+
52+
it "can transition to completed" do
53+
log.completed!
54+
expect(log).to be_completed
55+
end
56+
57+
it "can transition to failed" do
58+
log.failed!
59+
expect(log).to be_failed
60+
end
61+
end
62+
63+
describe "associations" do
64+
it "belongs to certification_batch_upload" do
65+
log = create(:audit_log, certification_batch_upload: batch_upload, chunk_number: 1)
66+
expect(log.certification_batch_upload).to eq(batch_upload)
67+
end
68+
69+
it "is destroyed when batch_upload is destroyed" do
70+
log = create(:audit_log, certification_batch_upload: batch_upload, chunk_number: 1)
71+
# Eager load associations to avoid strict_loading violations
72+
batch_upload_with_associations = CertificationBatchUpload.includes(:audit_logs, :upload_errors).find(batch_upload.id)
73+
expect { batch_upload_with_associations.destroy }.to change(described_class, :count).by(-1)
74+
end
75+
end
76+
end

0 commit comments

Comments
 (0)