Skip to content

Commit

Permalink
[feature] Calendar and icalendar meetings
Browse files Browse the repository at this point in the history
# 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
donrestarone authored Sep 14, 2023
1 parent 865914b commit a1a6551
Show file tree
Hide file tree
Showing 27 changed files with 1,510 additions and 7 deletions.
5 changes: 4 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,7 @@ gem 'devise-two-factor', "4.0.2"

gem "slowpoke"

gem "strong_migrations"
gem "strong_migrations"
gem "simple_calendar", "~> 3.0"

gem "icalendar", "~> 2.9"
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ GEM
multi_xml (>= 0.5.2)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
icalendar (2.9.0)
ice_cube (~> 0.16)
ice_cube (0.16.4)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
Expand Down Expand Up @@ -417,6 +420,8 @@ GEM
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
simple_calendar (3.0.2)
rails (>= 6.1)
simplecov (0.21.2)
docile (~> 1.1)
simplecov-html (~> 0.11)
Expand Down Expand Up @@ -535,6 +540,7 @@ DEPENDENCIES
gravatar_image_tag
groupdate
httparty
icalendar (~> 2.9)
image_processing (~> 1.12)
jbuilder (~> 2.7)
jsonapi-serializer
Expand All @@ -559,6 +565,7 @@ DEPENDENCIES
ros-apartment-sidekiq
sass-rails (>= 6)
selenium-webdriver
simple_calendar (~> 3.0)
simple_discussion!
simplecov
sinatra
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@import 'select2/dist/css/select2.css';
@import './direct_upload.scss';
@import 'daterangepicker/daterangepicker';
@import "simple_calendar";

:root {
--color-primary: #6C5BF5;
Expand Down
23 changes: 23 additions & 0 deletions app/controllers/comfy/admin/calendars_controller.rb
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
142 changes: 142 additions & 0 deletions app/controllers/meetings_controller.rb
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
43 changes: 38 additions & 5 deletions app/mailboxes/e_mailbox.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ def process
subject: subject
)
end

uploaded_attachments = attachments
multipart_attachments = multipart_attached
message = Message.create!(
email_message_id: mail.message_id,
message_thread: message_thread,
content: body,
content: body(uploaded_attachments),
from: mail.from.join(', '),
attachments: (attachments + multipart_attached).map{ |a| a[:blob] }
attachments: (uploaded_attachments + multipart_attachments).map{ |a| a[:blob] }
)
message_thread.update(unread: true)
process_attachments(uploaded_attachments) if uploaded_attachments.size > 0
ApiNamespace::Plugin::V1::SubdomainEventsService.new(message).track_event
end
end
Expand Down Expand Up @@ -60,10 +62,10 @@ def multipart_attached
return blobs
end

def body
def body(uploaded_blobs)
if mail.multipart? && mail.html_part
document = Nokogiri::HTML(mail.html_part.body.decoded)
attachments.map do |attachment_hash|
uploaded_blobs.map do |attachment_hash|
attachment = attachment_hash[:original]
blob = attachment_hash[:blob]
if attachment.content_id.present?
Expand Down Expand Up @@ -91,4 +93,35 @@ def sanitize_email_subject_prefixes(subject)
# examples: 'Re: ', 're: ', 'FWD: ', 'Fwd: ', 'Fw: '
subject.gsub(/^((re|fw(d)?): )/i, '')
end

def process_attachments(blobs)
# process .ics files
ics_files = blobs.select {|attachment| attachment[:original].content_type.include?('.ics') }
ics_files.each do |ics_file|
ics_string = ics_file[:blob].download
calendars = Icalendar::Calendar.parse(ics_string)
calendars.each do |calendar|
calendar.events.each do |event|
existing_meeting = Meeting.find_by(external_meeting_id: event.uid.to_s)
if existing_meeting
# handle replies to meeting invites
existing_meeting.update(updated_at: Time.now)
else
meeting = Meeting.create!(
name: event.summary,
external_meeting_id: event.uid,
start_time: event.dtstart,
end_time: event.dtend,
timezone: Array.wrap(event.dtstart.ical_params['tzid']).join('-'),
description: event.description,
participant_emails: event.attendee.map{|uri| uri.to },
location: event.location,
status: 'TENTATIVE',
custom_properties: event.custom_properties,
)
end
end
end
end
end
end
6 changes: 6 additions & 0 deletions app/models/meeting.rb
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
38 changes: 38 additions & 0 deletions app/views/comfy/admin/calendars/index.html.haml
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
1 change: 1 addition & 0 deletions app/views/comfy/admin/cms/partials/_navigation_inner.haml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
%li{class: 'nav-item', data: { turbo: 'true'}}
= active_link_to "Users", admin_users_path, class: 'nav-link'
= active_link_to "Email", mailbox_path, class: 'nav-link'
= active_link_to "Calendar", calendars_path, class: 'nav-link'
= active_link_to "API", api_namespaces_path, class: 'nav-link'
= active_link_to "App Settings", edit_web_settings_path, class: 'nav-link'
= active_link_to "Analytics", dashboard_path, class: 'nav-link'
Expand Down
41 changes: 41 additions & 0 deletions app/views/meetings/_form.html.haml
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: [',', ' '],
})
});
2 changes: 2 additions & 0 deletions app/views/meetings/_meeting.json.jbuilder
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)
7 changes: 7 additions & 0 deletions app/views/meetings/edit.html.haml
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
Loading

0 comments on commit a1a6551

Please sign in to comment.