diff --git a/CLAUDE.md b/CLAUDE.md index 6d3d844b..3475b1eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,152 @@ -- annotate models with the bundle exec annotaterb models command after database migrations -- annotate models with the bundle exec annotaterb routes command after anything that makes changes to routes -- for anything that blocks threads, prefer using an active job -- I document systems and things in the /docs/ folder \ No newline at end of file +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Running the Application +- `bin/dev` - Start web server, background jobs (Solid Queue), and CSS watcher +- `bin/rails server` - Start web server only +- `bin/rails solid_queue:start` - Start background job worker only +- `bin/rails tailwindcss:watch` - Start CSS watcher only +- `bin/rails console` - Start Rails console for debugging + +### Database Operations +- `rails db:create` - Create database +- `rails db:migrate` - Run pending migrations +- `rails db:seed` - Seed database with initial data +- `rails db:reset` - Drop, create, migrate, and seed database +- `bundle exec annotaterb models` - **REQUIRED after migrations** - Annotate model files with schema information +- `bundle exec annotaterb routes` - **REQUIRED after route changes** - Annotate routes.rb with route map + +### Testing +- `bundle exec rspec` - Run all tests +- `bundle exec rspec spec/path/to/file_spec.rb` - Run specific test file +- `bundle exec rspec spec/path/to/file_spec.rb:42` - Run specific test at line 42 +- **IMPORTANT: Maintain full RSpec test coverage for all code** + +### Code Quality +- `bundle exec rubocop` - Run linter +- `bundle exec rubocop -a` - Run linter with auto-fix +- `bundle exec brakeman` - Run security vulnerability scanner + +## Architecture Overview + +### Core Purpose +A Rails 8 API backend that syncs college course schedules to Google Calendar with intelligent change detection, user preferences, and customizable calendar templates. + +### Key Architectural Patterns + +**Multi-Email OAuth Architecture** +- Users can connect multiple Google accounts (school email, personal email, etc.) +- Single WIT Courses calendar is created via service account and shared with all user's connected emails +- Service account manages calendar operations; user OAuth credentials add calendar to their sidebar +- See `app/models/concerns/google_oauthable.rb` and `docs/calendar-sync/multi-email-google-calendar-oauth.md` + +**Intelligent Calendar Sync** +- Hash-based change detection prevents unnecessary Google Calendar API calls +- Respects user edits made directly in Google Calendar (compares DB state with Google Calendar state) +- Three-tier preference system: Individual Event > Event Type (lecture/lab/hybrid) > Global > System Defaults +- See `app/services/google_calendar_service.rb` and `docs/calendar-sync/intelligent_calendar_sync.md` + +**Background Job Processing** +- Use ActiveJob with Solid Queue (database-backed) for async operations +- **IMPORTANT: For anything that blocks threads, prefer using an ActiveJob** +- Common jobs: `GoogleCalendarSyncJob`, `CourseProcessorJob`, `NightlyCalendarSyncJob` +- Monitor jobs at `/admin/jobs` (Mission Control) + +**Calendar Preferences System** +- Liquid templating for customizable event titles/descriptions (17 template variables available) +- Per-event-type or per-event customization (color, reminders, visibility, templates) +- Validated templates prevent breaking changes +- See `docs/calendar-preferences/` for complete documentation + +**Security & Encryption** +- OAuth tokens encrypted with Lockbox +- Rate limiting with Rack::Attack +- Audit logging with Audits1984 and Console1984 +- Admin access via Google OAuth + access_level enum (user/admin/super_admin/owner) + +### Domain Models + +**User & Authentication** +- `User` - Core user model with access levels, uses email-based identification +- `Email` - Multiple emails per user (primary flag), enables multi-account OAuth +- `OauthCredential` - Encrypted OAuth tokens for Google Calendar (supports multiple credentials per user) +- Users found via `User.find_by_email(email_address)` or created via `User.find_or_create_by_email(email_address)` + +**Course & Schedule Data** +- `Course` - Course information with pgvector embeddings for semantic search +- `MeetingTime` - Individual class sessions with recurrence rules and event colors +- `Faculty` - Instructor information with Rate My Professor integration +- `Term` - Academic terms (Fall 2024, Spring 2025, etc.) +- `Building`, `Room` - Location data + +**Calendar Sync** +- `GoogleCalendar` - Tracks the WIT Courses calendar (one per user's primary OAuth credential) +- `GoogleCalendarEvent` - Tracks individual events with hash-based change detection +- `CalendarPreference` - User preferences (global or per event type) +- `EventPreference` - Individual event overrides + +### Service Layer +- `GoogleCalendarService` - All Google Calendar API operations (uses service account) +- `CalendarTemplateRenderer` - Liquid template rendering for event titles/descriptions +- `PreferenceResolver` - Resolves preference hierarchy for events +- `LeopardWebService` - Scrapes course data from college system +- `RateMyProfessorService` - Fetches professor ratings +- `JsonWebTokenService` - JWT authentication for API endpoints + +### API Authentication +- JWT-based authentication for all `/api/*` endpoints +- Include `Authorization: Bearer ` header +- See `app/controllers/concerns/json_web_token_authenticatable.rb` + +### Feature Flags +- Flipper for feature toggling +- Beta tester management at `/admin/beta_testers` +- Check features with `Features.enabled?(:feature_name, user)` +- See `app/lib/features.rb` + +## Documentation +- **IMPORTANT: I document systems and things in the `/docs/` folder** +- See `docs/README.md` for complete documentation map +- Quick reference: `docs/QUICK_REFERENCE.md` +- When building new features, update relevant docs + +## Important Development Notes + +1. **Always annotate after migrations**: Run `bundle exec annotaterb models` after database changes +2. **Always annotate after route changes**: Run `bundle exec annotaterb routes` after modifying routes +3. **Prefer background jobs**: Use ActiveJob for blocking operations (Google Calendar API calls, web scraping, etc.) +4. **Full test coverage required**: Write RSpec tests for all new code +5. **Respect user edits**: The calendar sync intelligently detects and preserves user modifications in Google Calendar +6. **Template validation**: Calendar preference templates are validated; invalid syntax is rejected +7. **OAuth token encryption**: All sensitive tokens are encrypted with Lockbox +8. **Multi-email support**: Design features to support users with multiple connected Google accounts + +## Common Development Workflows + +### Adding a New Background Job +1. Create job in `app/jobs/` +2. Enqueue with `MyJob.perform_later(args)` +3. Add specs in `spec/jobs/` +4. See `docs/infrastructure/job-queues.md` + +### Modifying Calendar Sync Logic +1. Update `GoogleCalendarService` methods +2. Consider hash-based change detection impact +3. Respect user edits detection logic +4. Add tests for new sync scenarios +5. See `docs/calendar-sync/intelligent_calendar_sync.md` + +### Adding Template Variables +1. Update `CalendarTemplateRenderer.build_context_from_meeting_time` +2. Document in `docs/calendar-preferences/template_variables.md` +3. Add validation tests +4. Update preview endpoint tests + +### Running Tests for Specific Features +- Calendar preferences: `bundle exec rspec spec/models/calendar_preference_spec.rb` +- Template rendering: `bundle exec rspec spec/services/calendar_template_renderer_spec.rb` +- Preference resolution: `bundle exec rspec spec/services/preference_resolver_spec.rb` +- Google Calendar sync: `bundle exec rspec spec/services/google_calendar_service_spec.rb` diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index 969ca213..702cef22 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -11,9 +11,19 @@ class ApplicationController < ::ApplicationController # Shared admin logic here def index @current_user = current_user + @users_count = User.count @admin_count = User.where(access_level: [:admin, :super_admin, :owner]).count - @active_sessions_count = 1 # Placeholder - update with actual session tracking if available + + @buildings_count = Building.count + @rooms_count = Room.count + @courses_count = Course.count + @faculties_count = Faculty.count + @terms_count = Term.count + @google_calendars_count = GoogleCalendar.count + @google_calendar_events_count = GoogleCalendarEvent.count + @rmp_ratings_count = RmpRating.count + end private diff --git a/app/controllers/api/courses_controller.rb b/app/controllers/api/courses_controller.rb index 4ec65372..dc28c5d4 100644 --- a/app/controllers/api/courses_controller.rb +++ b/app/controllers/api/courses_controller.rb @@ -97,7 +97,7 @@ def fetch_processed_courses(courses, user) .index_by(&:crn) - processed_data = unique_courses.map do |course_data| + unique_courses.map do |course_data| crn = (course_data[:crn] || course_data["crn"]).to_i course = courses_by_crn[crn] @@ -127,7 +127,7 @@ def fetch_processed_courses(courses, user) } end.compact - processed_data + end end diff --git a/app/controllers/api/misc_controller.rb b/app/controllers/api/misc_controller.rb new file mode 100644 index 00000000..7bf36588 --- /dev/null +++ b/app/controllers/api/misc_controller.rb @@ -0,0 +1,24 @@ +module Api + class MiscController < ApplicationController + + def get_current_terms + # returns the current term, and the next term + + current_term = Term.current + next_term = Term.next + + render json: { + current_term: { + name: current_term.name, + id: current_term.uid + }, + next_term: { + name: next_term.name, + id: next_term.uid + } + }, status: :ok + + end + + end +end \ No newline at end of file diff --git a/app/controllers/api/user_extension_config_controller.rb b/app/controllers/api/user_extension_config_controller.rb new file mode 100644 index 00000000..fcb3dd8d --- /dev/null +++ b/app/controllers/api/user_extension_config_controller.rb @@ -0,0 +1,43 @@ +module Api + class UserExtensionConfigController < ApplicationController + + def set + config = UserExtensionConfig.find_or_initialize_by(user_id: current_user.id) + + config.military_time = params[:military_time] unless params[:military_time].nil? + config.default_color_lecture = params[:default_color_lecture] unless params[:default_color_lecture].nil? + config.default_color_lab = params[:default_color_lab] unless params[:default_color_lab].nil? + + if config.save + render json: { message: "User extension config updated successfully" }, status: :ok + else + render json: { error: "Failed to update user extension config", details: config.errors.full_messages }, status: :unprocessable_entity + end + rescue => e + Rails.logger.error("Error updating user extension config for user #{current_user.id}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + render json: { error: "Failed to update user extension config" }, status: :internal_server_error + + end + + def get + config = current_user.user_extension_config + + if config.nil? + render json: { error: "User extension config not found" }, status: :not_found + return + end + + render json: { + military_time: config.military_time, + default_color_lecture: config.default_color_lecture, + default_color_lab: config.default_color_lab + }, status: :ok + rescue => e + Rails.logger.error("Error fetching user extension config for user #{current_user.id}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + render json: { error: "Failed to fetch user extension config" }, status: :internal_server_error + end + + end +end diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index ebcd0de6..ccb2145b 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -7,7 +7,7 @@ class UsersController < ApplicationController skip_before_action :verify_authenticity_token skip_before_action :authenticate_user_from_token!, only: [:onboard] - skip_before_action :check_beta_access, only: [:onboard] + skip_before_action :check_beta_access, only: [:onboard, :get_email] def onboard # takes email as it's one param @@ -36,6 +36,13 @@ def onboard end + def get_email + + email = current_user.primary_email + + render json: { email: email}, status: :ok + end + def add_email_to_g_cal email = params[:email] diff --git a/app/controllers/calendars_controller.rb b/app/controllers/calendars_controller.rb index 5a8a0a18..e06e23a4 100644 --- a/app/controllers/calendars_controller.rb +++ b/app/controllers/calendars_controller.rb @@ -29,6 +29,10 @@ def show def generate_ical(courses) require "icalendar" + # Initialize preference resolver and template renderer for this user + @preference_resolver = PreferenceResolver.new(@user) + @template_renderer = CalendarTemplateRenderer.new + cal = Icalendar::Calendar.new cal.prodid = "-//WITCC//Course Calendar//EN" cal.append_custom_property("X-WR-CALNAME", "WIT Course Schedule") @@ -76,8 +80,21 @@ def generate_ical(courses) e.dtstart = Icalendar::Values::DateTime.new(start_time) e.dtend = Icalendar::Values::DateTime.new(end_time) - # Course title with section - e.summary = course.title + # Resolve user preferences for this meeting time + prefs = @preference_resolver.resolve_for(meeting_time) + context = CalendarTemplateRenderer.build_context_from_meeting_time(meeting_time) + + # Apply title template (or fallback to course title) + if prefs[:title_template].present? + e.summary = @template_renderer.render(prefs[:title_template], context) + else + e.summary = course.title + end + + # Apply description template if set + if prefs[:description_template].present? + e.description = @template_renderer.render(prefs[:description_template], context) + end # Location if meeting_time.room && meeting_time.building @@ -93,14 +110,23 @@ def generate_ical(courses) # Stable UID for consistent event identity across refreshes e.uid = "course-#{course.crn}-meeting-#{meeting_time.id}@calendar-util.wit.edu" - e.color = "##{meeting_time.event_color}" if meeting_time.event_color.present? + # Use preference color if set, otherwise use meeting_time default + color_hex = if prefs[:color_id].present? + get_google_color_hex(prefs[:color_id]) + elsif meeting_time.event_color.present? + meeting_time.event_color + end + + if color_hex + e.color = "##{color_hex}" + end # Timestamps for change detection e.dtstamp = Icalendar::Values::DateTime.new(Time.current) - if meeting_time.event_color.present? - e.append_custom_property("X-APPLE-CALENDAR-COLOR", "##{meeting_time.event_color}") - e.append_custom_property("COLOR", meeting_time.event_color.to_s) + if color_hex + e.append_custom_property("X-APPLE-CALENDAR-COLOR", "##{color_hex}") + e.append_custom_property("COLOR", color_hex.to_s) end # Use the most recent update time between course and meeting_time @@ -164,4 +190,24 @@ def find_first_meeting_date(meeting_time) nil end + def get_google_color_hex(color_id) + # Map Google Calendar color IDs (1-11) to hex colors + # These match the Google Calendar color palette + color_map = { + 1 => 'A4BDFC', # Lavender + 2 => '7AE7BF', # Sage + 3 => 'DBADFF', # Grape + 4 => 'FF887C', # Flamingo + 5 => 'FBD75B', # Banana + 6 => 'FFB878', # Tangerine + 7 => '46D6DB', # Peacock + 8 => 'E1E1E1', # Graphite + 9 => '5484ED', # Blueberry + 10 => '51B749', # Basil + 11 => 'DC2127' # Tomato + } + + color_map[color_id] + end + end diff --git a/app/errors/invalid_term_error.rb b/app/errors/invalid_term_error.rb new file mode 100644 index 00000000..774c3453 --- /dev/null +++ b/app/errors/invalid_term_error.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Error raised when an invalid term UID is provided +class InvalidTermError < StandardError + attr_reader :uid + + def initialize(uid, message = nil) + @uid = uid + @message = message || "Invalid term UID: #{uid}. Term does not exist." + super(@message) + end +end diff --git a/app/helpers/api/user_extension_config_helper.rb b/app/helpers/api/user_extension_config_helper.rb new file mode 100644 index 00000000..b742d765 --- /dev/null +++ b/app/helpers/api/user_extension_config_helper.rb @@ -0,0 +1,2 @@ +module Api::UserExtensionConfigHelper +end diff --git a/app/jobs/ensure_future_terms_job.rb b/app/jobs/ensure_future_terms_job.rb new file mode 100644 index 00000000..405c1fbf --- /dev/null +++ b/app/jobs/ensure_future_terms_job.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class EnsureFutureTermsJob < ApplicationJob + queue_as :default + + # Ensures current term and N terms ahead exist (default: 2 terms in the future) + def perform(terms_ahead: 2) + # Determine current term + today = Date.today + current_year = today.year + current_season = if today.month >= 8 + :fall + elsif today.month >= 6 + :summer + else + :spring + end + + # Generate list of terms to ensure (current + terms_ahead) + terms_to_ensure = [] + year = current_year + season = current_season + + # Add current term + future terms + (terms_ahead + 1).times do + terms_to_ensure << { year: year, season: season } + + # Calculate next term + case season + when :spring + season = :summer + when :summer + season = :fall + when :fall + season = :spring + year += 1 + end + end + + # Create missing terms + terms_to_ensure.each do |term_attrs| + next if Term.exists?(year: term_attrs[:year], season: term_attrs[:season]) + + # Generate UID based on term pattern: + # Fall [year] โ†’ [year+1]10 + # Spring [year] โ†’ [year]20 + # Summer [year] โ†’ [year]30 + uid = case term_attrs[:season].to_sym + when :fall + (term_attrs[:year] + 1) * 100 + 10 + when :spring + term_attrs[:year] * 100 + 20 + when :summer + term_attrs[:year] * 100 + 30 + end + + Term.create!( + year: term_attrs[:year], + season: term_attrs[:season], + uid: uid + ) + + Rails.logger.info "Created term: #{term_attrs[:season].capitalize} #{term_attrs[:year]} (uid: #{uid})" + end + end +end diff --git a/app/models/term.rb b/app/models/term.rb index 376b1d2c..9d01a565 100644 --- a/app/models/term.rb +++ b/app/models/term.rb @@ -33,6 +33,79 @@ def name "#{season.to_s.capitalize} #{year}" end + # Find a term by its UID + # @param uid [Integer] the term UID + # @return [Term, nil] the term if found, nil otherwise + def self.find_by_uid(uid) + find_by(uid: uid) + end + + # Find a term by its UID, raises if not found + # @param uid [Integer] the term UID + # @return [Term] the term + # @raise [ActiveRecord::RecordNotFound] if term not found + def self.find_by_uid!(uid) + find_by!(uid: uid) + end + + # Returns the current academic term based on today's date + def self.current + today = Date.today + current_year = today.year + + # Determine current season based on date ranges: + # Spring: January 1 - May 31 + # Summer: June 1 - July 31 + # Fall: August 1 - December 31 + current_season = if today.month >= 8 # August - December + :fall + elsif today.month >= 6 # June - July + :summer + else # January - May + :spring + end + + find_by(year: current_year, season: current_season) + end + + # Returns the next academic term after the current term + def self.next + current_term = current + return nil unless current_term + + # Determine next term based on season progression: + # Fall -> Spring (next year) + # Spring -> Summer (same year) + # Summer -> Fall (same year) + case current_term.season.to_sym + when :fall + find_by(year: current_term.year + 1, season: :spring) + when :spring + find_by(year: current_term.year, season: :summer) + when :summer + find_by(year: current_term.year, season: :fall) + end + end + + # Convenience method to get current term UID + # @return [Integer, nil] the UID of the current term + def self.current_uid + current&.uid + end + + # Convenience method to get next term UID + # @return [Integer, nil] the UID of the next term + def self.next_uid + self.next&.uid + end + + # Check if a term exists by UID + # @param uid [Integer] the term UID + # @return [Boolean] true if term exists + def self.exists_by_uid?(uid) + exists?(uid: uid) + end + private def uniqueness_of_year_and_semester diff --git a/app/models/user.rb b/app/models/user.rb index f5de76c0..f5c89374 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,6 +33,7 @@ class User < ApplicationRecord has_many :google_calendar_events, dependent: :destroy has_many :calendar_preferences, dependent: :destroy has_many :event_preferences, dependent: :destroy + has_one :user_extension_config, dependent: :destroy before_create :generate_calendar_token # Class method to find or create a user by email diff --git a/app/models/user_extension_config.rb b/app/models/user_extension_config.rb new file mode 100644 index 00000000..b7e565de --- /dev/null +++ b/app/models/user_extension_config.rb @@ -0,0 +1,3 @@ +class UserExtensionConfig < ApplicationRecord + belongs_to :user +end diff --git a/app/services/calendar_template_renderer.rb b/app/services/calendar_template_renderer.rb index c9278e42..e4f49243 100644 --- a/app/services/calendar_template_renderer.rb +++ b/app/services/calendar_template_renderer.rb @@ -32,11 +32,6 @@ def self.validate_template(template_string) end end - def initialize - @liquid = Liquid::Template.new - @liquid.error_mode = :strict - end - def render(template_string, context) return "" if template_string.blank? diff --git a/app/services/course_processor_service.rb b/app/services/course_processor_service.rb index 1ab0ab61..03d28cde 100644 --- a/app/services/course_processor_service.rb +++ b/app/services/course_processor_service.rb @@ -12,6 +12,9 @@ def initialize(courses, user) end def call + # Validate input + validate_courses_data! + processed_courses = [] # Deduplicate courses by CRN and term @@ -23,26 +26,14 @@ def call course_reference_number: course_data[:crn] ) - term = Term.find_or_create_by!(uid: course_data[:term]) do |t| - associated_term = detailed_course_info[:associated_term] - - # associated term is in format "Fall 2025" - season_str, year_str = associated_term.to_s.strip.split(/\s+/) - year = year_str.to_i - season = case season_str - when "Spring" - :spring - when "Fall" - :fall - when "Summer" - :summer - else - raise "Unknown season string: #{season_str.inspect}" - end - - t.year = year - t.season = Term.seasons[season] + # Look up term by UID - it should already exist via EnsureFutureTermsJob + term = Term.find_by_uid(course_data[:term]) + unless term + raise InvalidTermError.new( + course_data[:term], + "Term with UID #{course_data[:term]} not found. Please ensure EnsureFutureTermsJob has run." + ) end # schedule_type: "Lecture (LEC)", @@ -137,6 +128,33 @@ def call processed_courses end + private + + def validate_courses_data! + raise ArgumentError, "courses cannot be nil" if courses.nil? + raise ArgumentError, "courses must be an array" unless courses.is_a?(Array) + raise ArgumentError, "courses cannot be empty" if courses.empty? + + courses.each_with_index do |course_data, index| + unless course_data.is_a?(Hash) + raise ArgumentError, "course at index #{index} must be a hash" + end + + unless course_data[:crn].present? + raise ArgumentError, "course at index #{index} missing required field: crn" + end + + unless course_data[:term].present? + raise ArgumentError, "course at index #{index} missing required field: term" + end + + # Validate term UID is numeric + unless course_data[:term].to_s.match?(/^\d+$/) + raise ArgumentError, "course at index #{index} has invalid term UID: #{course_data[:term]}" + end + end + end + def process_faculty(course, faculty_data) # Deduplicate faculty by email unique_faculty = faculty_data.uniq { |f| f["emailAddress"] || f[:emailAddress] } diff --git a/app/views/admin/application/index.html.erb b/app/views/admin/application/index.html.erb index 463bbe39..c9eb3cc0 100644 --- a/app/views/admin/application/index.html.erb +++ b/app/views/admin/application/index.html.erb @@ -1,17 +1,13 @@ -
+

