Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/controllers/api/event_preferences_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ def set_preferenceable
id = params[:google_calendar_event_id]
scope = GoogleCalendarEvent.eager_load(meeting_time: { course: :faculties })
@preferenceable = id.to_s.include?("_") ? scope.find_by_public_id!(id) : scope.find(id)
# A GoogleCalendarEvent belongs to a specific user's calendar; enforce
# ownership here so another user's event id can't leak preference data.
authorize @preferenceable, :show?
else
render json: { error: "Preferenceable not specified" }, status: :bad_request
end
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/api/university_calendar_events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class UniversityCalendarEventsController < ApiController

def index
authorize UniversityCalendarEvent, :index?
@events = policy_scope(UniversityCalendarEvent).upcoming.order(:start_time)
@events = policy_scope(UniversityCalendarEvent).includes(:term).upcoming.order(:start_time)

@events = @events.where(category: params[:category]) if params[:category].present?

Expand Down Expand Up @@ -52,7 +52,7 @@ def categories

def holidays
authorize UniversityCalendarEvent, :index?
@holidays = UniversityCalendarEvent.holidays.upcoming.order(:start_time)
@holidays = UniversityCalendarEvent.holidays.includes(:term).upcoming.order(:start_time)

if params[:term_id].present?
term = Term.find_by_public_id(params[:term_id])
Expand Down
73 changes: 55 additions & 18 deletions app/controllers/api/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,32 @@ class UsersController < ApiController
skip_before_action :authenticate_user_from_token!, only: [ :onboard ]

# POST /api/user/onboard
#
# The extension sends a Google OAuth access token for the user's (personal)
# Google account — the same account they sync calendars to. We verify it with
# Google and key the account to that verified email, so a caller can only ever
# reach the account tied to a Google identity they actually control (no
# domain restriction — personal accounts are the norm). See GoogleTokenVerifier.
def onboard
email = params[:email]
access_token = params[:google_access_token] || params[:access_token]
preferred_name = params[:preferred_name]

if email.blank?
render json: { error: "email is required" }, status: :bad_request
if access_token.blank?
render json: { error: "google_access_token is required" }, status: :bad_request
return
end

if preferred_name.blank?
render json: { error: "preferred_name is required" }, status: :bad_request
verification = GoogleTokenVerifier.verify_access_token(access_token)
unless verification.success?
Rails.logger.warn("Onboard token verification failed: #{verification.error}")
render json: { error: "Invalid Google token" }, status: :unauthorized
return
end

name_parts = preferred_name.strip.split(" ", 2)
first_name = name_parts[0]
last_name = name_parts[1]
google_email = verification.email
user = find_or_create_onboarding_user(google_email, preferred_name, params[:wit_email])

user = User.find_by(email: email.to_s.strip.downcase) ||
User.create!(
email: email.to_s.strip.downcase,
first_name: first_name,
last_name: last_name,
password: SecureRandom.hex(24)
)

token = JsonWebTokenService.encode({ user_id: user.id }, nil)
token = JsonWebTokenService.encode({ user_id: user.id })

render json: { pub_id: user.public_id.delete_prefix("usr_"), jwt: token }, status: :ok
rescue => e
Expand Down Expand Up @@ -240,7 +239,7 @@ def get_processed_events_by_term
.where(term_id: term.id)
.includes(course: [
:faculties,
{ meeting_times: [ :event_preference, { course: :faculties } ] }
{ meeting_times: [ :event_preference, { rooms: :building }, { course: :faculties } ] }
])

preference_resolver = PreferenceResolver.new(current_user)
Expand Down Expand Up @@ -358,5 +357,43 @@ def enable_notifications
Rails.logger.error("Error enabling notifications for user #{current_user.id}: #{e.message}")
render json: { error: "Failed to enable notifications" }, status: :internal_server_error
end

private

# Resolves the account for a verified Google email, in this order:
# 1. a user already keyed to that Google email
# 2. a user who has connected that Google account for calendar sync
# (oauth_credentials are OAuth-verified, so this link is trustworthy)
# 3. otherwise, a fresh account keyed to the Google email
#
# Note: we deliberately do NOT link by a client-supplied WIT email — that
# value isn't verified, so trusting it would let a caller attach their token
# to someone else's existing account. Legacy accounts that never connected a
# Google account can't be auto-linked safely and start fresh.
def find_or_create_onboarding_user(google_email, preferred_name, wit_email = nil)
wit_email = wit_email.to_s.strip.downcase.presence

