Skip to content

Commit 5db1552

Browse files
mroderickopencode
andcommitted
feat(invitations): add invitation_logs migration and models
- Create invitation_logs and invitation_log_entries tables - Add InvitationLog model with enums, associations, expiration callback - Add InvitationLogEntry model with status tracking - Add fabricators for test data - Add model specs Co-Authored-By: opencode <noreply@opencode.ai>
1 parent 6a73071 commit 5db1552

9 files changed

Lines changed: 350 additions & 1 deletion

app/models/invitation_log.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class InvitationLog < ApplicationRecord
2+
enum :action, { invite: 'invite', reminder: 'reminder', waiting_list_notification: 'waiting_list_notification' },
3+
prefix: false
4+
enum :status, { running: 'running', completed: 'completed', failed: 'failed' }, prefix: false
5+
6+
belongs_to :loggable, polymorphic: true
7+
belongs_to :initiator, class_name: 'Member', optional: true
8+
belongs_to :chapter, optional: true
9+
has_many :entries, class_name: 'InvitationLogEntry', dependent: :destroy
10+
11+
before_create :set_expires_at
12+
13+
private
14+
15+
def set_expires_at
16+
self.expires_at ||= 180.days.from_now
17+
end
18+
end

app/models/invitation_log_entry.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class InvitationLogEntry < ApplicationRecord
2+
enum :status, { success: 'success', failed: 'failed', skipped: 'skipped' }, prefix: false
3+
4+
belongs_to :invitation_log
5+
belongs_to :member
6+
belongs_to :invitation, polymorphic: true, optional: true
7+
8+
validates :member_id, uniqueness: { scope: %i[invitation_type invitation_id] }, allow_nil: true
9+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
class CreateInvitationLogs < ActiveRecord::Migration[8.1]
2+
def change
3+
create_table :invitation_logs do |t|
4+
t.references :loggable, polymorphic: true, index: true
5+
t.references :initiator, foreign_key: { to_table: :members }, index: true
6+
t.references :chapter, index: true
7+
t.string :audience, null: false
8+
t.string :action, null: false, default: 'invite'
9+
t.integer :total_invitees, default: 0
10+
t.integer :success_count, default: 0
11+
t.integer :failure_count, default: 0
12+
t.integer :skipped_count, default: 0
13+
t.datetime :started_at
14+
t.datetime :completed_at
15+
t.string :status, null: false, default: 'running'
16+
t.text :error_message
17+
t.datetime :expires_at
18+
t.timestamps
19+
end
20+
21+
add_index :invitation_logs, :status
22+
add_index :invitation_logs, :created_at
23+
add_index :invitation_logs, :expires_at
24+
add_index :invitation_logs, %i[loggable_type loggable_id audience action status],
25+
name: 'index_invitation_logs_unique_active', unique: true,
26+
where: "status = 'running'"
27+
28+
create_table :invitation_log_entries do |t|
29+
t.references :invitation_log, null: false, index: true, foreign_key: true
30+
t.references :member, null: false, index: true
31+
t.references :invitation, polymorphic: true
32+
t.string :status, null: false, default: 'success'
33+
t.text :failure_reason
34+
t.datetime :processed_at
35+
t.timestamps
36+
end
37+
38+
add_index :invitation_log_entries, %i[invitation_log_id status]
39+
add_index :invitation_log_entries, %i[member_id processed_at]
40+
add_index :invitation_log_entries, %i[invitation_type invitation_id], unique: true
41+
end
42+
end

db/schema.rb

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.1].define(version: 2026_02_24_130000) do
13+
ActiveRecord::Schema[8.1].define(version: 2026_03_30_193245) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "pg_catalog.plpgsql"
1616

@@ -287,6 +287,51 @@
287287
t.index ["chapter_id"], name: "index_groups_on_chapter_id"
288288
end
289289

