Skip to content

Commit 234fca5

Browse files
mroderickopencode
andcommitted
feat(invitations): add admin UI for viewing invitation logs
- Add WorkshopInvitationLogsController with index/show actions - Add InvitationLogPolicy for authorization - Add views for listing and detail of invitation logs - Add route nesting under admin/workshops - Add seed data for invitation logs - Add controller and policy specs Co-Authored-By: opencode <noreply@opencode.ai>
1 parent c3bbc8d commit 234fca5

10 files changed

Lines changed: 421 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module Admin
2+
class WorkshopInvitationLogsController < Admin::ApplicationController
3+
def index
4+
@workshop = Workshop.find(params[:workshop_id])
5+
authorize @workshop, :show?
6+
logs = InvitationLog.where(loggable: @workshop)
7+
.order(created_at: :desc)
8+
.includes(:initiator, :entries)
9+
@pagy, @logs = pagy(logs)
10+
end
11+
12+
def show
13+
@workshop = Workshop.find(params[:workshop_id])
14+
authorize @workshop, :show?
15+
@log = InvitationLog.find(params[:id])
16+
@entries = @log.entries.order(processed_at: :desc).includes(:member)
17+
end
18+
end
19+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class InvitationLogPolicy < ApplicationPolicy
2+
def index?
3+
is_admin_or_chapter_organiser?
4+
end
5+
6+
def show?
7+
is_admin_or_chapter_organiser?
8+
end
9+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
.card.mb-3
2+
.card-body
3+
.d-flex.justify-content-between.align-items-start
4+
%div
5+
%h5.card-title.mb-1
6+
Batch ##{invitation_log.id}
7+
%span.badge.bg-secondary= invitation_log.status
8+
%p.mb-1
9+
%strong> Action:
10+
= invitation_log.action.humanize
11+
%strong.ms-2 Audience:
12+
= invitation_log.audience.humanize
13+
%strong.ms-2 By:
14+
= invitation_log.initiator&.full_name || 'System'
15+
%strong.ms-2 Started:
16+
= l(invitation_log.started_at, format: :short) if invitation_log.started_at
17+
.mt-2
18+
%span.badge.bg-success= "#{invitation_log.success_count} sent"
19+
- if invitation_log.failure_count.positive?
20+
%span.badge.bg-danger= "#{invitation_log.failure_count} failed"
21+
- if invitation_log.skipped_count.positive?
22+
%span.badge.bg-warning.text-dark= "#{invitation_log.skipped_count} skipped"
23+
%span.badge.bg-secondary= "#{invitation_log.total_invitees} total"
24+
%div
25+
= link_to admin_workshop_invitation_log_path(@workshop, invitation_log),
26+
class: 'btn btn-sm btn-outline-primary' do
27+
Details
28+
- if invitation_log.failure_count.positive?
29+
= "(#{invitation_log.failure_count} failures)"
30+
31+
- if invitation_log.entries.failed.any?
32+
.mt-3
33+
%button.btn.btn-sm.btn-outline-danger{ data: { bs_toggle: 'collapse', bs_target: "#failures-#{invitation_log.id}" } }
34+
%i.fas.fa-exclamation-triangle
35+
Show #{invitation_log.failure_count} failures
36+
.collapse.mt-2{ id: "failures-#{invitation_log.id}" }
37+
.list-group
38+
- invitation_log.entries.failed.each do |entry|
39+
.list-group-item.list-group-item-danger
40+
.d-flex.justify-content-between
41+
%strong= entry.member.full_name
42+
%small= entry.failure_reason
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
- content_for :title, "Invitation Logs - #{@workshop.title}"
2+
3+
.container.py-4
4+
%h1 Invitation Logs
5+
%p.lead #{@workshop.title}
6+
7+
- if @logs.empty?
8+
%p.text-muted No invitation logs yet.
9+
- else
10+
= render partial: 'invitation_log', collection: @logs
11+
12+
= pagy_bootstrap_nav(@pagy) if @pagy.pages > 1
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
- content_for :title, "Invitation Log ##{@log.id}"
2+
3+
.container.py-4
4+
%nav{ 'aria-label': 'breadcrumb' }
5+
%ol.breadcrumb
6+
%li.breadcrumb-item= link_to @workshop.title, admin_workshop_path(@workshop)
7+
%li.breadcrumb-item= link_to 'Invitation Logs', admin_workshop_invitation_logs_path(@workshop)
8+
%li.breadcrumb-item.active Batch ##{@log.id}
9+
10+
%h1 Invitation Log ##{@log.id}
11+
12+
.card.mb-3
13+
.card-body
14+
.row
15+
.col-md-6
16+
%p
17+
%strong Status:
18+
%span.badge.bg-secondary= @log.status
19+
%p
20+
%strong Action:
21+
= @log.action.humanize
22+
%p
23+
%strong Audience:
24+
= @log.audience.humanize
25+
%p
26+
%strong Initiated by:
27+
= @log.initiator&.full_name || 'System'
28+
.col-md-6
29+
%p
30+
%strong Started:
31+
= l(@log.started_at, format: :long) if @log.started_at
32+
%p
33+
%strong Completed:
34+
= @log.completed_at ? l(@log.completed_at, format: :long) : 'In progress'
35+
%p
36+
%strong Total invitees:
37+
= @log.total_invitees
38+
- if @log.error_message.present?
39+
%p
40+
%strong Error:
41+
.text-danger= @log.error_message
42+
43+
.row.mb-3
44+
.col
45+
%span.badge.bg-success= "#{@log.success_count} sent"
46+
- if @log.failure_count.positive?
47+
.col
48+
%span.badge.bg-danger= "#{@log.failure_count} failed"
49+
- if @log.skipped_count.positive?
50+
.col
51+
%span.badge.bg-warning.text-dark= "#{@log.skipped_count} skipped"
52+
53+
%h3 Entries
54+
55+
%table.table.table-sm
56+
%thead
57+
%tr
58+
%th Member
59+
%th Status
60+
%th Reason
61+
%th Processed
62+
%tbody
63+
- @entries.each do |entry|
64+
%tr
65+
%td= entry.member.full_name
66+
%td
67+
%span{ class: entry.success? ? 'badge bg-success' : entry.failed? ? 'badge bg-danger' : 'badge bg-warning' }
68+
= entry.status.humanize
69+
%td= entry.failure_reason
70+
%td= l(entry.processed_at, format: :short) if entry.processed_at

