Skip to content

Commit a701242

Browse files
committed
feat: Implement event RSVP functionality with ICS export
- Added RSVP actions (interested, going, cancel) in EventsController. - Created EventAttendance model to track RSVPs with validations. - Introduced EventAttendancePolicy for access control on RSVP actions. - Implemented ICS export functionality for events. - Updated routes to include RSVP and ICS endpoints for events. - Enhanced Event and Calendar models to support associations with EventAttendance and CalendarEntry. - Added views for RSVP actions and ICS download link in event show page. - Created tests for EventsController, EventAttendance model, and policies. - Updated documentation to reflect new features and usage.
1 parent db789ef commit a701242

File tree

25 files changed

+645
-16
lines changed

25 files changed

+645
-16
lines changed

app/controllers/better_together/calendars_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ module BetterTogether
44
# CRUD for calendars
55
class CalendarsController < FriendlyResourceController
66
# GET /better_together/calendars
7+
def show
8+
@calendar = set_resource_instance
9+
authorize @calendar
10+
@upcoming_events = @calendar.events.upcoming.order(:starts_at)
11+
@past_events = @calendar.events.past.order(starts_at: :desc)
12+
end
713

814
# GET /better_together/calendars/new
915
def new

app/controllers/better_together/events_controller.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,52 @@ def index
1414
@past_events = @events.past
1515
end
1616

17+
def show
18+
super
19+
end
20+
21+
def ics
22+
send_data @event.to_ics,
23+
filename: "#{@event.slug}.ics",
24+
type: 'text/calendar; charset=UTF-8'
25+
end
26+
27+
# RSVP actions
28+
def rsvp_interested
29+
rsvp_update('interested')
30+
end
31+
32+
def rsvp_going
33+
rsvp_update('going')
34+
end
35+
36+
def rsvp_cancel
37+
@event = set_resource_instance
38+
authorize @event, :show?
39+
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: helpers.current_person)
40+
attendance&.destroy
41+
redirect_to @event, notice: t('better_together.events.rsvp_cancelled', default: 'RSVP cancelled')
42+
end
43+
1744
protected
1845

1946
def resource_class
2047
::BetterTogether::Event
2148
end
49+
50+
private
51+
52+
def rsvp_update(status)
53+
@event = set_resource_instance
54+
authorize @event, :show?
55+
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: helpers.current_person)
56+
attendance.status = status
57+
authorize attendance
58+
if attendance.save
59+
redirect_to @event, notice: t('better_together.events.rsvp_saved', default: 'RSVP saved')
60+
else
61+
redirect_to @event, alert: attendance.errors.full_messages.to_sentence
62+
end
63+
end
2264
end
2365
end

app/controllers/better_together/resource_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
module BetterTogether
44
# Abstracts the retrieval of resources
55
class ResourceController < ApplicationController # rubocop:todo Metrics/ClassLength
6-
before_action :set_resource_instance, only: %i[show edit update destroy]
7-
before_action :authorize_resource, only: %i[new show edit update destroy]
6+
before_action :set_resource_instance, only: %i[show edit update destroy ics]
7+
before_action :authorize_resource, only: %i[new show edit update destroy ics]
88
before_action :resource_collection, only: %i[index]
99
before_action :authorize_resource_class, only: %i[index]
1010
after_action :verify_authorized, except: :index

app/models/better_together/calendar.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class Calendar < ApplicationRecord
1212

1313
belongs_to :community, class_name: '::BetterTogether::Community'
1414

15+
has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy
16+
has_many :events, through: :calendar_entries
17+
1518
slugged :name
1619

1720
translates :name
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22

33
module BetterTogether
4+
# Join model between Calendar and Event for future calendar organization
45
class CalendarEntry < ApplicationRecord
6+
belongs_to :calendar, class_name: 'BetterTogether::Calendar'
7+
belongs_to :event, class_name: 'BetterTogether::Event'
8+
9+
validates :event_id, uniqueness: { scope: :calendar_id }
510
end
611
end

app/models/better_together/event.rb

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module BetterTogether
44
# A Schedulable Event
5+
# rubocop:disable Metrics/ClassLength
56
class Event < ApplicationRecord
67
include Attachments::Images
78
include Categorizable
@@ -16,6 +17,12 @@ class Event < ApplicationRecord
1617

1718
attachable_cover_image
1819

20+
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
21+
has_many :attendees, through: :event_attendances, source: :person
22+
23+
has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy
24+
has_many :calendars, through: :calendar_entries
25+
1926
categorizable(class_name: 'BetterTogether::EventCategory')
2027

2128
# belongs_to :address, -> { where(physical: true, primary_flag: true) }
@@ -27,7 +34,6 @@ class Event < ApplicationRecord
2734
translates :description, backend: :action_text
2835

2936
validates :name, presence: true
30-
validates :starts_at, presence: true
3137
validates :registration_url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }, allow_blank: true,
3238
allow_nil: true
3339
validate :ends_at_after_starts_at
@@ -75,15 +81,88 @@ def to_s
7581
name
7682
end
7783

84+
# Minimal iCalendar representation for export
85+
def to_ics
86+
lines = ics_header_lines + ics_event_lines + ics_footer_lines
87+
"#{lines.join("\r\n")}\r\n"
88+
end
89+
7890
configure_attachment_cleanup
7991

8092
private
8193