290+
create_table "invitation_log_entries", force: :cascade do |t|
291+
t.datetime "created_at", null: false
292+
t.text "failure_reason"
293+
t.bigint "invitation_id"
294+
t.bigint "invitation_log_id", null: false
295+
t.string "invitation_type"
296+
t.bigint "member_id", null: false
297+
t.datetime "processed_at"
298+
t.string "status", default: "success", null: false
299+
t.datetime "updated_at", null: false
300+
t.index ["invitation_log_id", "status"], name: "index_invitation_log_entries_on_invitation_log_id_and_status"
301+
t.index ["invitation_log_id"], name: "index_invitation_log_entries_on_invitation_log_id"
302+
t.index ["invitation_type", "invitation_id"], name: "idx_on_invitation_type_invitation_id_6d6ef495e6", unique: true
303+
t.index ["invitation_type", "invitation_id"], name: "index_invitation_log_entries_on_invitation"
304+
t.index ["member_id", "processed_at"], name: "index_invitation_log_entries_on_member_id_and_processed_at"
305+
t.index ["member_id"], name: "index_invitation_log_entries_on_member_id"
306+
end
307+
308+
create_table "invitation_logs", force: :cascade do |t|
309+
t.string "action", default: "invite", null: false
310+
t.string "audience", null: false
311+
t.bigint "chapter_id"
312+
t.datetime "completed_at"
313+
t.datetime "created_at", null: false
314+
t.text "error_message"
315+
t.datetime "expires_at"
316+
t.integer "failure_count", default: 0
317+
t.bigint "initiator_id"
318+
t.bigint "loggable_id"
319+
t.string "loggable_type"
320+
t.integer "skipped_count", default: 0
321+
t.datetime "started_at"
322+
t.string "status", default: "running", null: false
323+
t.integer "success_count", default: 0
324+
t.integer "total_invitees", default: 0
325+
t.datetime "updated_at", null: false
326+
t.index ["chapter_id"], name: "index_invitation_logs_on_chapter_id"
327+
t.index ["created_at"], name: "index_invitation_logs_on_created_at"
328+
t.index ["expires_at"], name: "index_invitation_logs_on_expires_at"
329+
t.index ["initiator_id"], name: "index_invitation_logs_on_initiator_id"
330+
t.index ["loggable_type", "loggable_id", "audience", "action", "status"], name: "index_invitation_logs_unique_active", unique: true, where: "((status)::text = 'running'::text)"
331+
t.index ["loggable_type", "loggable_id"], name: "index_invitation_logs_on_loggable"
332+
t.index ["status"], name: "index_invitation_logs_on_status"
333+
end
334+
290335
create_table "invitations", id: :serial, force: :cascade do |t|
291336
t.boolean "attending"
292337
t.datetime "created_at", precision: nil
@@ -590,5 +635,7 @@
590635
t.index ["date_and_time"], name: "index_workshops_on_date_and_time"
591636
end
592637

638+
add_foreign_key "invitation_log_entries", "invitation_logs"
639+
add_foreign_key "invitation_logs", "members", column: "initiator_id"
593640
add_foreign_key "member_email_deliveries", "members"
594641
end

