Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
23e5eff
Merge pull request #71 from cs169/feature/teacher-verification-notes
kienthuynh Apr 2, 2026
775d0e1
Merge branch 'main' into implement-redirect
kienthuynh Apr 2, 2026
3717165
Merge pull request #96 from cs169/implement-redirect
kienthuynh Apr 2, 2026
3135c77
Merge pull request #88 from cs169/readd-school-merging
kienthuynh Apr 2, 2026
94abf7f
Merge pull request #89 from cs169/add-school-location-to-teacher-view
kienthuynh Apr 2, 2026
97e06cb
Add hidden search results notice to teachers index
kienthuynh Apr 22, 2026
03658ce
Add database migrations for MailBluster integration
Apr 10, 2026
288237e
Add MailblusterService with API wrapper
Apr 10, 2026
f2a2036
Add MailBluster initializer with API key configuration
Apr 10, 2026
2272e27
Auto-sync teacher to MailBluster on approval
Apr 10, 2026
d76e088
Auto-sync to MailBluster when personal email is added
Apr 10, 2026
e24948b
Add routes for MailBluster sync actions
Apr 10, 2026
56dfba3
Add sync_mailbluster and sync_all_mailbluster controller actions
Apr 10, 2026
9d59f9d
Add 'Sync All to MailBluster' button on teachers index page
Apr 10, 2026
48f399f
Add MailBluster sync card to teacher show page
Apr 10, 2026
fb7f136
Add MailBluster helpers to Teacher model and update CSV export
Apr 10, 2026
17a1bea
Add delivery tracking scopes and methods to EmailAddress model
Apr 10, 2026
3dbaae5
Update TeacherHelper with bounce badge and sync status helpers
Apr 10, 2026
84e69ad
Add MailBluster sync status column to teachers table
Apr 10, 2026
13531b1
Add comprehensive RSpec tests for MailblusterService
Apr 10, 2026
3f1e0d1
Add controller tests for MailBluster sync actions
Apr 10, 2026
103e20c
Add model tests for MailBluster and email delivery tracking
Apr 10, 2026
50801fc
Add EmailAddressesController tests for MailBluster sync on email add
Apr 10, 2026
b491938
Add Cucumber feature tests for MailBluster sync UI
Apr 10, 2026
f32a065
Fix Cucumber tests: resolve email fixture collision and button assert…
Apr 10, 2026
8fc59c9
Add additional Cucumber scenarios for MailBluster integration
Apr 10, 2026
6f9bb6f
Add Rake tasks for MailBluster sync management
Apr 10, 2026
91f5071
Improve error handling in MailblusterService#sync_teacher
Apr 10, 2026
5e50b9a
Auto-sync to MailBluster when application status changes
Apr 10, 2026
8e3a2f0
Delete MailBluster lead when destroying a teacher
Apr 10, 2026
ffcb252
Add MailBluster integration documentation to README
Apr 10, 2026
868730e
Re-sync to MailBluster when email addresses are deleted
Apr 10, 2026
683bc4e
Add rate limit delay between bulk MailBluster API calls
Apr 10, 2026
27e0088
Link synced badge to MailBluster profile in teacher table
Apr 10, 2026
1afabde
Add helper specs for MailBluster sync status and email labels
Apr 10, 2026
41e4b42
Update model annotations for email_address_spec
Apr 10, 2026
8ca29ea
Fix RuboCop offenses in MailblusterService
Apr 12, 2026
019481a
Fix test data leak from before(:all) loading seeds
Apr 12, 2026
1d3c266
Expand seed data with diverse teachers and schools
Apr 12, 2026
9677917
Hide MailBluster column on index
Apr 18, 2026
02b1060
Remove MB Sync column in dashboard view
kienthuynh Apr 23, 2026
3c2864e
Edit seed data for future integration
kienthuynh Apr 23, 2026
8785523
update primary and bounced email tags
kienthuynh Apr 23, 2026
5dbaaf0
lint
kienthuynh Apr 23, 2026
5bca40b
implement course filter
kienthuynh Apr 23, 2026
7f79f51
Fix personal email deletion confirm and last-email guard
NishK05 Apr 22, 2026
2f43a81
Merge pull request #108 from cs169/EmailModalFeat
NishK05 Apr 23, 2026
08fed1d
Merge branch 'beautyjoy:main' into main
kienthuynh Apr 28, 2026
e3e53df
Add sort indicator arrows and pointer cursor to DataTable headers
Apr 10, 2026
9e66c9e
Merge pull request #104 from cs169/feature/datatable-sort-indicators
kienthuynh Apr 28, 2026
0eae50e
added rspec test and view teacher url to the form submission email te…
NishK05 Apr 23, 2026
6737645
Add JSON DataTables endpoint for schools index
Apr 10, 2026
634689c
Test and verify SchoolDatatable JSON response shape and attributes
Apr 10, 2026
f493016
Add server-side search across name, city, state, country, website
Apr 10, 2026
ae48092
Add pagination spec for schools DataTables endpoint
Apr 10, 2026
59e3933
Switch schools index to server-side DataTables table shell
Apr 10, 2026
a6378d7
Wire up server-side DataTables JS and add Cucumber scenarios
Apr 10, 2026
3c5218c
lint
kienthuynh Apr 28, 2026
fc17328
Merge pull request #101 from cs169/feature/mailbluster
kienthuynh May 1, 2026
c437bc5
Merge pull request #105 from cs169/feature/server-side-datatables-sch…
kienthuynh May 1, 2026
7c60cbe
Merge pull request #109 from cs169/feature/hidden-search-results
kienthuynh May 1, 2026
9d0f0b0
Merge pull request #110 from cs169/feature/email-tags
kienthuynh May 1, 2026
516c67e
Use configured host for teacher email URLs
NishK05 May 1, 2026
6034c15
Add contributions section to README
hagenhaeussler May 1, 2026
36f84af
Merge branch 'main' into Feature/TeacherURLinFormSubmissionTemplate
kienthuynh May 5, 2026
8536bd8
Merge pull request #111 from cs169/Feature/TeacherURLinFormSubmission…
kienthuynh May 5, 2026
e90cec3
Merge branch 'main' into feature/course-filter
kienthuynh May 6, 2026
bace1ed
Fix rspec syntax errors
kienthuynh May 6, 2026
c1eacda
lint
kienthuynh May 6, 2026
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ S3_BUCKET_NAME=[NEW ONE]
S3_REGION=us-west-2
S3_SECRET_ACCESS_KEY=[NEW ONE]
SECRET_KEY_BASE=[NEW ONE]
MAILBLUSTER_API_KEY=[NEW ONE]
SENTRY_DSN=
SNAP_CLIENT_SECRET='ignore'
SNAP_CLIENT_URL='https://forum.snap.berkeley.edu/session/sso_provider'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@ cc-test-reporter