94+
def ics_header_lines
95+
[
96+
'BEGIN:VCALENDAR',
97+
'VERSION:2.0',
98+
'PRODID:-//Better Together Community Engine//EN',
99+
'CALSCALE:GREGORIAN',
100+
'METHOD:PUBLISH',
101+
'BEGIN:VEVENT'
102+
]
103+
end
104+
105+
def ics_event_lines
106+
lines = []
107+
lines.concat(ics_basic_event_info)
108+
lines << ics_description_line if ics_description_present?
109+
lines.concat(ics_timing_info)
110+
lines << "URL:#{url}"
111+
lines
112+
end
113+
114+
def ics_basic_event_info
115+
[
116+
"DTSTAMP:#{ics_timestamp}",
117+
"UID:event-#{id}@better-together",
118+
"SUMMARY:#{name}"
119+
]
120+
end
121+
122+
def ics_timing_info
123+
lines = []
124+
lines << "DTSTART:#{ics_start_time}" if starts_at
125+
lines << "DTEND:#{ics_end_time}" if ends_at
126+
lines
127+
end
128+
129+
def ics_footer_lines
130+
['END:VEVENT', 'END:VCALENDAR']
131+
end
132+
133+
def ics_timestamp
134+
Time.current.utc.strftime('%Y%m%dT%H%M%SZ')
135+
end
136+
137+
def ics_start_time
138+
starts_at&.utc&.strftime('%Y%m%dT%H%M%SZ')
139+
end
140+
141+
def ics_end_time
142+
ends_at&.utc&.strftime('%Y%m%dT%H%M%SZ')
143+
end
144+
145+
def ics_description_present?
146+
respond_to?(:description) && description
147+
end
148+
149+
def ics_description_line
150+
desc_text = ActionView::Base.full_sanitizer.sanitize(description.to_plain_text)
151+
desc_text += "\n\n#{I18n.t('better_together.events.ics.view_details_url', url: url)}"
152+
"DESCRIPTION:#{desc_text}"
153+
end
154+
82155
def ends_at_after_starts_at
83156
return if ends_at.blank? || starts_at.blank?
84157
return if ends_at > starts_at
85158

86159
errors.add(:ends_at, I18n.t('errors.models.ends_at_before_starts_at'))
87160
end
161+
162+
# Public URL to this event for use in ICS export
163+
def url
164+
BetterTogether::Engine.routes.url_helpers.event_url(self, locale: I18n.locale)
165+
end
88166
end
167+
# rubocop:enable Metrics/ClassLength
89168
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# Tracks a person's RSVP to an event
5+
class EventAttendance < ApplicationRecord
6+
STATUS = {
7+
interested: 'interested',
8+
going: 'going'
9+
}.freeze
10+
11+
belongs_to :event, class_name: 'BetterTogether::Event'
12+
belongs_to :person, class_name: 'BetterTogether::Person'
13+
14+
validates :status, inclusion: { in: STATUS.values }
15+
validates :event_id, uniqueness: { scope: :person_id }
16+
end
17+
end
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+
module BetterTogether
4+
# Access control for event attendance (RSVPs)
5+
class EventAttendancePolicy < ApplicationPolicy
6+
def create?
7+
user.present?
8+
end
9+
10+
def update?
11+
user.present? && record.person_id == agent&.id
12+
end
13+
14+
alias rsvp_interested? update?
15+
alias rsvp_going? update?
16+
17+
def destroy?
18+
update?
19+
end
20+
end
21+
end

app/policies/better_together/event_policy.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def show?
1111
record.privacy_public? || creator_or_manager
1212
end
1313

14+
alias ics? show?
15+
1416
def update?
1517
creator_or_manager
1618
end

app/views/better_together/events/show.html.erb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
destroy_path: policy(@resource).destroy? ? event_path(@resource) : nil,
3636
destroy_confirm: t('globals.confirm_delete'),
3737
destroy_aria_label: 'Delete Record' %>
38+
<div class="mt-2 text-end">
39+
<%= link_to t('better_together.events.add_to_calendar', default: 'Add to calendar (.ics)'), ics_event_path(@event), class: 'btn btn-outline-secondary btn-sm' %>
40+
</div>
3841
</div>
3942
</div>
4043
</div>
@@ -43,6 +46,27 @@
4346
<!-- Membership section -->
4447
<hr aria-hidden="true">
4548

49+
<!-- RSVP Actions -->
50+
<% if current_person %>
51+
<% attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person) %>
52+
<div class="container my-3">
53+
<div class="d-flex gap-2">
54+
<%= button_to t('better_together.events.rsvp_interested', default: 'Interested'), rsvp_interested_event_path(@event), method: :post, class: "btn btn-outline-primary #{'active' if attendance&.status == 'interested'}" %>
55+
<%= button_to t('better_together.events.rsvp_going', default: 'Going'), rsvp_going_event_path(@event), method: :post, class: "btn btn-primary #{'active' if attendance&.status == 'going'}" %>
56+
<% if attendance %>
57+
<%= button_to t('better_together.events.rsvp_cancel', default: 'Cancel RSVP'), rsvp_cancel_event_path(@event), method: :delete, class: 'btn btn-outline-danger' %>
58+
<% end %>
59+
</div>
60+
<div class="text-muted mt-2">
61+
<% going_count = BetterTogether::EventAttendance.where(event: @event, status: 'going').count %>
62+
<% interested_count = BetterTogether::EventAttendance.where(event: @event, status: 'interested').count %>
63+
<small>
64+
<%= t('better_together.events.rsvp_counts', default: 'Going: %{going} · Interested: %{interested}', going: going_count, interested: interested_count) %>
65+
</small>
66+
</div>
67+
</div>
68+
<% end %>
69+
4670
<!-- Event tabbed section with members -->
4771
<section class="card tabbed-section">
4872
<div class="card-header">
@@ -94,4 +118,4 @@
94118
</div>
95119

96120
<%= share_buttons(shareable: @event) if @event.privacy_public? %>
97-
</div>
121+
</div>

0 commit comments

Comments
 (0)