rebase.sh

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/bin/bash
2+
set -e
3+
4+
BASE="6a73071c"
5+
6+
echo "Resetting to $BASE..."
7+
git reset --hard "$BASE"
8+
9+
echo ""
10+
echo "=== Commit 1: feat(invitations): add invitation_logs migration and models ==="
11+
12+
# Migration
13+
git checkout 3a4f2dbf -- db/migrate/20260330193245_create_invitation_logs.rb
14+
15+
# Models (minimal from d8863478 - no scopes/utility methods yet)
16+
git checkout d8863478 -- app/models/invitation_log.rb
17+
git checkout d8863478 -- app/models/invitation_log_entry.rb
18+
19+
# Fabricators
20+
git checkout d8863478 -- spec/fabricators/invitation_log_fabricator.rb
21+
git checkout d8863478 -- spec/fabricators/invitation_log_entry_fabricator.rb
22+
23+
# Model specs
24+
git checkout d8863478 -- spec/models/invitation_log_spec.rb
25+
git checkout 503fb86f -- spec/models/invitation_log_entry_spec.rb
26+
27+
# Schema
28+
git checkout c1c02233 -- db/schema.rb
29+
30+
git add -A
31+
git commit -m "feat(invitations): add invitation_logs migration and models
32+
33+
- Create invitation_logs and invitation_log_entries tables
34+
- Add InvitationLog model with enums, associations, expiration callback
35+
- Add InvitationLogEntry model with status tracking
36+
- Add fabricators for test data
37+
- Add model specs
38+
39+
Co-Authored-By: opencode <noreply@opencode.ai>"
40+
41+
echo "Running model tests..."
42+
bundle exec rspec spec/models/invitation_log_spec.rb spec/models/invitation_log_entry_spec.rb 2>&1 | tail -5
43+
echo ""
44+
45+
echo "=== Commit 2: feat(invitations): add InvitationLogger service ==="
46+
47+
git checkout b4a44ef2 -- app/services/invitation_logger.rb
48+
git checkout b4a44ef2 -- spec/services/invitation_logger_spec.rb
49+
50+
git add -A
51+
git commit -m "feat(invitations): add InvitationLogger service
52+
53+
- Service for logging invitation batch operations
54+
- Tracks success/failure/skip counts per batch
55+
- Provides convenience methods for starting/finishing/failing batches
56+
57+
Co-Authored-By: opencode <noreply@opencode.ai>"
58+
59+
echo "Running service tests..."
60+
bundle exec rspec spec/services/invitation_logger_spec.rb 2>&1 | tail -5
61+
echo ""
62+
63+
echo "=== Commit 3: feat(invitations): integrate logging into InvitationManager ==="
64+
65+
# Concerns with logging integration
66+
git checkout 7e1d2ab9 -- app/models/concerns/workshop_invitation_manager_concerns.rb
67+
68+
# Controller to pass initiator_id
69+
git checkout 436fe167 -- app/controllers/admin/workshops_controller.rb
70+
71+
# Workshop model with invitation_logs association + presenter scope fix
72+
git checkout 383e131b -- app/models/workshop.rb
73+
74+
# Integration spec
75+
git checkout 7e1d2ab9 -- spec/models/invitation_manager_logging_spec.rb
76+
77+
git add -A
78+
git commit -m "feat(invitations): integrate logging into InvitationManager
79+
80+
- Add invitation_logs association to Workshop model
81+
- Integrate InvitationLogger into workshop email sending
82+
- Pass current_user.id for audit trail
83+
- Handle WorkshopPresenter via workshop.model in scope
84+
85+
Co-Authored-By: opencode <noreply@opencode.ai>"
86+
87+
echo "Running integration tests..."
88+
bundle exec rspec spec/models/invitation_manager_logging_spec.rb 2>&1 | tail -5
89+
echo ""
90+
91+
echo "=== Commit 4: feat(invitations): add admin UI for viewing invitation logs ==="
92+
93+
# Controller (final version with pagy fix)
94+
git checkout b1915fbb -- app/controllers/admin/workshop_invitation_logs_controller.rb
95+
96+
# Policy (final version with is_admin_or_chapter_organiser?)
97+
git checkout 503fb86f -- app/policies/invitation_log_policy.rb
98+
99+
# Views (final versions with content_for fix)
100+
git checkout a0fc1955 -- app/views/admin/workshop_invitation_logs/index.html.haml
101+
git checkout a0fc1955 -- app/views/admin/workshop_invitation_logs/show.html.haml
102+
103+
# Shared partial from original admin UI commit
104+
git checkout cbafb93c -- app/views/admin/workshop_invitation_logs/_invitation_log.html.haml
105+
106+
# Workshop show page with logs section
107+
git checkout cbafb93c -- app/views/admin/workshops/show.html.haml
108+
109+
# Routes (final version with controller: option)
110+
git checkout e3008275 -- config/routes.rb
111+
112+
# Seeds
113+
git checkout f7f701be -- db/seeds.rb
114+
115+
# Controller and policy specs
116+
git checkout 503fb86f -- spec/controllers/admin/workshop_invitation_logs_controller_spec.rb
117+
git checkout 503fb86f -- spec/policies/invitation_log_policy_spec.rb
118+
119+
git add -A
120+
git commit -m "feat(invitations): add admin UI for viewing invitation logs
121+
122+
- Add WorkshopInvitationLogsController with index/show actions
123+
- Add InvitationLogPolicy for authorization
124+
- Add views for listing and detail of invitation logs
125+
- Add route nesting under admin/workshops
126+
- Add seed data for invitation logs
127+
- Add controller and policy specs
128+
129+
Co-Authored-By: opencode <noreply@opencode.ai>"
130+
131+
echo "Running controller and policy tests..."
132+
bundle exec rspec spec/controllers/admin/workshop_invitation_logs_controller_spec.rb spec/policies/invitation_log_policy_spec.rb 2>&1 | tail -5
133+
echo ""
134+
135+
echo "=== Commit 5: feat(invitations): add cleanup job for expired logs ==="
136+
137+
git checkout 284656d6 -- app/jobs/cleanup_expired_invitation_logs_job.rb
138+
git checkout 284656d6 -- lib/tasks/invitation_logs.rake
139+
git checkout 284656d6 -- spec/jobs/cleanup_expired_invitation_logs_job_spec.rb
140+
141+
git add -A
142+
git commit -m "feat(invitations): add cleanup job for expired logs
143+
144+
- Add CleanupExpiredInvitationLogsJob for 180-day retention
145+
- Add rake task invitation_logs:cleanup
146+
- Add job specs
147+
148+
Co-Authored-By: opencode <noreply@opencode.ai>"
149+
150+
echo "Running cleanup job tests..."
151+
bundle exec rspec spec/jobs/cleanup_expired_invitation_logs_job_spec.rb 2>&1 | tail -5
152+
echo ""
153+
154+
echo ""
155+
echo "=== Final verification: all tests ==="
156+
bundle exec rspec \
157+
spec/models/invitation_log_spec.rb \
158+
spec/models/invitation_log_entry_spec.rb \
159+
spec/services/invitation_logger_spec.rb \
160+
spec/models/invitation_manager_logging_spec.rb \
161+
spec/controllers/admin/workshop_invitation_logs_controller_spec.rb \
162+
spec/policies/invitation_log_policy_spec.rb \
163+
spec/jobs/cleanup_expired_invitation_logs_job_spec.rb \
164+
--format progress 2>&1 | tail -15
165+
166+
echo ""
167+
echo "=== Final git log ==="
168+
git log --oneline -5
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fabricator(:invitation_log_entry) do
2+
invitation_log
3+
member
4+
status 'success'
5+
processed_at { Time.current }
6+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fabricator(:invitation_log) do
2+
loggable { Fabricate(:workshop) }
3+
initiator { Fabricate(:member) }
4+
audience 'students'
5+
action 'invite'
6+
status 'running'
7+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
RSpec.describe InvitationLogEntry do
2+
describe 'associations' do
3+
it { is_expected.to belong_to(:invitation_log) }
4+
it { is_expected.to belong_to(:member) }
5+
it { is_expected.to belong_to(:invitation).optional }
6+
end
7+
8+
describe 'enums' do
9+
it 'defines status enum with string values' do
10+
expect(InvitationLogEntry.statuses).to eq({
11+
'success' => 'success',
12+
'failed' => 'failed',
13+
'skipped' => 'skipped'
14+
})
15+
end
16+
end
17+
18+
describe 'validations' do
19+
it 'validates uniqueness of member_id scoped to invitation' do
20+
entry = Fabricate(:invitation_log_entry)
21+
expect(entry).to validate_uniqueness_of(:member_id)
22+
.scoped_to(:invitation_type, :invitation_id)
23+
end
24+
end
25+
end