# CSV data files
*.csv

*.claude/
CLAUDE.md
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ gem "activerecord-import", require: false
gem "httparty", "~> 0.21.0"

gem "country_select", "~> 8.0"
gem "ajax-datatables-rails"

group :development do
gem "annotate"
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
ajax-datatables-rails (1.5.0)
rails (>= 6.0)
zeitwerk
annotate (3.1.1)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
Expand Down Expand Up @@ -567,6 +570,7 @@ PLATFORMS

DEPENDENCIES
activerecord-import
ajax-datatables-rails
annotate
aws-sdk-s3
axe-core-cucumber
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,44 @@ If bundler install runs successfully, continue with the following commands to co
- `heroku config:set ...` for each of the environment variables.
- `heroku open`

## MailBluster Integration

The app integrates with [MailBluster](https://mailbluster.com/) for email marketing and newsletter management.

### Configuration

Set the `MAILBLUSTER_API_KEY` environment variable:

```bash
# Local development
export MAILBLUSTER_API_KEY=your_api_key_here

# Heroku
heroku config:set MAILBLUSTER_API_KEY=your_api_key_here
```

### Features

- **Auto-sync on approval**: When a teacher is validated, their info is synced to MailBluster as a lead
- **Auto-sync on status change**: Updating a teacher's application status triggers a MailBluster sync
- **Auto-sync on email add**: Adding a new email address to a validated teacher triggers sync
- **Manual sync**: Admins can sync individual teachers or all validated teachers from the UI
- **Lead cleanup**: Deleting a teacher removes their lead from MailBluster
- **Delivery tracking**: Email addresses track `emails_sent`, `emails_delivered`, and `bounced` status

### Rake Tasks

```bash
# Sync all validated teachers to MailBluster
bundle exec rake mailbluster:sync_all

# Sync a single teacher by ID
bundle exec rake mailbluster:sync_teacher[123]

# Check sync status
bundle exec rake mailbluster:status
```



### CodeClimate Local Test
Expand All @@ -168,3 +206,6 @@ TODO: Link to download CodeClimate binaries for macOS.
```
https://codeclimate.com/downloads/test-reporter/test-reporter-latest-darwin-amd64
```

### Contributions
[Hagen Haeussler](https://www.linkedin.com/in/hagen-h%C3%A4u%C3%9Fler-6bb293289/?lipi=urn%3Ali%3Apage%3Ad_flagship3_profile_view_base_contact_details%3BeCJ75lhZToWJMy945exu1g%3D%3D)
17 changes: 16 additions & 1 deletion app/controllers/email_addresses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

class EmailAddressesController < ApplicationController
before_action :require_login
before_action :require_admin
before_action :set_teacher
before_action :require_email_edit_permission

def create
email = params[:email].to_s.strip
Expand All @@ -13,6 +13,8 @@ def create
end

@teacher.email_addresses.create!(email:, primary: false)
# Sync teacher to MailBluster when a new email is added
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
redirect_to teacher_path(@teacher), notice: "Personal email addresses added successfully."
rescue ActiveRecord::RecordInvalid => e
error_message = e.record&.errors&.full_messages&.join(", ")
Expand All @@ -27,7 +29,14 @@ def destroy
return
end

if @teacher.email_addresses.count <= 1
redirect_to teacher_path(@teacher), alert: "Add another email before deleting this one."
return
end

email.destroy!
# Re-sync to MailBluster since email list changed
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured? && @teacher.validated?
redirect_to teacher_path(@teacher), notice: "Email address deleted successfully."
rescue ActiveRecord::RecordNotFound
redirect_to teacher_path(@teacher), alert: "Email address not found."
Expand All @@ -39,4 +48,10 @@ def destroy
def set_teacher
@teacher = Teacher.find(params[:teacher_id])
end

def require_email_edit_permission
return if is_admin? || current_user.id == @teacher.id

redirect_to edit_teacher_path(current_user.id), alert: "You can only edit your own information"
end
end
5 changes: 4 additions & 1 deletion app/controllers/schools_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ class SchoolsController < ApplicationController
before_action :require_admin

def index
@schools = School.all.order(:name)
respond_to do |format|
format.html
format.json { render json: SchoolDatatable.new(params) }
end
end

def show
Expand Down
50 changes: 48 additions & 2 deletions app/controllers/teachers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ class TeachersController < ApplicationController
include CsvProcess

before_action :load_pages, only: [:new, :create, :edit, :update]
before_action :load_teacher, except: [:new, :index, :create, :import, :search]
before_action :load_teacher, except: [:new, :index, :create, :import, :search, :sync_all_mailbluster]
before_action :sanitize_params, only: [:new, :create, :edit, :update]
before_action :require_login, except: [:new, :create]
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search]
before_action :require_admin, only: [:validate, :deny, :destroy, :index, :show, :search, :sync_mailbluster, :sync_all_mailbluster]
before_action :require_edit_permission, only: [:edit, :update, :resend_welcome_email]

rescue_from ActiveRecord::RecordNotUnique, with: :deny_access
Expand Down Expand Up @@ -131,6 +131,7 @@ def update

attach_new_files_if_any
send_email_if_application_status_changed_and_email_resend_enabled
sync_to_mailbluster_if_status_changed

if fail_to_update
return
Expand Down Expand Up @@ -162,6 +163,19 @@ def send_email_if_application_status_changed_and_email_resend_enabled
end
end

def sync_to_mailbluster_if_status_changed
return unless MailblusterService.configured?
return unless @teacher.application_status_changed?

if @teacher.validated?
MailblusterService.create_or_update_lead(@teacher)
elsif @teacher.application_status_was == "validated"
# If teacher was validated but status changed away, update MailBluster
# to mark them as unsubscribed
MailblusterService.create_or_update_lead(@teacher)
end
end

def request_info
@teacher.info_needed!
if !params[:skip_email].present?
Expand All @@ -173,6 +187,7 @@ def request_info
def validate
@teacher.validated!
TeacherMailer.welcome_email(@teacher).deliver_now
MailblusterService.create_or_update_lead(@teacher) if MailblusterService.configured?
redirect_to root_path
end

Expand All @@ -185,6 +200,9 @@ def deny
end

def destroy
if MailblusterService.configured? && @teacher.primary_email.present?
MailblusterService.delete_lead(@teacher.primary_email)
end
@teacher.destroy!
flash[:info] = "Deleted #{@teacher.full_name} successfully."
redirect_to teachers_path
Expand All @@ -208,6 +226,34 @@ def import
redirect_to teachers_path
end

def sync_mailbluster
unless MailblusterService.configured?
redirect_to teacher_path(@teacher), alert: "MailBluster API key is not configured."
return
end

result = MailblusterService.sync_teacher(@teacher)
if result[:success]
redirect_to teacher_path(@teacher), notice: "Successfully synced #{@teacher.full_name} to MailBluster."
else
redirect_to teacher_path(@teacher), alert: "Failed to sync #{@teacher.full_name} to MailBluster. #{result[:error]}"
end
end

def sync_all_mailbluster
unless MailblusterService.configured?
redirect_to teachers_path, alert: "MailBluster API key is not configured."
return
end

results = MailblusterService.sync_all_teachers
flash[:notice] = "MailBluster sync complete: #{results[:synced]} synced, #{results[:failed]} failed, #{results[:skipped]} skipped."
if results[:errors].any?
flash[:alert] = "Errors: #{results[:errors].first(5).join('; ')}"
end
redirect_to teachers_path
end

private
def load_teacher
@teachers = Teacher.all
Expand Down
63 changes: 63 additions & 0 deletions app/datatables/school_datatable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# frozen_string_literal: true

class SchoolDatatable < AjaxDatatablesRails::ActiveRecord
include Rails.application.routes.url_helpers

def view_columns
@view_columns ||= {
name: { source: "School.name", cond: :like },
location: { source: "School.city", cond: :like },
country: { source: "School.country", cond: :like },
website: { source: "School.website", cond: :like },
teachers_count: { source: "School.teachers_count", searchable: false },
grade_level: { source: "School.grade_level", searchable: false },
actions: { source: "School.id", searchable: false, orderable: false }
}
end

def data
records.map do |record|
{
name: name_link(record),
location: record.location,
country: record.country,
website: website_link(record),
teachers_count: record.teachers_count,
grade_level: record.display_grade_level,
actions: action_links(record),
DT_RowId: record.id
}
end
end

def get_raw_records
School.all
end

private
SEARCHABLE_COLUMNS = %w[name city state country website].freeze

def filter_records(records)
search_value = params.dig(:search, :value).presence
return records unless search_value

conditions = SEARCHABLE_COLUMNS.map { |col| "schools.#{col} ILIKE :q" }.join(" OR ")
records.where(conditions, q: "%#{search_value}%")
end

def name_link(record)
"<a href=\"#{school_path(record)}\">#{ERB::Util.html_escape(record.name)}</a>".html_safe
end

def website_link(record)
url = record.website
display = url.to_s.truncate(30)
"<a href=\"#{ERB::Util.html_escape(url)}\" target=\"_blank\">#{ERB::Util.html_escape(display)}</a>".html_safe
end

def action_links(record)
edit = "<a class=\"btn btn-info\" href=\"#{edit_school_path(record)}\">Edit</a>"
delete = "<a class=\"btn btn-outline-danger\" data-confirm=\"Are you sure?\" rel=\"nofollow\" data-method=\"delete\" href=\"#{school_path(record)}\">❌</a>"
"<span class=\"btn-group\">#{edit} #{delete}</span>".html_safe
end
end
20 changes: 18 additions & 2 deletions app/helpers/teacher_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@ def ip_history_display(teacher)
end

def email_address_label(email)
return unless email.primary?
'&nbsp; <span class="badge badge-pill badge-primary h6">primary</span>'.html_safe
labels = []
labels << '<span class="primary-email-dot" data-toggle="tooltip" data-placement="top" title="Primary email"></span>' if email.primary?
labels << '<span class="bounced-email-dot" data-toggle="tooltip" data-placement="top" title="Bounced email"></span>' if email.bounced?
return nil if labels.empty?
labels.join("").html_safe
end

def mailbluster_sync_status(teacher)
if teacher.mailbluster_synced?
url = teacher.mailbluster_profile_url
if url
link_to('<span class="badge badge-success">Synced</span>'.html_safe, url, target: "_blank", title: "View in MailBluster")
else
'<span class="badge badge-success">Synced</span>'.html_safe
end
else
'<span class="badge badge-secondary">Not Synced</span>'.html_safe
end
end
end
1 change: 1 addition & 0 deletions app/javascript/packs/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import "datatables.net-buttons-bs4";
import 'datatables.net-buttons/js/buttons.html5.js';

import './datatables.js';
import './schools_index.js';
import '../styles/application.scss';
import './schools.js';

Expand Down
Loading
Loading