-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[feature] Calendar and icalendar meetings
# Calendar and Meetings addresses: #1597 This release adds the ability for Violet to handle your calendar. Incoming emails with .ics attachments will automatically be added to your calendar as meetings. ## Calendar UI ![Screenshot from 2023-09-14 00-25-19](https://github.com/restarone/violet_rails/assets/35935196/d6ab2626-85e1-49d3-83d3-f2aadfc34eeb) ## Outgoing Meeting request RSVP controls ![IMG_7264](https://github.com/restarone/violet_rails/assets/35935196/bae81b61-32e4-4970-a9b8-60b787366f11) todo: 1. include .vcs file for outlook 2. add validations to meeting model further reading: 1. icalendar syncing events: https://joshfrankel.me/blog/lemme-pencil-you-in-using-icalendar-and-rails-to-sync-calendar-events/ 2. all the options: https://blog.corsego.com/icalendar-ruby 3. publish? https://stackoverflow.com/questions/55927263/what-does-icalendar-publish-method-do 4. dealing with email client quirkyness when displaying RSVP buttons: https://stackoverflow.com/questions/66102584/when-i-add-method-request-to-icalendar-gmail-stops-recognizing-as-event
- Loading branch information
1 parent
865914b
commit a1a6551
Showing
27 changed files
with
1,510 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
class Comfy::Admin::CalendarsController < Comfy::Admin::Cms::BaseController | ||
layout "comfy/admin/cms" | ||
before_action :check_email_authorization | ||
def new | ||
|
||
end | ||
|
||
def index | ||
# Scope your query to the dates being shown: | ||
start_date = params.fetch(:start_date, Date.today).to_date | ||
@meetings = Meeting.where(start_time: start_date.beginning_of_month.beginning_of_week..start_date.end_of_month.end_of_week) | ||
end | ||
|
||
|
||
private | ||
|
||
def check_email_authorization | ||
unless current_user.can_manage_email | ||
flash.alert = 'You do not have permission to manage email' | ||
redirect_back(fallback_location: root_path) | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
class MeetingsController < Comfy::Admin::Cms::BaseController | ||
before_action :set_meeting, only: %i[ show edit update destroy ] | ||
before_action :check_email_authorization | ||
|
||
# GET /meetings or /meetings.json | ||
def index | ||
@meetings = Meeting.all | ||
end | ||
|
||
# GET /meetings/1 or /meetings/1.json | ||
def show | ||
end | ||
|
||
# GET /meetings/new | ||
def new | ||
@meeting = Meeting.new | ||
end | ||
|
||
# GET /meetings/1/edit | ||
def edit | ||
end | ||
|
||
# POST /meetings or /meetings.json | ||
def create | ||
@meeting = Meeting.new(meeting_params) | ||
@meeting.external_meeting_id = "#{SecureRandom.uuid}.#{Apartment::Tenant.current}@#{ENV['APP_HOST']}" | ||
@meeting.status = 'CONFIRMED' | ||
@meeting.participant_emails = meeting_params[:participant_emails].filter{ |node| URI::MailTo::EMAIL_REGEXP.match?(node) } | ||
|
||
respond_to do |format| | ||
if @meeting.save | ||
# send .ics file to participants | ||
cal = Icalendar::Calendar.new | ||
filename = "Invitation: #{@meeting.name}" | ||
from_address = "#{Apartment::Tenant.current}@#{ENV['APP_HOST']}" | ||
# to generate outlook | ||
if false == 'vcs' | ||
cal.prodid = '-//Microsoft Corporation//Outlook MIMEDIR//EN' | ||
cal.version = '1.0' | ||
filename += '.vcs' | ||
else # ical | ||
cal.prodid = '-//Restarone Solutions, Inc.//NONSGML ExportToCalendar//EN' | ||
cal.version = '2.0' | ||
filename += '.ics' | ||
end | ||
cal.append_custom_property('METHOD', 'REQUEST') | ||
cal.event do |e| | ||
e.dtstart = Icalendar::Values::DateTime.new(@meeting.start_time, tzid: @meeting.timezone) | ||
e.dtend = Icalendar::Values::DateTime.new(@meeting.end_time, tzid: @meeting.timezone) | ||
e.organizer = Icalendar::Values::CalAddress.new("mailto:#{from_address}", cn: from_address) | ||
e.attendee = Icalendar::Values::CalAddress.new("mailto:#{from_address}", partstat: 'ACCEPTED') | ||
e.uid = @meeting.external_meeting_id | ||
@meeting.participant_emails.each do |email| | ||
attendee_params = { | ||
"CUTYPE" => "INDIVIDUAL", | ||
"ROLE" => "REQ-PARTICIPANT", | ||
"PARTSTAT" => "NEEDS-ACTION", | ||
"RSVP" => "TRUE", | ||
} | ||
|
||
attendee_value = Icalendar::Values::Text.new("MAILTO:#{email}", attendee_params) | ||
cal.append_custom_property("ATTENDEE", attendee_value) | ||
end | ||
e.description = @meeting.description | ||
e.location = @meeting.location | ||
e.sequence = Time.now.to_i | ||
e.status = "CONFIRMED" | ||
e.summary = @meeting.name | ||
|
||
|
||
e.alarm do |a| | ||
a.summary = "#{@meeting.name} starts in 30 minutes!" | ||
a.trigger = '-PT30M' | ||
end | ||
end | ||
file = cal.to_ical | ||
attachment = { filename: filename, mime_type: "text/calendar;method=REQUEST;name=\'#{filename}\'", content: file } | ||
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(attachment[:content]), filename: attachment[:filename], content_type: attachment[:mime_type], metadata: nil) | ||
email_thread = MessageThread.create!(recipients: @meeting.participant_emails, subject: "Invitation: #{@meeting.name}") | ||
email_content = <<-HTML | ||
<div> | ||
<p>You have been invited to the following meeting, please see details below<br><br> | ||
</p> | ||
</div> | ||
HTML | ||
email_content += ActionText::Content.new("<action-text-attachment sgid='#{blob.attachable_sgid}'></action-text-attachment>").to_s | ||
email_message = email_thread.messages.create!( | ||
content: email_content.html_safe, | ||
from: from_address | ||
) | ||
EMailer.with(message: email_message, message_thread: email_thread, attachments: [attachment]).ship.deliver_later | ||
|
||
format.html { redirect_to @meeting, notice: "Meeting was successfully created." } | ||
format.json { render :show, status: :created, location: @meeting } | ||
else | ||
format.html { render :new, status: :unprocessable_entity } | ||
format.json { render json: @meeting.errors, status: :unprocessable_entity } | ||
end | ||
end | ||
end | ||
|
||
# PATCH/PUT /meetings/1 or /meetings/1.json | ||
def update | ||
respond_to do |format| | ||
if @meeting.update(meeting_params) | ||
format.html { redirect_to @meeting, notice: "Meeting was successfully updated." } | ||
format.json { render :show, status: :ok, location: @meeting } | ||
else | ||
format.html { render :edit, status: :unprocessable_entity } | ||
format.json { render json: @meeting.errors, status: :unprocessable_entity } | ||
end | ||
end | ||
end | ||
|
||
# DELETE /meetings/1 or /meetings/1.json | ||
def destroy | ||
@meeting.destroy | ||
respond_to do |format| | ||
format.html { redirect_to meetings_url, notice: "Meeting was successfully destroyed." } | ||
format.json { head :no_content } | ||
end | ||
end | ||
|
||
private | ||
# Use callbacks to share common setup or constraints between actions. | ||
def set_meeting | ||
@meeting = Meeting.find(params[:id]) | ||
end | ||
|
||
# Only allow a list of trusted parameters through. | ||
def meeting_params | ||
params.require(:meeting).permit(:name, :start_time, :end_time, :description, :timezone, :location, participant_emails: []) | ||
end | ||
|
||
def check_email_authorization | ||
unless current_user.can_manage_email | ||
flash.alert = 'You do not have permission to manage email' | ||
redirect_back(fallback_location: root_path) | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
class Meeting < ApplicationRecord | ||
STATUS_LIST = ['TENTATIVE', 'CONFIRMED', 'CANCELLED'] | ||
# https://www.kanzaki.com/docs/ical/status.html | ||
|
||
validates :status, inclusion: { in: STATUS_LIST } | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
.page-header | ||
.h2 | ||
Calendar | ||
%p Early access, feature under active development | ||
|
||
|
||
= month_calendar(events: @meetings, attribute: :start_time) do |date, meetings| | ||
= date | ||
- meetings.each do |meeting| | ||
%div | ||
= meeting.name | ||
|
||
.h2 Listing meetings | ||
|
||
%table.table | ||
%thead | ||
%tr | ||
%th Name | ||
%th Start time | ||
%th Participant emails | ||
%th Status | ||
%th | ||
%th | ||
|
||
%tbody | ||
- @meetings.each do |meeting| | ||
%tr | ||
%td | ||
= link_to meeting.name, meeting | ||
%td= meeting.start_time | ||
%td= meeting.participant_emails | ||
%td= meeting.status | ||
%td= link_to 'Edit', edit_meeting_path(meeting) | ||
%td= link_to 'Destroy', meeting, method: :delete, data: { confirm: 'Are you sure?' } | ||
|
||
%br | ||
|
||
= link_to 'New Meeting', new_meeting_path |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
= form_for @meeting do |f| | ||
- if @meeting.errors.any? | ||
#error_explanation | ||
%h2= "#{pluralize(@meeting.errors.count, "error")} prohibited this meeting from being saved:" | ||
%ul | ||
- @meeting.errors.full_messages.each do |message| | ||
%li= message | ||
|
||
.field | ||
= f.label :name | ||
= f.text_field :name | ||
.field | ||
= f.label :start_time | ||
= f.datetime_select :start_time | ||
.field | ||
= f.label :end_time | ||
= f.datetime_select :end_time | ||
.field | ||
= f.label :participant_emails | ||
= f.select :participant_emails, options_for_select([]), { include_blank: false }, { multiple: true } | ||
.field | ||
= f.label :description | ||
= f.text_area :description | ||
.field | ||
= f.label :timezone | ||
= f.select :timezone, ActiveSupport::TimeZone::MAPPING | ||
.field | ||
= f.label :location | ||
= f.text_field :location | ||
.actions | ||
= f.submit 'Save' | ||
|
||
:javascript | ||
$(document).ready( function() { | ||
$("#meeting_participant_emails").select2({ | ||
multiple: true, | ||
required: true, | ||
tags: true, | ||
tokenSeparators: [',', ' '], | ||
}) | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
json.extract! meeting, :id, :name, :start_time, :end_time, :participant_emails, :description, :timezone, :location, :status, :external_meeting_id, :created_at, :updated_at | ||
json.url meeting_url(meeting, format: :json) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
%h1 Editing meeting | ||
|
||
= render 'form' | ||
|
||
= link_to 'Show', @meeting | ||
\| | ||
= link_to 'Back', meetings_path |
Oops, something went wrong.