Admin Dashboard

-
+
-
-
-
-
Total Users
-
-
+
+ <%= link_to "Users", admin_users_path, class: "text-base font-medium text-gray-700 hover:underline" %>
<%= @users_count %>
@@ -19,12 +15,8 @@
-
-
-
-
Admins
-
-
+
+ <%= link_to "Admins", admin_users_path, class: "text-base font-medium text-gray-700 hover:underline" %>
<%= @admin_count %>
@@ -32,14 +24,73 @@
-
-
-
-
Active Sessions
-
+
+ <%= link_to "Buildings", admin_buildings_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @buildings_count %> +
+
+
+ +
+
+ <%= link_to "Rooms", admin_rooms_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @rooms_count %> +
+
+
+ +
+
+ <%= link_to "Courses", admin_courses_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @courses_count %>
+
+
+ +
+
+ <%= link_to "Faculty", admin_faculties_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @faculties_count %> +
+
+
+ +
+
+ <%= link_to "Terms", admin_terms_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @terms_count %> +
+
+
+ +
+
+ <%= link_to "Google Calendars", admin_calendars_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @google_calendars_count %> +
+
+
+ +
+
+ <%= link_to "Calendar Events", admin_google_calendar_events_path, class: "text-base font-medium text-gray-700 hover:underline" %> +
+ <%= @google_calendar_events_count %> +
+
+
+ +
+
+ <%= link_to "RMP Ratings", admin_rmp_ratings_path, class: "text-base font-medium text-gray-700 hover:underline" %>
- <%= @active_sessions_count %> + <%= @rmp_ratings_count %>
diff --git a/config/recurring.yml b/config/recurring.yml index 077b4da5..8540b094 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -29,6 +29,13 @@ blazer_send_failing_checks: queue: low schedule: every day at 8am +# Ensure future academic terms exist (current + 2 terms ahead) +# Runs on the 1st of every month at 2:00 AM in application timezone (Eastern Time) +ensure_future_terms: + class: EnsureFutureTermsJob + queue: low + schedule: every month on the 1st at 2am + # Update all faculty RateMyProfessor ratings weekly # Runs every Sunday at 3:00 AM in application timezone (Eastern Time) update_faculty_rmp_ratings: diff --git a/config/routes.rb b/config/routes.rb index d1aac9fe..1d0a8caf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -238,9 +238,15 @@ post "user/gcal", to: "users#request_g_cal" post "user/gcal/add_email", to: "users#add_email_to_g_cal" delete "user/gcal/remove_email", to: "users#remove_email_from_g_cal" + get "user/email", to: "users#get_email" + + get "user/extension_config", to: "users#get_extension_config" + post "user/extension_config", to: "users#set_extension_config" get "faculty/by_rmp", to: "faculty#get_info_by_rmp_id" + get "terms/current_and_next", to: "misc#get_current_terms" + # Course events post "process_courses", to: "courses#process_courses" diff --git a/db/migrate/20251105195331_enable_pgvector_extension.rb b/db/migrate/20251024000000_enable_pgvector_extension.rb similarity index 100% rename from db/migrate/20251105195331_enable_pgvector_extension.rb rename to db/migrate/20251024000000_enable_pgvector_extension.rb diff --git a/db/migrate/20251024000014_create_faculties.rb b/db/migrate/20251024000014_create_faculties.rb index 79af0a95..a8661a06 100644 --- a/db/migrate/20251024000014_create_faculties.rb +++ b/db/migrate/20251024000014_create_faculties.rb @@ -4,9 +4,11 @@ def change t.string :first_name, null: false t.string :last_name, null: false t.string :email, null: false + t.vector :embedding, limit: 1536 t.timestamps end add_index :faculties, :email, unique: true + # Note: HNSW index for embedding will be added after data is populated end end \ No newline at end of file diff --git a/db/migrate/20251024000017_create_courses.rb b/db/migrate/20251024000017_create_courses.rb index 19a5b80d..14f08c27 100644 --- a/db/migrate/20251024000017_create_courses.rb +++ b/db/migrate/20251024000017_create_courses.rb @@ -15,6 +15,8 @@ def change t.string :schedule_type, null: false + t.vector :embedding, limit: 1536 + t.timestamps end diff --git a/db/migrate/20251103001630_create_rmp_ratings.rb b/db/migrate/20251103001630_create_rmp_ratings.rb index 827300c3..b51d7024 100644 --- a/db/migrate/20251103001630_create_rmp_ratings.rb +++ b/db/migrate/20251103001630_create_rmp_ratings.rb @@ -19,6 +19,7 @@ def change t.text :rating_tags t.integer :thumbs_up_total, default: 0 t.integer :thumbs_down_total, default: 0 + t.vector :embedding, limit: 1536 t.timestamps end diff --git a/db/migrate/20251104233425_create_google_calendar_events.rb b/db/migrate/20251104233425_create_google_calendar_events.rb deleted file mode 100644 index a1feeb50..00000000 --- a/db/migrate/20251104233425_create_google_calendar_events.rb +++ /dev/null @@ -1,22 +0,0 @@ -class CreateGoogleCalendarEvents < ActiveRecord::Migration[8.1] - def change - create_table :google_calendar_events do |t| - t.references :user, null: false, foreign_key: true - t.references :meeting_time, null: true, foreign_key: true - t.string :google_event_id, null: false - t.string :calendar_id, null: false - t.string :summary - t.string :location - t.datetime :start_time - t.datetime :end_time - t.text :recurrence - - t.timestamps - end - - # Add indexes for faster lookups - add_index :google_calendar_events, :google_event_id - add_index :google_calendar_events, [:user_id, :calendar_id] - add_index :google_calendar_events, [:user_id, :meeting_time_id], unique: true - end -end diff --git a/db/migrate/20251104233425_create_google_calendar_tables.rb b/db/migrate/20251104233425_create_google_calendar_tables.rb new file mode 100644 index 00000000..14e37491 --- /dev/null +++ b/db/migrate/20251104233425_create_google_calendar_tables.rb @@ -0,0 +1,36 @@ +class CreateGoogleCalendarTables < ActiveRecord::Migration[8.1] + def change + # Create google_calendars table + create_table :google_calendars do |t| + t.string :google_calendar_id, null: false + t.string :summary + t.text :description + t.string :time_zone + t.datetime :last_synced_at + t.references :oauth_credential, null: false, foreign_key: true + + t.timestamps + end + add_index :google_calendars, :google_calendar_id, unique: true + + # Create google_calendar_events table + create_table :google_calendar_events do |t| + t.references :google_calendar, null: false, foreign_key: true + t.references :meeting_time, null: true, foreign_key: true + t.string :google_event_id, null: false + t.string :summary + t.string :location + t.datetime :start_time + t.datetime :end_time + t.text :recurrence + t.datetime :last_synced_at + t.string :event_data_hash + + t.timestamps + end + + # Add indexes for faster lookups + add_index :google_calendar_events, :google_event_id + add_index :google_calendar_events, [:google_calendar_id, :meeting_time_id] + end +end diff --git a/db/migrate/20251104234307_add_last_synced_at_to_google_calendar_events.rb b/db/migrate/20251104234307_add_last_synced_at_to_google_calendar_events.rb deleted file mode 100644 index b95c83d7..00000000 --- a/db/migrate/20251104234307_add_last_synced_at_to_google_calendar_events.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddLastSyncedAtToGoogleCalendarEvents < ActiveRecord::Migration[8.1] - def change - add_column :google_calendar_events, :last_synced_at, :datetime - add_column :google_calendar_events, :event_data_hash, :string - end -end diff --git a/db/migrate/20251105000656_create_google_calendars.rb b/db/migrate/20251105000656_create_google_calendars.rb deleted file mode 100644 index cfce4cb8..00000000 --- a/db/migrate/20251105000656_create_google_calendars.rb +++ /dev/null @@ -1,15 +0,0 @@ -class CreateGoogleCalendars < ActiveRecord::Migration[8.1] - def change - create_table :google_calendars do |t| - t.string :google_calendar_id, null: false - t.string :summary - t.text :description - t.string :time_zone - t.datetime :last_synced_at - t.references :oauth_credential, null: false, foreign_key: true - - t.timestamps - end - add_index :google_calendars, :google_calendar_id, unique: true - end -end diff --git a/db/migrate/20251105000759_update_google_calendar_events_to_use_google_calendars.rb b/db/migrate/20251105000759_update_google_calendar_events_to_use_google_calendars.rb deleted file mode 100644 index 0109e3a1..00000000 --- a/db/migrate/20251105000759_update_google_calendar_events_to_use_google_calendars.rb +++ /dev/null @@ -1,71 +0,0 @@ -class UpdateGoogleCalendarEventsToUseGoogleCalendars < ActiveRecord::Migration[8.1] - disable_ddl_transaction! - - def up - # Add new reference column without foreign key - add_reference :google_calendar_events, :google_calendar, null: true, index: { algorithm: :concurrently } - - # Migrate existing data - # Group events by calendar_id and user_id to create GoogleCalendar records - safety_assured do - execute <<-SQL - INSERT INTO google_calendars (google_calendar_id, oauth_credential_id, created_at, updated_at, last_synced_at) - SELECT DISTINCT - gce.calendar_id, - oc.id as oauth_credential_id, - NOW(), - NOW(), - MAX(gce.last_synced_at) - FROM google_calendar_events gce - INNER JOIN oauth_credentials oc ON gce.user_id = oc.user_id - WHERE oc.metadata->>'course_calendar_id' = gce.calendar_id - GROUP BY gce.calendar_id, oc.id - ON CONFLICT (google_calendar_id) DO NOTHING - SQL - - # Update google_calendar_events to reference the new google_calendars - execute <<-SQL - UPDATE google_calendar_events gce - SET google_calendar_id = gc.id - FROM google_calendars gc - WHERE gc.google_calendar_id = gce.calendar_id - SQL - end - - # Make the new column non-nullable - safety_assured { change_column_null :google_calendar_events, :google_calendar_id, false } - - # Remove old indexes that reference calendar_id - remove_index :google_calendar_events, name: "index_google_calendar_events_on_user_id_and_calendar_id", if_exists: true - - # Remove old calendar_id column - safety_assured { remove_column :google_calendar_events, :calendar_id } - - # Add new index - add_index :google_calendar_events, [:google_calendar_id, :meeting_time_id], algorithm: :concurrently - end - - def down - # Add back calendar_id column - add_column :google_calendar_events, :calendar_id, :string, null: false - - # Restore data - safety_assured do - execute <<-SQL - UPDATE google_calendar_events gce - SET calendar_id = gc.google_calendar_id - FROM google_calendars gc - WHERE gce.google_calendar_id = gc.id - SQL - end - - # Remove new index - remove_index :google_calendar_events, column: [:google_calendar_id, :meeting_time_id], if_exists: true - - # Add back old index - add_index :google_calendar_events, [:user_id, :calendar_id] - - # Remove reference column - remove_reference :google_calendar_events, :google_calendar - end -end diff --git a/db/migrate/20251105200050_add_embedding_to_rmp_ratings.rb b/db/migrate/20251105200050_add_embedding_to_rmp_ratings.rb deleted file mode 100644 index 9c913627..00000000 --- a/db/migrate/20251105200050_add_embedding_to_rmp_ratings.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddEmbeddingToRmpRatings < ActiveRecord::Migration[8.1] - def change - add_column :rmp_ratings, :embedding, :vector, limit: 1536 - end -end diff --git a/db/migrate/20251105200053_add_embedding_to_faculties.rb b/db/migrate/20251105200053_add_embedding_to_faculties.rb deleted file mode 100644 index c96dcacb..00000000 --- a/db/migrate/20251105200053_add_embedding_to_faculties.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddEmbeddingToFaculties < ActiveRecord::Migration[8.1] - def change - add_column :faculties, :embedding, :vector, limit: 1536 - end -end diff --git a/db/migrate/20251105200244_add_index_to_rmp_ratings_embedding.rb b/db/migrate/20251105200244_add_index_to_rmp_ratings_embedding.rb deleted file mode 100644 index 5c168aec..00000000 --- a/db/migrate/20251105200244_add_index_to_rmp_ratings_embedding.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddIndexToRmpRatingsEmbedding < ActiveRecord::Migration[8.1] - disable_ddl_transaction! - - def change - add_index :rmp_ratings, :embedding, using: :hnsw, opclass: :vector_cosine_ops, algorithm: :concurrently - end -end diff --git a/db/migrate/20251105200250_add_index_to_faculties_embedding.rb b/db/migrate/20251105200250_add_index_to_faculties_embedding.rb deleted file mode 100644 index 7077306c..00000000 --- a/db/migrate/20251105200250_add_index_to_faculties_embedding.rb +++ /dev/null @@ -1,9 +0,0 @@ -class AddIndexToFacultiesEmbedding < ActiveRecord::Migration[8.1] - disable_ddl_transaction! - - def change - # Skip index creation - HNSW requires data with dimensions - # Will add index later after embeddings are generated - # add_index :faculties, :embedding, using: :hnsw, opclass: :vector_cosine_ops, algorithm: :concurrently - end -end diff --git a/db/migrate/20251105202326_add_embedding_to_courses.rb b/db/migrate/20251105202326_add_embedding_to_courses.rb deleted file mode 100644 index c5403140..00000000 --- a/db/migrate/20251105202326_add_embedding_to_courses.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddEmbeddingToCourses < ActiveRecord::Migration[8.1] - def change - add_column :courses, :embedding, :vector, limit: 1536 - end -end diff --git a/db/migrate/20251105202447_add_index_to_courses_embedding.rb b/db/migrate/20251105202447_add_index_to_courses_embedding.rb deleted file mode 100644 index f1b7c36d..00000000 --- a/db/migrate/20251105202447_add_index_to_courses_embedding.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddIndexToCoursesEmbedding < ActiveRecord::Migration[8.1] - disable_ddl_transaction! - - def change - add_index :courses, :embedding, using: :hnsw, opclass: :vector_cosine_ops, algorithm: :concurrently - end -end diff --git a/db/migrate/20251105224600_create_calendar_preferences.rb b/db/migrate/20251105224600_create_calendar_preferences.rb deleted file mode 100644 index e2955de1..00000000 --- a/db/migrate/20251105224600_create_calendar_preferences.rb +++ /dev/null @@ -1,18 +0,0 @@ -class CreateCalendarPreferences < ActiveRecord::Migration[8.1] - def change - create_table :calendar_preferences do |t| - t.references :user, null: false, foreign_key: true - t.integer :scope, null: false - t.string :event_type - t.text :title_template - t.text :description_template - t.jsonb :reminder_settings, default: [] - t.integer :color_id - t.string :visibility - - t.timestamps - end - - add_index :calendar_preferences, [:user_id, :scope, :event_type], unique: true, name: 'index_calendar_prefs_on_user_scope_type' - end -end diff --git a/db/migrate/20251105224657_create_event_preferences.rb b/db/migrate/20251105224600_create_preference_tables.rb similarity index 51% rename from db/migrate/20251105224657_create_event_preferences.rb rename to db/migrate/20251105224600_create_preference_tables.rb index 7ea5b4ce..191d3329 100644 --- a/db/migrate/20251105224657_create_event_preferences.rb +++ b/db/migrate/20251105224600_create_preference_tables.rb @@ -1,5 +1,22 @@ -class CreateEventPreferences < ActiveRecord::Migration[8.1] +class CreatePreferenceTables < ActiveRecord::Migration[8.1] def change + # Create calendar preferences table + create_table :calendar_preferences do |t| + t.references :user, null: false, foreign_key: true + t.integer :scope, null: false + t.string :event_type + t.text :title_template + t.text :description_template + t.jsonb :reminder_settings, default: [] + t.integer :color_id + t.string :visibility + + t.timestamps + end + + add_index :calendar_preferences, [:user_id, :scope, :event_type], unique: true, name: 'index_calendar_prefs_on_user_scope_type' + + # Create event preferences table create_table :event_preferences do |t| t.references :user, null: false, foreign_key: true t.references :preferenceable, polymorphic: true, null: false diff --git a/db/migrate/20251106012149_create_user_extension_configs.rb b/db/migrate/20251106012149_create_user_extension_configs.rb new file mode 100644 index 00000000..3ec52139 --- /dev/null +++ b/db/migrate/20251106012149_create_user_extension_configs.rb @@ -0,0 +1,18 @@ +class CreateUserExtensionConfigs < ActiveRecord::Migration[8.1] + def change + create_table :user_extension_configs do |t| + t.references :user, null: false, foreign_key: true + + # interface UserSettings { + # military_time: boolean; + # default_color_lecture: string; + # default_color_lab: string; + # } + t.boolean :military_time, default: false, null: false + t.string :default_color_lecture, null: false, default: GoogleColors::EVENT_PEACOCK + t.string :default_color_lab, null: false, default: GoogleColors::EVENT_BANANA + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 9c85cea2..56183ae4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_11_05_224657) do +ActiveRecord::Schema[8.1].define(version: 2025_11_06_012149) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "vector" @@ -174,7 +174,6 @@ t.string "title" t.datetime "updated_at", null: false t.index ["crn"], name: "index_courses_on_crn", unique: true - t.index ["embedding"], name: "index_courses_on_embedding", opclass: :vector_cosine_ops, using: :hnsw t.index ["term_id"], name: "index_courses_on_term_id" end @@ -228,14 +227,13 @@ create_table "faculties", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", null: false - t.vector "embedding" + t.vector "embedding", limit: 1536 t.string "first_name", null: false t.string "last_name", null: false t.string "rmp_id" t.jsonb "rmp_raw_data", default: {} t.datetime "updated_at", null: false t.index ["email"], name: "index_faculties_on_email", unique: true - t.index ["embedding"], name: "index_faculties_on_embedding", opclass: :vector_cosine_ops, using: :hnsw t.index ["rmp_id"], name: "index_faculties_on_rmp_id", unique: true t.index ["rmp_raw_data"], name: "index_faculties_on_rmp_raw_data", using: :gin end @@ -269,13 +267,10 @@ t.datetime "start_time" t.string "summary" t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["google_calendar_id", "meeting_time_id"], name: "index_google_calendar_events_on_google_calendar_id_and_meeting_" + t.index ["google_calendar_id", "meeting_time_id"], name: "idx_on_google_calendar_id_meeting_time_id_6c9efabf50" t.index ["google_calendar_id"], name: "index_google_calendar_events_on_google_calendar_id" t.index ["google_event_id"], name: "index_google_calendar_events_on_google_event_id" t.index ["meeting_time_id"], name: "index_google_calendar_events_on_meeting_time_id" - t.index ["user_id", "meeting_time_id"], name: "index_google_calendar_events_on_user_id_and_meeting_time_id", unique: true - t.index ["user_id"], name: "index_google_calendar_events_on_user_id" end create_table "google_calendars", force: :cascade do |t| @@ -394,7 +389,7 @@ t.string "course_name" t.datetime "created_at", null: false t.integer "difficulty_rating" - t.vector "embedding" + t.vector "embedding", limit: 1536 t.bigint "faculty_id", null: false t.string "grade" t.integer "helpful_rating" @@ -407,7 +402,6 @@ t.integer "thumbs_up_total", default: 0 t.datetime "updated_at", null: false t.boolean "would_take_again" - t.index ["embedding"], name: "index_rmp_ratings_on_embedding", opclass: :vector_cosine_ops, using: :hnsw t.index ["faculty_id"], name: "index_rmp_ratings_on_faculty_id" t.index ["rmp_id"], name: "index_rmp_ratings_on_rmp_id", unique: true end @@ -441,6 +435,16 @@ t.index ["year", "season"], name: "index_terms_on_year_and_season", unique: true end + create_table "user_extension_configs", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "default_color_lab", default: "#fbd75b", null: false + t.string "default_color_lecture", default: "#46d6db", null: false + t.boolean "military_time", default: false, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_user_extension_configs_on_user_id" + end + create_table "users", force: :cascade do |t| t.integer "access_level", default: 0, null: false t.boolean "calendar_needs_sync", default: false, null: false @@ -470,8 +474,8 @@ add_foreign_key "enrollments", "terms" add_foreign_key "enrollments", "users" add_foreign_key "event_preferences", "users" + add_foreign_key "google_calendar_events", "google_calendars" add_foreign_key "google_calendar_events", "meeting_times" - add_foreign_key "google_calendar_events", "users" add_foreign_key "google_calendars", "oauth_credentials" add_foreign_key "meeting_times", "courses" add_foreign_key "meeting_times", "rooms" @@ -482,4 +486,5 @@ add_foreign_key "rmp_ratings", "faculties", validate: false add_foreign_key "rooms", "buildings" add_foreign_key "teacher_rating_tags", "faculties", validate: false + add_foreign_key "user_extension_configs", "users" end diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md index 7f7ad6f2..750b1d7d 100644 --- a/docs/QUICK_REFERENCE.md +++ b/docs/QUICK_REFERENCE.md @@ -3,13 +3,13 @@ ## ๐Ÿš€ Quick Start ### For Extension Developers -1. Read: [`extension_integration_guide.md`](./extension_integration_guide.md) -2. API Reference: [`api_calendar_preferences.md`](./api_calendar_preferences.md) -3. Template Variables: [`template_variables.md`](./template_variables.md) +1. Read: [`extension_integration_guide.md`](./calendar-preferences/extension_integration_guide.md) +2. API Reference: [`api_calendar_preferences.md`](./calendar-preferences/api_calendar_preferences.md) +3. Template Variables: [`template_variables.md`](./calendar-preferences/template_variables.md) ### For Backend Developers -1. Architecture: [`calendar_preferences.md`](./calendar_preferences.md) -2. Implementation: [`../CALENDAR_PREFERENCES_IMPLEMENTATION.md`](../CALENDAR_PREFERENCES_IMPLEMENTATION.md) +1. Architecture: [`calendar_preferences.md`](./calendar-preferences/calendar_preferences.md) +2. Implementation: [`../CALENDAR_PREFERENCES_IMPLEMENTATION.md`](needs-sort/CALENDAR_PREFERENCES_IMPLEMENTATION.md) ## ๐Ÿ“‹ API Endpoints Cheat Sheet @@ -44,7 +44,7 @@ Time: start_time, end_time, day, day_abbr Academic: term, schedule_type ``` -Full reference: [`template_variables.md`](./template_variables.md) +Full reference: [`template_variables.md`](./calendar-preferences/template_variables.md) ## ๐Ÿ“ Template Examples @@ -165,12 +165,13 @@ curl -X POST /api/calendar_preferences/preview \ ``` docs/ -โ”œโ”€โ”€ README.md # Start here -โ”œโ”€โ”€ QUICK_REFERENCE.md # This file -โ”œโ”€โ”€ calendar_preferences.md # System architecture -โ”œโ”€โ”€ api_calendar_preferences.md # API reference -โ”œโ”€โ”€ template_variables.md # Template guide -โ””โ”€โ”€ extension_integration_guide.md # Extension guide +โ”œโ”€โ”€ README.md # Start here +โ”œโ”€โ”€ QUICK_REFERENCE.md # This file +โ””โ”€โ”€ calendar-preferences/ + โ”œโ”€โ”€ calendar_preferences.md # System architecture + โ”œโ”€โ”€ api_calendar_preferences.md # API reference + โ”œโ”€โ”€ template_variables.md # Template guide + โ””โ”€โ”€ extension_integration_guide.md # Extension guide ``` ## ๐Ÿงช Testing @@ -203,7 +204,7 @@ bundle exec rspec spec/models/calendar_preference_spec.rb:42 --- **Quick Links:** -- [Full API Docs](./api_calendar_preferences.md) -- [Extension Guide](./extension_integration_guide.md) -- [Template Variables](./template_variables.md) -- [Implementation Summary](../CALENDAR_PREFERENCES_IMPLEMENTATION.md) +- [Full API Docs](./calendar-preferences/api_calendar_preferences.md) +- [Extension Guide](./calendar-preferences/extension_integration_guide.md) +- [Template Variables](./calendar-preferences/template_variables.md) +- [Implementation Summary](needs-sort/CALENDAR_PREFERENCES_IMPLEMENTATION.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..e768c785 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,141 @@ +# Calendar Backend Documentation + +Welcome to the Calendar Backend documentation! This guide will help you find the information you need. + +## Quick Navigation + +- **[Quick Reference](./QUICK_REFERENCE.md)** - Cheat sheet for common tasks, API endpoints, and template variables + +## Getting Started + +### Setup & Configuration +- **[DevContainer Setup](./setup/devcontainer-setup.md)** - Set up your development environment using DevContainers + +## Core Features + +### Calendar Preferences +Customize how course events appear in Google Calendar with templates, colors, and reminders. + +- **[Calendar Preferences Architecture](./calendar-preferences/calendar_preferences.md)** - System architecture and design +- **[API Reference](./calendar-preferences/api_calendar_preferences.md)** - Complete API documentation +- **[Template Variables Guide](./calendar-preferences/template_variables.md)** - All available template variables and examples +- **[Extension Integration Guide](./calendar-preferences/extension_integration_guide.md)** - How to integrate with Chrome extensions + +**Use Cases:** +- Backend developers: Start with Architecture +- Extension developers: Read Integration Guide โ†’ API Reference โ†’ Template Variables +- Quick reference: See [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) + +### Calendar Synchronization +Smart syncing between course schedules and Google Calendar. + +- **[Intelligent Calendar Sync](./calendar-sync/intelligent_calendar_sync.md)** - Change detection and optimization +- **[Nightly Calendar Sync](./calendar-sync/nightly-calendar-sync.md)** - Automated background synchronization +- **[Google Calendar Events](./calendar-sync/google_calendar_events.md)** - Event creation and management +- **[User Google Calendar Edits](./calendar-sync/user_google_calendar_edits.md)** - Handling user modifications +- **[Multi-Email OAuth](./calendar-sync/multi-email-google-calendar-oauth.md)** - OAuth setup for multiple Google accounts + +**Use Cases:** +- Understanding sync performance: See Intelligent Calendar Sync +- Setting up automated syncs: See Nightly Calendar Sync +- OAuth configuration: See Multi-Email OAuth + +### Embeddings & AI +Vector embeddings and semantic search capabilities. + +- **[pgvector Embeddings](./embeddings/pgvector-embeddings.md)** - Vector database setup and usage +- **[Future Embedding Use Cases](./embeddings/future-embedding-use-cases.md)** - Planned AI features + +**Use Cases:** +- Setting up vector search: See pgvector Embeddings +- Understanding AI roadmap: See Future Use Cases + +## Integrations + +- **[Rate My Professor](./integrations/rate-my-professor-integration.md)** - Integration with RateMyProfessor data + +## Infrastructure + +- **[Job Queues](./infrastructure/job-queues.md)** - Background job processing with ActiveJob + +**Use Cases:** +- Adding background jobs: See Job Queues +- Understanding async processing: See Job Queues + +## Documentation Map + +``` +docs/ +โ”œโ”€โ”€ README.md (you are here) +โ”œโ”€โ”€ QUICK_REFERENCE.md (quick cheat sheet) +โ”‚ +โ”œโ”€โ”€ setup/ +โ”‚ โ””โ”€โ”€ devcontainer-setup.md +โ”‚ +โ”œโ”€โ”€ calendar-preferences/ +โ”‚ โ”œโ”€โ”€ calendar_preferences.md (architecture) +โ”‚ โ”œโ”€โ”€ api_calendar_preferences.md (API docs) +โ”‚ โ”œโ”€โ”€ template_variables.md (template guide) +โ”‚ โ””โ”€โ”€ extension_integration_guide.md (extension integration) +โ”‚ +โ”œโ”€โ”€ calendar-sync/ +โ”‚ โ”œโ”€โ”€ intelligent_calendar_sync.md (sync optimization) +โ”‚ โ”œโ”€โ”€ nightly-calendar-sync.md (automated sync) +โ”‚ โ”œโ”€โ”€ google_calendar_events.md (event management) +โ”‚ โ”œโ”€โ”€ user_google_calendar_edits.md (user modifications) +โ”‚ โ””โ”€โ”€ multi-email-google-calendar-oauth.md (OAuth setup) +โ”‚ +โ”œโ”€โ”€ embeddings/ +โ”‚ โ”œโ”€โ”€ pgvector-embeddings.md (vector database) +โ”‚ โ””โ”€โ”€ future-embedding-use-cases.md (AI roadmap) +โ”‚ +โ”œโ”€โ”€ integrations/ +โ”‚ โ””โ”€โ”€ rate-my-professor-integration.md +โ”‚ +โ””โ”€โ”€ infrastructure/ + โ””โ”€โ”€ job-queues.md +``` + +## Common Workflows + +### I want to... + +**Set up my development environment** +โ†’ [DevContainer Setup](./setup/devcontainer-setup.md) + +**Understand calendar preferences** +โ†’ [Calendar Preferences Architecture](./calendar-preferences/calendar_preferences.md) + +**Build a Chrome extension** +โ†’ [Extension Integration Guide](./calendar-preferences/extension_integration_guide.md) + +**Use the calendar preferences API** +โ†’ [API Reference](./calendar-preferences/api_calendar_preferences.md) + +**Customize event titles/descriptions** +โ†’ [Template Variables Guide](./calendar-preferences/template_variables.md) + +**Optimize calendar sync performance** +โ†’ [Intelligent Calendar Sync](./calendar-sync/intelligent_calendar_sync.md) + +**Set up automated nightly syncs** +โ†’ [Nightly Calendar Sync](./calendar-sync/nightly-calendar-sync.md) + +**Configure Google OAuth** +โ†’ [Multi-Email OAuth](./calendar-sync/multi-email-google-calendar-oauth.md) + +**Add background jobs** +โ†’ [Job Queues](./infrastructure/job-queues.md) + +**Implement semantic search** +โ†’ [pgvector Embeddings](./embeddings/pgvector-embeddings.md) + +**Integrate RateMyProfessor data** +โ†’ [Rate My Professor Integration](./integrations/rate-my-professor-integration.md) + +## Need Help? + +1. Check the [Quick Reference](./QUICK_REFERENCE.md) for common tasks +2. Browse the relevant category above +3. Search for keywords in the documentation +4. Open an issue on GitHub diff --git a/docs/api_calendar_preferences.md b/docs/calendar-preferences/api_calendar_preferences.md similarity index 100% rename from docs/api_calendar_preferences.md rename to docs/calendar-preferences/api_calendar_preferences.md diff --git a/docs/calendar_preferences.md b/docs/calendar-preferences/calendar_preferences.md similarity index 89% rename from docs/calendar_preferences.md rename to docs/calendar-preferences/calendar_preferences.md index 4ecb7e0c..85d2c0a1 100644 --- a/docs/calendar_preferences.md +++ b/docs/calendar-preferences/calendar_preferences.md @@ -250,7 +250,11 @@ If no preferences are configured: **Color:** - Uses `MeetingTime#event_color` (existing logic based on schedule_type) -## Google Calendar Sync Integration +## Calendar Integration + +Preferences are applied to both **Google Calendar sync** and **iCal feeds** for consistent event formatting across all calendar platforms. + +### Google Calendar Sync Integration The `GoogleCalendarService` uses resolved preferences when creating/updating events: @@ -283,6 +287,45 @@ def create_event_in_calendar(user, meeting_time) end ``` +### iCal Feed Integration + +The `CalendarsController` also uses resolved preferences when generating iCal feeds: + +```ruby +# In CalendarsController#generate_ical +def generate_ical(courses) + # Initialize preference resolver for this user + @preference_resolver = PreferenceResolver.new(@user) + @template_renderer = CalendarTemplateRenderer.new + + courses.each do |course| + course.meeting_times.each do |meeting_time| + # Resolve user preferences for this meeting time + prefs = @preference_resolver.resolve_for(meeting_time) + context = CalendarTemplateRenderer.build_context_from_meeting_time(meeting_time) + + # Apply title template + event.summary = @template_renderer.render(prefs[:title_template], context) + + # Apply description template if set + event.description = @template_renderer.render(prefs[:description_template], context) + + # Apply color preferences + color_hex = get_google_color_hex(prefs[:color_id]) if prefs[:color_id].present? + event.color = "##{color_hex}" if color_hex + + # ... rest of event creation + end + end +end +``` + +**Note:** iCal feeds are accessed via the calendar token: +- URL format: `/calendar/:calendar_token.ics` +- No authentication required (token acts as security) +- Feeds respect all user preferences +- Refreshed based on cache headers (1 hour) + ## API Endpoints ### Global and Event-Type Preferences diff --git a/docs/extension_integration_guide.md b/docs/calendar-preferences/extension_integration_guide.md similarity index 100% rename from docs/extension_integration_guide.md rename to docs/calendar-preferences/extension_integration_guide.md diff --git a/docs/template_variables.md b/docs/calendar-preferences/template_variables.md similarity index 100% rename from docs/template_variables.md rename to docs/calendar-preferences/template_variables.md diff --git a/docs/google_calendar_events.md b/docs/calendar-sync/google_calendar_events.md similarity index 100% rename from docs/google_calendar_events.md rename to docs/calendar-sync/google_calendar_events.md diff --git a/docs/intelligent_calendar_sync.md b/docs/calendar-sync/intelligent_calendar_sync.md similarity index 100% rename from docs/intelligent_calendar_sync.md rename to docs/calendar-sync/intelligent_calendar_sync.md diff --git a/docs/multi-email-google-calendar-oauth.md b/docs/calendar-sync/multi-email-google-calendar-oauth.md similarity index 100% rename from docs/multi-email-google-calendar-oauth.md rename to docs/calendar-sync/multi-email-google-calendar-oauth.md diff --git a/docs/nightly-calendar-sync.md b/docs/calendar-sync/nightly-calendar-sync.md similarity index 100% rename from docs/nightly-calendar-sync.md rename to docs/calendar-sync/nightly-calendar-sync.md diff --git a/docs/user_google_calendar_edits.md b/docs/calendar-sync/user_google_calendar_edits.md similarity index 100% rename from docs/user_google_calendar_edits.md rename to docs/calendar-sync/user_google_calendar_edits.md diff --git a/docs/future-embedding-use-cases.md b/docs/embeddings/future-embedding-use-cases.md similarity index 100% rename from docs/future-embedding-use-cases.md rename to docs/embeddings/future-embedding-use-cases.md diff --git a/docs/pgvector-embeddings.md b/docs/embeddings/pgvector-embeddings.md similarity index 100% rename from docs/pgvector-embeddings.md rename to docs/embeddings/pgvector-embeddings.md diff --git a/docs/job-queues.md b/docs/infrastructure/job-queues.md similarity index 100% rename from docs/job-queues.md rename to docs/infrastructure/job-queues.md diff --git a/docs/rate-my-professor-integration.md b/docs/integrations/rate-my-professor-integration.md similarity index 100% rename from docs/rate-my-professor-integration.md rename to docs/integrations/rate-my-professor-integration.md diff --git a/CALENDAR_PREFERENCES_IMPLEMENTATION.md b/docs/needs-sort/CALENDAR_PREFERENCES_IMPLEMENTATION.md similarity index 93% rename from CALENDAR_PREFERENCES_IMPLEMENTATION.md rename to docs/needs-sort/CALENDAR_PREFERENCES_IMPLEMENTATION.md index 00e03fa9..1e100630 100644 --- a/CALENDAR_PREFERENCES_IMPLEMENTATION.md +++ b/docs/needs-sort/CALENDAR_PREFERENCES_IMPLEMENTATION.md @@ -52,6 +52,7 @@ A complete hierarchical event configuration system has been implemented for the **Updated:** - `app/services/google_calendar_service.rb` - Integrated preferences into event creation/update +- `app/controllers/calendars_controller.rb` - Integrated preferences into iCal feed generation ### API Controllers @@ -153,6 +154,11 @@ System Defaults - โœ… Colors (Google Calendar color IDs 1-11) - โœ… Visibility (public/private/default) +### Calendar Integration +- โœ… **Google Calendar Sync** - Full preference support in GoogleCalendarService +- โœ… **iCal Feeds** - Preferences applied to .ics exports +- โœ… **Consistent Formatting** - Same templates used across all calendar platforms + ## ๐Ÿ“Š Database Schema ### CalendarPreference @@ -341,11 +347,12 @@ See `/docs/extension_integration_guide.md` for complete integration guide. 1. User configures preferences in Chrome extension 2. Extension saves via API endpoints -3. When calendar syncs: - - `GoogleCalendarService` creates/updates events +3. When calendar syncs (Google Calendar) or iCal feed is requested: + - `GoogleCalendarService` creates/updates events (for Google Calendar sync) + - `CalendarsController` generates iCal feed (for iCal subscriptions) - For each event, `PreferenceResolver` walks hierarchy - `CalendarTemplateRenderer` renders templates - - Formatted event sent to Google Calendar API + - Formatted events sent to Google Calendar API or included in iCal feed ### Data Flow ``` @@ -354,14 +361,18 @@ Chrome Extension (UI) Rails Backend (API Controllers) โ†“ (saves to) Database (calendar_preferences, event_preferences) - โ†“ (read during sync) + โ†“ (read during sync/feed generation) PreferenceResolver (resolves hierarchy) โ†“ (provides context) CalendarTemplateRenderer (renders templates) - โ†“ (formats event) -GoogleCalendarService (creates events) - โ†“ (syncs to) -Google Calendar API + โ†“ (formats events) + โ”œโ”€โ†’ GoogleCalendarService (creates events) + โ”‚ โ†“ (syncs to) + โ”‚ Google Calendar API + โ”‚ + โ””โ”€โ†’ CalendarsController (generates iCal) + โ†“ (serves) + iCal Feed (.ics file) ``` ## ๐ŸŽ“ Learning Resources diff --git a/CHANGELOG_CALENDAR_PREFERENCES.md b/docs/needs-sort/CHANGELOG_CALENDAR_PREFERENCES.md similarity index 99% rename from CHANGELOG_CALENDAR_PREFERENCES.md rename to docs/needs-sort/CHANGELOG_CALENDAR_PREFERENCES.md index 9797364f..7a5b2b13 100644 --- a/CHANGELOG_CALENDAR_PREFERENCES.md +++ b/docs/needs-sort/CHANGELOG_CALENDAR_PREFERENCES.md @@ -297,7 +297,7 @@ Planned for future releases: - [API Documentation](docs/api_calendar_preferences.md) - [Template Variables](docs/template_variables.md) - [Extension Integration](docs/extension_integration_guide.md) -- [Quick Reference](docs/QUICK_REFERENCE.md) +- [Quick Reference](../QUICK_REFERENCE.md) --- diff --git a/docs/devcontainer-setup.md b/docs/setup/devcontainer-setup.md similarity index 100% rename from docs/devcontainer-setup.md rename to docs/setup/devcontainer-setup.md diff --git a/spec/factories/user_extension_configs.rb b/spec/factories/user_extension_configs.rb new file mode 100644 index 00000000..f26c4ec2 --- /dev/null +++ b/spec/factories/user_extension_configs.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :user_extension_config do + + end +end diff --git a/spec/helpers/api/user_extension_config_helper_spec.rb b/spec/helpers/api/user_extension_config_helper_spec.rb new file mode 100644 index 00000000..722875ad --- /dev/null +++ b/spec/helpers/api/user_extension_config_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Api::UserExtensionConfigHelper. For example: +# +# describe Api::UserExtensionConfigHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Api::UserExtensionConfigHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/jobs/ensure_future_terms_job_spec.rb b/spec/jobs/ensure_future_terms_job_spec.rb new file mode 100644 index 00000000..b1f6612a --- /dev/null +++ b/spec/jobs/ensure_future_terms_job_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe EnsureFutureTermsJob, type: :job do + describe '#perform' do + let(:current_year) { Date.today.year } + + before do + # Clear existing terms + Term.destroy_all + end + + context 'when no terms exist' do + it 'creates current term and 2 terms ahead (3 terms total)' do + expect { + described_class.perform_now + }.to change(Term, :count).by(3) # current + 2 ahead = 3 terms + end + + it 'assigns uids based on term pattern (fall: [year+1]10, spring: [year]20, summer: [year]30)' do + # Since we only create current + 2 ahead, let's verify the UIDs are correct + # for whatever terms are created based on current date + described_class.perform_now + + terms = Term.all + terms.each do |term| + expected_uid = case term.season.to_sym + when :fall + (term.year + 1) * 100 + 10 + when :spring + term.year * 100 + 20 + when :summer + term.year * 100 + 30 + end + expect(term.uid).to eq(expected_uid) + end + end + end + + context 'when some terms already exist' do + before do + # Create current term (based on today's date) + # If today is in Fall (Aug-Dec), create Fall term + today = Date.today + if today.month >= 8 + Term.create!(year: current_year, season: :fall, uid: (current_year + 1) * 100 + 10) + elsif today.month >= 6 + Term.create!(year: current_year, season: :summer, uid: current_year * 100 + 30) + else + Term.create!(year: current_year, season: :spring, uid: current_year * 100 + 20) + end + end + + it 'only creates missing future terms' do + expect { + described_class.perform_now + }.to change(Term, :count).by(2) # 2 future terms (current already exists) + end + + it 'generates correct uids for new terms' do + described_class.perform_now + + next_year = current_year + 1 + # Check next year's spring term has correct UID + next_spring = Term.find_by(year: next_year, season: :spring) + expect(next_spring.uid).to eq(next_year * 100 + 20) + end + + it 'does not duplicate existing terms' do + initial_count = Term.count + described_class.perform_now + + # Should create exactly 2 more terms (the future terms) + expect(Term.count).to eq(initial_count + 2) + + # Verify no duplicates by checking all terms have unique year+season combos + term_combos = Term.pluck(:year, :season) + expect(term_combos.uniq.count).to eq(term_combos.count) + end + end + + context 'with custom terms_ahead parameter' do + it 'creates current term and specified number of terms ahead' do + expect { + described_class.perform_now(terms_ahead: 4) + }.to change(Term, :count).by(5) # current + 4 ahead = 5 terms + end + end + + context 'term progression' do + it 'creates terms in correct seasonal order' do + described_class.perform_now(terms_ahead: 2) + + terms = Term.order(:created_at).pluck(:season, :year) + + # Verify terms progress correctly (e.g., Fall -> Spring (next year) -> Summer) + # The exact seasons depend on current date, but we can verify count + expect(terms.count).to eq(3) + end + end + + context 'logging' do + it 'logs each created term' do + allow(Rails.logger).to receive(:info).and_call_original + + described_class.perform_now(terms_ahead: 2) + + # Verify 3 terms were logged (current + 2 ahead) + expect(Rails.logger).to have_received(:info).with(/Created term:/).exactly(3).times + end + end + end +end diff --git a/spec/models/term_spec.rb b/spec/models/term_spec.rb index 3d79a77a..13737823 100644 --- a/spec/models/term_spec.rb +++ b/spec/models/term_spec.rb @@ -19,6 +19,198 @@ # require "rails_helper" -RSpec.describe Term do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe Term, type: :model do + describe 'validations' do + it 'validates presence of uid' do + term = build(:term, uid: nil) + expect(term).not_to be_valid + expect(term.errors[:uid]).to include("can't be blank") + end + + it 'validates uniqueness of uid' do + create(:term, uid: 202610, year: 2025, season: :fall) + duplicate_term = build(:term, uid: 202610, year: 2026, season: :spring) + expect(duplicate_term).not_to be_valid + expect(duplicate_term.errors[:uid]).to include('has already been taken') + end + end + + describe 'associations' do + it 'has many courses' do + term = create(:term) + expect(term).to respond_to(:courses) + end + + it 'has many enrollments' do + term = create(:term) + expect(term).to respond_to(:enrollments) + end + end + + describe '.find_by_uid' do + let!(:term) { create(:term, uid: 202610, year: 2025, season: :fall) } + + it 'finds term by uid' do + expect(Term.find_by_uid(202610)).to eq(term) + end + + it 'returns nil if term not found' do + expect(Term.find_by_uid(999999)).to be_nil + end + end + + describe '.find_by_uid!' do + let!(:term) { create(:term, uid: 202610, year: 2025, season: :fall) } + + it 'finds term by uid' do + expect(Term.find_by_uid!(202610)).to eq(term) + end + + it 'raises ActiveRecord::RecordNotFound if term not found' do + expect { + Term.find_by_uid!(999999) + }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe '.current' do + context 'in fall semester (Aug-Dec)' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 10, 15)) + create(:term, uid: 202610, year: 2025, season: :fall) + end + + it 'returns the fall term' do + expect(Term.current.season).to eq('fall') + expect(Term.current.year).to eq(2025) + end + end + + context 'in spring semester (Jan-May)' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 3, 15)) + create(:term, uid: 202520, year: 2025, season: :spring) + end + + it 'returns the spring term' do + expect(Term.current.season).to eq('spring') + expect(Term.current.year).to eq(2025) + end + end + + context 'in summer semester (Jun-Jul)' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 7, 15)) + create(:term, uid: 202530, year: 2025, season: :summer) + end + + it 'returns the summer term' do + expect(Term.current.season).to eq('summer') + expect(Term.current.year).to eq(2025) + end + end + end + + describe '.next' do + context 'when current term is fall' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 10, 15)) + create(:term, uid: 202610, year: 2025, season: :fall) + create(:term, uid: 202620, year: 2026, season: :spring) + end + + it 'returns spring of next year' do + expect(Term.next.season).to eq('spring') + expect(Term.next.year).to eq(2026) + end + end + + context 'when current term is spring' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 3, 15)) + create(:term, uid: 202520, year: 2025, season: :spring) + create(:term, uid: 202530, year: 2025, season: :summer) + end + + it 'returns summer of same year' do + expect(Term.next.season).to eq('summer') + expect(Term.next.year).to eq(2025) + end + end + + context 'when current term is summer' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 7, 15)) + create(:term, uid: 202530, year: 2025, season: :summer) + create(:term, uid: 202610, year: 2025, season: :fall) + end + + it 'returns fall of same year' do + expect(Term.next.season).to eq('fall') + expect(Term.next.year).to eq(2025) + end + end + end + + describe '.current_uid' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 10, 15)) + create(:term, uid: 202610, year: 2025, season: :fall) + end + + it 'returns the uid of the current term' do + expect(Term.current_uid).to eq(202610) + end + + it 'returns nil if no current term exists' do + Term.destroy_all + expect(Term.current_uid).to be_nil + end + end + + describe '.next_uid' do + before do + allow(Date).to receive(:today).and_return(Date.new(2025, 10, 15)) + create(:term, uid: 202610, year: 2025, season: :fall) + create(:term, uid: 202620, year: 2026, season: :spring) + end + + it 'returns the uid of the next term' do + expect(Term.next_uid).to eq(202620) + end + + it 'returns nil if no next term exists' do + Term.find_by(uid: 202620).destroy + expect(Term.next_uid).to be_nil + end + end + + describe '.exists_by_uid?' do + let!(:term) { create(:term, uid: 202610, year: 2025, season: :fall) } + + it 'returns true if term exists' do + expect(Term.exists_by_uid?(202610)).to be true + end + + it 'returns false if term does not exist' do + expect(Term.exists_by_uid?(999999)).to be false + end + end + + describe '#name' do + it 'returns formatted name for fall term' do + term = build(:term, year: 2025, season: :fall) + expect(term.name).to eq('Fall 2025') + end + + it 'returns formatted name for spring term' do + term = build(:term, year: 2025, season: :spring) + expect(term.name).to eq('Spring 2025') + end + + it 'returns formatted name for summer term' do + term = build(:term, year: 2025, season: :summer) + expect(term.name).to eq('Summer 2025') + end + end end diff --git a/spec/models/user_extension_config_spec.rb b/spec/models/user_extension_config_spec.rb new file mode 100644 index 00000000..71d81685 --- /dev/null +++ b/spec/models/user_extension_config_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe UserExtensionConfig, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/api/user_extension_config_spec.rb b/spec/requests/api/user_extension_config_spec.rb new file mode 100644 index 00000000..5db112e3 --- /dev/null +++ b/spec/requests/api/user_extension_config_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Api::UserExtensionConfigs", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/services/course_processor_service_spec.rb b/spec/services/course_processor_service_spec.rb new file mode 100644 index 00000000..e32b267d --- /dev/null +++ b/spec/services/course_processor_service_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CourseProcessorService, type: :service do + let(:user) { create(:user) } + let(:term) { create(:term, uid: 202610, year: 2025, season: :fall) } + + describe '#call' do + context 'validation' do + it 'raises error when courses is nil' do + expect { + described_class.new(nil, user).call + }.to raise_error(ArgumentError, /courses cannot be nil/) + end + + it 'raises error when courses is not an array' do + expect { + described_class.new("not an array", user).call + }.to raise_error(ArgumentError, /courses must be an array/) + end + + it 'raises error when courses is empty' do + expect { + described_class.new([], user).call + }.to raise_error(ArgumentError, /courses cannot be empty/) + end + + it 'raises error when course is not a hash' do + courses = ["not a hash"] + expect { + described_class.new(courses, user).call + }.to raise_error(ArgumentError, /must be a hash/) + end + + it 'raises error when crn is missing' do + courses = [{ term: 202610 }] + expect { + described_class.new(courses, user).call + }.to raise_error(ArgumentError, /missing required field: crn/) + end + + it 'raises error when term is missing' do + courses = [{ crn: 12345 }] + expect { + described_class.new(courses, user).call + }.to raise_error(ArgumentError, /missing required field: term/) + end + + it 'raises error when term UID is not numeric' do + courses = [{ crn: 12345, term: "invalid" }] + expect { + described_class.new(courses, user).call + }.to raise_error(ArgumentError, /invalid term UID/) + end + end + + context 'term lookup' do + it 'raises InvalidTermError when term does not exist' do + courses = [{ crn: 12345, term: 999999 }] + + expect { + described_class.new(courses, user).call + }.to raise_error(InvalidTermError) do |error| + expect(error.uid).to eq(999999) + expect(error.message).to include("Term with UID 999999 not found") + end + end + + it 'successfully finds term when it exists' do + courses = [{ + crn: 12345, + term: term.uid, + start: Date.today, + end: Date.today + 90.days, + courseNumber: "CS101" + }] + + # Mock LeopardWebService responses + allow(LeopardWebService).to receive(:get_class_details).and_return({ + associated_term: "Fall 2025", + subject: "CS", + title: "Intro to CS", + schedule_type: "Lecture (LEC)", + section_number: "01", + credit_hours: 3, + grade_mode: "Normal" + }) + + allow(LeopardWebService).to receive(:get_faculty_meeting_times).and_return({ + "fmt" => [] + }) + + expect { + described_class.new(courses, user).call + }.not_to raise_error + end + end + + context 'deduplication' do + before do + allow(LeopardWebService).to receive(:get_class_details).and_return({ + associated_term: "Fall 2025", + subject: "CS", + title: "Intro to CS", + schedule_type: "Lecture (LEC)", + section_number: "01", + credit_hours: 3, + grade_mode: "Normal" + }) + + allow(LeopardWebService).to receive(:get_faculty_meeting_times).and_return({ + "fmt" => [] + }) + end + + it 'deduplicates courses by CRN and term' do + courses = [ + { crn: 12345, term: term.uid, start: Date.today, end: Date.today + 90.days, courseNumber: "CS101" }, + { crn: 12345, term: term.uid, start: Date.today, end: Date.today + 90.days, courseNumber: "CS101" }, + { crn: 12345, term: term.uid, start: Date.today, end: Date.today + 90.days, courseNumber: "CS101" } + ] + + # Should only call LeopardWebService once + expect(LeopardWebService).to receive(:get_class_details).once + expect(LeopardWebService).to receive(:get_faculty_meeting_times).once + + result = described_class.new(courses, user).call + expect(result.length).to eq(1) + end + end + end +end