spec/models/invitation_log_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
RSpec.describe InvitationLog do
2+
describe 'associations' do
3+
it { is_expected.to belong_to(:loggable) }
4+
it { is_expected.to belong_to(:initiator).class_name('Member').optional }
5+
it { is_expected.to belong_to(:chapter).optional }
6+
it { is_expected.to have_many(:entries).class_name('InvitationLogEntry').dependent(:destroy) }
7+
end
8+
9+
describe 'enums' do
10+
it 'defines action enum with string values' do
11+
expect(InvitationLog.actions).to eq({ 'invite' => 'invite', 'reminder' => 'reminder', 'waiting_list_notification' => 'waiting_list_notification' })
12+
end
13+
14+
it 'defines status enum with string values' do
15+
expect(InvitationLog.statuses).to eq({ 'running' => 'running', 'completed' => 'completed', 'failed' => 'failed' })
16+
end
17+
end
18+
19+
describe 'before_create :set_expires_at' do
20+
it 'sets expires_at to 180 days from now on save' do
21+
log = Fabricate.build(:invitation_log)
22+
expect(log.expires_at).to be_nil
23+
log.save!
24+
expect(log.expires_at).to be_within(1.second).of(180.days.from_now)
25+
end
26+
end
27+
end

0 commit comments

Comments
 (0)