app/views/admin/workshops/show.html.haml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,24 @@
9797
.py-4.py-lg-5.bg-light
9898
.container#invitations
9999
= render partial: 'invitation_management'
100+
101+
.py-4.py-lg-5
102+
.container
103+
%h3 Invitation Logs
104+
%p
105+
= link_to 'View Invitation Logs', admin_workshop_invitation_logs_path(@workshop), class: 'btn btn-sm btn-secondary'
106+
107+
- logs = @workshop.invitation_logs.order(created_at: :desc).limit(3)
108+
- if logs.any?
109+
- logs.each do |log|
110+
.card.mb-2
111+
.card-body.py-2
112+
.d-flex.justify-content-between.align-items-center
113+
%div
114+
%strong= log.action.humanize
115+
%span.badge.bg-secondary= log.audience.humanize
116+
%small.text-muted.ms-2 by #{log.initiator&.full_name || 'System'}
117+
.text-end
118+
%span.badge.bg-success= "#{log.success_count} sent"
119+
- if log.failure_count.positive?
120+
%span.badge.bg-danger= "#{log.failure_count} failed"

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149

150150
resource :invitations, only: [:update]
151151
resources :invitations, only: [:update]
152+
resources :invitation_logs, only: %i[index show], controller: 'workshop_invitation_logs'
152153
end
153154

154155
resources :testimonials, only: %i[index]