existing = User.find_by(email: google_email) ||
User.joins(:oauth_credentials)
.find_by(oauth_credentials: { email: google_email, provider: "google" })
if existing
# Record the WIT email as metadata on the caller's OWN account (resolved
# from the verified token). It's never used to decide which account this
# is, so a forged value can only mislabel the caller's own record.
if wit_email && existing.wit_email != wit_email
existing.update_column(:wit_email, wit_email) # rubocop:disable Rails/SkipsModelValidations
end
return existing
end

first_name, last_name = preferred_name.to_s.strip.split(" ", 2)
User.create!(
email: google_email,
wit_email: wit_email,
first_name: first_name,
last_name: last_name,
password: SecureRandom.hex(24)
)
end
end
end
4 changes: 3 additions & 1 deletion app/controllers/calendars_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ def generate_ical(courses, final_exams)
rule = IceCube::Rule.weekly.day(day_sym).until(until_time)
e.rrule = rule.to_ical.gsub(/UNTIL=(\d{8})T\d{6}Z?/, 'UNTIL=\1')
else
until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59)
# End-of-day in local (Eastern) zone as UTC, so evening classes keep
# their final occurrence instead of it falling past a bare-UTC UNTIL.
until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc
rule = IceCube::Rule.weekly.day(day_sym).until(until_time)
e.rrule = rule.to_ical
end
Expand Down
62 changes: 0 additions & 62 deletions app/controllers/users/registrations_controller.rb

This file was deleted.

6 changes: 5 additions & 1 deletion app/jobs/google_calendar_create_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def perform(user_id)
end
end

user.sync_course_schedule(force: true)
# Route the follow-up sync through GoogleCalendarSyncJob rather than syncing
# inline: that job carries limits_concurrency keyed per user, so a
# creation-triggered sync can't race a concurrent sync and double-create
# remote events.
GoogleCalendarSyncJob.perform_later(user, force: true)
end
end
15 changes: 15 additions & 0 deletions app/jobs/google_calendar_event_delete_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

# Deletes a single event from a Google Calendar. Enqueued when a
# GoogleCalendarEvent DB row is destroyed (e.g. its meeting time was removed
# during reconcile) so the live Google event is removed too, rather than left
# behind as an untracked orphan.
class GoogleCalendarEventDeleteJob < ApplicationJob
queue_as :high

def perform(calendar_id, google_event_id)
return if calendar_id.blank? || google_event_id.blank?

GoogleCalendarService.new.delete_calendar_event(calendar_id, google_event_id)
end
end
15 changes: 13 additions & 2 deletions app/models/calendar_preference.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
#
# Indexes
#
# index_calendar_preferences_on_user_id (user_id)
# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE
# index_calendar_preferences_on_user_id (user_id)
# index_calendar_prefs_on_user_scope_type (user_id,scope,event_type) UNIQUE
# index_calendar_prefs_one_global_per_user (user_id) UNIQUE WHERE (scope = 0)
#
# Foreign Keys
#
Expand All @@ -41,6 +42,10 @@ class CalendarPreference < ApplicationRecord
validates :scope, presence: true
validates :event_type, presence: true, if: -> { scope_event_type? || scope_uni_cal_category? }
validates :event_type, absence: true, if: :scope_global?
# Exactly one global preference per user. The DB unique index on
# (user_id, scope, event_type) can't enforce this because event_type is NULL
# for global rows and Postgres treats NULLs as distinct.
validate :only_one_global_preference, if: :scope_global?
validates :event_type, inclusion: { in: UNI_CAL_CATEGORIES }, if: :scope_uni_cal_category?
validates :title_template, length: { maximum: 500 }, allow_blank: true
validates :description_template, length: { maximum: 2000 }, allow_blank: true
Expand All @@ -57,6 +62,12 @@ class CalendarPreference < ApplicationRecord

private

def only_one_global_preference
existing = CalendarPreference.where(user_id: user_id, scope: :global)
existing = existing.where.not(id: id) if persisted?
errors.add(:base, "a global preference already exists for this user") if existing.exists?
end

def validate_template_syntax
[ :title_template, :description_template, :location_template ].each do |field|
value = send(field)
Expand Down
38 changes: 29 additions & 9 deletions app/models/concerns/course_change_trackable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,28 @@ def self.with_enrollment_cache
included do
# Mark all enrolled users' calendars as needing sync when course details change
after_save :mark_enrolled_users_for_sync, if: :saved_change_to_relevant_attributes?
after_destroy :mark_enrolled_users_for_sync

# On destroy, the enrollments are removed by a dependent-destroy cascade
# (a before_destroy callback), so by the time an after_destroy hook runs the
# enrollment query returns nobody. Capture the affected users BEFORE the
# cascade (prepend), then flag them once the destroy commits.
before_destroy :capture_enrolled_users_for_sync, prepend: true
after_commit :flag_captured_enrolled_users, on: :destroy
end

