Skip to content
Closed
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
156 changes: 152 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
# 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 <token>` 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`
12 changes: 11 additions & 1 deletion app/controllers/admin/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/api/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -127,7 +127,7 @@ def fetch_processed_courses(courses, user)
}
end.compact

processed_data

end

end
Expand Down
24 changes: 24 additions & 0 deletions app/controllers/api/misc_controller.rb
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions app/controllers/api/user_extension_config_controller.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion app/controllers/api/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
58 changes: 52 additions & 6 deletions app/controllers/calendars_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions app/errors/invalid_term_error.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/helpers/api/user_extension_config_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Api::UserExtensionConfigHelper
end
Loading
Loading