db/seeds.rb

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,145 @@
156156
end
157157
end
158158
Rails.logger.info '..done!'
159+
160+
Rails.logger.info 'Creating invitation logs...'
161+
162+
# Get organizers as initiators (or create one if none exist)
163+
organisers = Member.joins(:roles).where(roles: { name: 'organiser' }).to_a
164+
if organisers.empty?
165+
organisers << Fabricate(:member, name: 'Chapter', surname: 'Organiser')
166+
end
167+
168+
realistic_failure_reasons = [
169+
'SMTP connection timeout',
170+
'Invalid email format: malformed address',
171+
'Rate limit exceeded: too many recipients',
172+
'Recipient mailbox full',
173+
'Connection refused by mail server',
174+
'DNS lookup failed for recipient domain'
175+
].freeze
176+
177+
# Filter to recent past workshops (last 3 months)
178+
recent_past_workshops = past_workshops.select do |w|
179+
w.date_and_time > 3.months.ago
180+
end
181+
182+
# Create logs for ~25 recent workshops
183+
recent_past_workshops.sample(25).each do |workshop|
184+
initiator = organisers.sample
185+
audience = %w[students coaches everyone].sample
186+
187+
# Determine invitee pool based on audience
188+
potential_invitees = case audience
189+
when 'students' then students
190+
when 'coaches' then coaches
191+
else students + coaches
192+
end.sample(50)
193+
194+
# Simulate realistic distribution
195+
total_to_invite = rand(15..potential_invitees.length)
196+
skipped_count = rand(0..(total_to_invite * 0.1).to_i)
197+
actual_sent = total_to_invite - skipped_count
198+
success_count = rand((actual_sent * 0.85).to_i..actual_sent)
199+
failure_count = actual_sent - success_count
200+
201+
log = InvitationLog.create!(
202+
loggable: workshop,
203+
initiator: initiator,
204+
chapter: workshop.chapter,
205+
audience: audience,
206+
action: 'invite',
207+
status: 'completed',
208+
total_invitees: total_to_invite,
209+
success_count: success_count,
210+
failure_count: failure_count,
211+
skipped_count: skipped_count,
212+
started_at: workshop.date_and_time - 2.hours,
213+
completed_at: workshop.date_and_time - 1.hour + rand(0..30).minutes,
214+
expires_at: 180.days.from_now
215+
)
216+
217+
# Get actual members for entries
218+
invitees = potential_invitees.sample(total_to_invite)
219+
skipped_members = invitees.shift(skipped_count)
220+
sent_members = invitees
221+
222+
# Create skipped entries
223+
skipped_members.each do |member|
224+
InvitationLogEntry.create!(
225+
invitation_log: log,
226+
member: member,
227+
status: 'skipped',
228+
failure_reason: 'Invitation already exists',
229+
processed_at: log.started_at + rand(1..10).seconds
230+
)
231+
end
232+
233+
# Create success entries
234+
success_members = sent_members.sample(success_count)
235+
success_members.each do |member|
236+
invitation = WorkshopInvitation.where(workshop: workshop, member: member).first
237+
InvitationLogEntry.create!(
238+
invitation_log: log,
239+
member: member,
240+
invitation: invitation,
241+
status: 'success',
242+
processed_at: log.started_at + rand(10..120).seconds
243+
)
244+
end
245+
246+
# Create failure entries
247+
failure_members = sent_members - success_members
248+
failure_members.each do |member|
249+
InvitationLogEntry.create!(
250+
invitation_log: log,
251+
member: member,
252+
status: 'failed',
253+
failure_reason: realistic_failure_reasons.sample,
254+
processed_at: log.started_at + rand(10..120).seconds
255+
)
256+
end
257+
end
258+
259+
# Create 2 running (in-progress) logs for future workshops
260+
future_workshops.sample(2).each do |workshop|
261+
initiator = organisers.sample
262+
audience = %w[students coaches everyone].sample
263+
264+
log = InvitationLog.create!(
265+
loggable: workshop,
266+
initiator: initiator,
267+
chapter: workshop.chapter,
268+
audience: audience,
269+
action: 'invite',
270+
status: 'running',
271+
total_invitees: 0,
272+
success_count: 0,
273+
failure_count: 0,
274+
skipped_count: 0,
275+
started_at: Time.current - rand(5..30).minutes,
276+
expires_at: 180.days.from_now
277+
)
278+
279+
# Create a few in-progress entries
280+
potential_invitees = case audience
281+
when 'students' then students
282+
when 'coaches' then coaches
283+
else students + coaches
284+
end.sample(20)
285+
286+
potential_invitees.sample(rand(3..8)).each do |member|
287+
InvitationLogEntry.create!(
288+
invitation_log: log,
289+
member: member,
290+
status: 'success',
291+
processed_at: log.started_at + rand(60..300).seconds
292+
)
293+
log.update_column(:success_count, log.success_count + 1)
294+
end
295+
end
296+
297+
Rails.logger.info '..done creating invitation logs!'
159298
rescue Exception => e
160299
Rails.logger.error 'Something went wrong. Try running `bundle exec rake db:drop db:create db:migrate` first'
161300
Rails.logger.error e.message
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
RSpec.describe Admin::WorkshopInvitationLogsController, type: :controller do
2+
let(:workshop) { Fabricate(:workshop) }
3+
let(:admin) { Fabricate(:member).tap { |m| m.add_role(:admin) } }
4+
let(:regular_member) { Fabricate(:member) }
5+
6+
before { login_as_admin(admin) }
7+
8+
describe 'GET #index' do
9+
it 'renders the index page' do
10+
get :index, params: { workshop_id: workshop.id }
11+
12+
expect(response).to have_http_status(:success)
13+
end
14+
15+
it 'denies access for regular members' do
16+
other_chapter = Fabricate(:chapter)
17+
login_as_organiser(regular_member, other_chapter)
18+
19+
get :index, params: { workshop_id: workshop.id }
20+
21+
expect(response).to have_http_status(:redirect)
22+
end
23+
end
24+
25+
describe 'GET #show' do
26+
let(:log) { Fabricate(:invitation_log, loggable: workshop) }
27+
28+
it 'renders the show page' do
29+
get :show, params: { workshop_id: workshop.id, id: log.id }
30+
31+
expect(response).to have_http_status(:success)
32+
end
33+
34+
it 'denies access for regular members' do
35+
other_chapter = Fabricate(:chapter)
36+
login_as_organiser(regular_member, other_chapter)
37+
38+
get :show, params: { workshop_id: workshop.id, id: log.id }
39+
40+
expect(response).to have_http_status(:redirect)
41+
end
42+
end
43+
end

0 commit comments

Comments
 (0)