private

def capture_enrolled_users_for_sync
@captured_enrolled_user_ids = enrolled_google_user_ids
end

def flag_captured_enrolled_users
ids = @captured_enrolled_user_ids
return if ids.blank?

User.where(id: ids).update_all(calendar_needs_sync: true) # rubocop:disable Rails/SkipsModelValidations
end

def saved_change_to_relevant_attributes?
# Track changes to any attributes that affect calendar display
relevant_attrs = %w[title start_date end_date subject course_number section_number]
Expand All @@ -40,14 +57,17 @@ def mark_enrolled_users_for_sync
end
return unless has_enrollments

user_ids = User.joins(:enrollments)
.joins(:oauth_credentials)
.where(enrollments: { course_id: id })
.where(oauth_credentials: { provider: "google" })
.where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL")
.distinct
.pluck(:id)

user_ids = enrolled_google_user_ids
User.where(id: user_ids).update_all(calendar_needs_sync: true) if user_ids.any? # rubocop:disable Rails/SkipsModelValidations
end

def enrolled_google_user_ids
User.joins(:enrollments)
.joins(:oauth_credentials)
.where(enrollments: { course_id: id })
.where(oauth_credentials: { provider: "google" })
.where("oauth_credentials.metadata->>'course_calendar_id' IS NOT NULL")
.distinct
.pluck(:id)
end
end
9 changes: 6 additions & 3 deletions app/models/concerns/course_schedule_syncable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,10 @@ def build_recurrence_rule(meeting_time)
end

# Build weekly recurrence rule using ice_cube and export to iCalendar format.
# UNTIL is set to end-of-day UTC so the last occurrence is fully included.
until_time = Time.utc(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59)
# UNTIL must be end-of-day in the *local* zone expressed as UTC. Using bare
# Time.utc(...,23,59,59) drops the final occurrence of evening (Eastern)
# classes, whose start instant falls after midnight UTC.
until_time = Time.zone.local(recurrence_end.year, recurrence_end.month, recurrence_end.day, 23, 59, 59).utc
rule = IceCube::Rule.weekly.day(meeting_time.day_of_week.to_sym).until(until_time)
"RRULE:#{rule.to_ical}"
end
Expand Down Expand Up @@ -645,7 +647,8 @@ def university_event_scope(time_scope)
def tbd_room?(room)
return false unless room

room.number == 0
# Room#number is a string column, so compare against the string "0".
room.number.to_s == "0"
# Note: Room model in production only has 'number', not 'name'
# If room.name is added later, uncomment these lines:
# room.name&.downcase&.include?("tbd") ||
Expand Down
12 changes: 10 additions & 2 deletions app/models/concerns/meeting_time_change_trackable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@ def self.with_enrollment_cache
after_destroy :mark_enrolled_users_for_sync
end

# Public entry point for related records (e.g. the room join table) to flag
# this meeting time's enrolled users for a calendar resync.
def resync_enrolled_users!
mark_enrolled_users_for_sync
end

private

def saved_change_to_relevant_attributes?
# Track changes to any attributes that affect calendar display
relevant_attrs = %w[begin_time end_time day_of_week start_date end_date room_id]
# Track changes to any attributes that affect calendar display.
# NOTE: rooms live in the course_meeting_time_rooms join table, not a
# room_id column here — room changes are flagged from that model instead.
relevant_attrs = %w[begin_time end_time day_of_week start_date end_date]
relevant_attrs.any? { |attr| saved_change_to_attribute?(attr) }
end

Expand Down
11 changes: 11 additions & 0 deletions app/models/course/meeting_time_room.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,15 @@ class Course::MeetingTimeRoom < ApplicationRecord

belongs_to :meeting_time, class_name: "Course::MeetingTime"
belongs_to :room

# A room being added or removed changes the calendar event location, so flag
# the meeting time's enrolled users for resync (the meeting_time row itself
# doesn't change, so its own callbacks won't fire).
after_commit :resync_enrolled_users, on: [ :create, :destroy ]

private

def resync_enrolled_users
meeting_time&.resync_enrolled_users!
end
end
1 change: 1 addition & 0 deletions app/models/friendship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# index_friendships_on_requester_id (requester_id)
# index_friendships_on_requester_id_and_addressee_id (requester_id,addressee_id) UNIQUE
# index_friendships_on_requester_id_and_status (requester_id,status)
# index_friendships_on_unordered_pair (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)) UNIQUE
#
# Foreign Keys
#
Expand Down
Loading
Loading