diff --git a/app/controllers/content_modules_controller.rb b/app/controllers/content_modules_controller.rb
new file mode 100644
index 0000000..7fa64c5
--- /dev/null
+++ b/app/controllers/content_modules_controller.rb
@@ -0,0 +1,53 @@
+class ContentModulesController < AdminController
+ before_action :set_content_module, only: %i[edit update destroy]
+
+ def index
+ @programs = Program.order(:name)
+ @active_program = @programs.find { |p| p.id.to_s == params[:program_id] } || @programs.first
+ @modules_by_level = @active_program
+ .content_modules
+ .includes(:links)
+ .group_by(&:level)
+ end
+
+ def new
+ @content_module = ContentModule.new
+ end
+
+ def create
+ @content_module = ContentModule.new(content_module_params)
+
+ if @content_module.save
+ redirect_to content_modules_path, notice: "Module was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @content_module.update(content_module_params)
+ redirect_to content_modules_path, notice: "Module was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @content_module.destroy!
+ redirect_to content_modules_path, notice: "Module was successfully deleted.", status: :see_other
+ rescue ActiveRecord::DeleteRestrictionError
+ redirect_to content_modules_path, alert: "Cannot delete a module that has been assigned to classrooms."
+ end
+
+ private
+ def set_content_module
+ @content_module = ContentModule.find(params.expect(:id))
+ end
+
+ def content_module_params
+ params.expect(content_module: [ :name, :program_id, :level, :position ])
+ end
+end
diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb
new file mode 100644
index 0000000..4f9ca7c
--- /dev/null
+++ b/app/controllers/links_controller.rb
@@ -0,0 +1,47 @@
+class LinksController < AdminController
+ before_action :set_content_module, only: %i[new create]
+ before_action :set_link, only: %i[edit update destroy]
+
+ def new
+ @link = @content_module.links.build
+ end
+
+ def create
+ @link = @content_module.links.build(link_params)
+
+ if @link.save
+ redirect_to edit_content_module_path(@content_module), notice: "Link was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @link.update(link_params)
+ redirect_to edit_content_module_path(@link.content_module), notice: "Link was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @link.destroy!
+ redirect_to edit_content_module_path(@link.content_module), notice: "Link was successfully deleted.", status: :see_other
+ end
+
+ private
+ def set_content_module
+ @content_module = ContentModule.find(params.expect(:content_module_id))
+ end
+
+ def set_link
+ @link = Link.find(params.expect(:id))
+ end
+
+ def link_params
+ params.expect(link: [ :title, :url, :link_type, :position ])
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..2020e97 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,8 @@
module ApplicationHelper
+ def safe_external_url(url)
+ uri = URI.parse(url.to_s)
+ uri.scheme.in?(%w[http https]) ? url : "#"
+ rescue URI::InvalidURIError
+ "#"
+ end
end
diff --git a/app/models/content_module.rb b/app/models/content_module.rb
new file mode 100644
index 0000000..061c5b4
--- /dev/null
+++ b/app/models/content_module.rb
@@ -0,0 +1,10 @@
+class ContentModule < ApplicationRecord
+ belongs_to :program
+ has_many :links, dependent: :destroy
+
+ enum :level, { basic: "basic", moderate: "moderate", advanced: "advanced" }, validate: true
+
+ validates :name, :level, presence: true
+
+ default_scope { order(:position) }
+end
diff --git a/app/models/link.rb b/app/models/link.rb
new file mode 100644
index 0000000..995a7c4
--- /dev/null
+++ b/app/models/link.rb
@@ -0,0 +1,10 @@
+class Link < ApplicationRecord
+ belongs_to :content_module
+
+ enum :link_type, { survey: "survey", game: "game" }, validate: true
+
+ validates :title, :url, :link_type, presence: true
+ validates :url, format: { with: /\Ahttps?:\/\/.+\z/i, message: "must start with http:// or https://" }, allow_blank: true
+
+ default_scope { order(:position) }
+end
diff --git a/app/models/program.rb b/app/models/program.rb
index c15e52c..edeeb93 100644
--- a/app/models/program.rb
+++ b/app/models/program.rb
@@ -1,4 +1,5 @@
class Program < ApplicationRecord
has_many :classroom_programs, dependent: :destroy
has_many :classrooms, through: :classroom_programs
+ has_many :content_modules, dependent: :destroy
end
diff --git a/app/views/content_modules/_form.html.erb b/app/views/content_modules/_form.html.erb
new file mode 100644
index 0000000..8b20996
--- /dev/null
+++ b/app/views/content_modules/_form.html.erb
@@ -0,0 +1,30 @@
+<%= form_with(model: content_module) do |form| %>
+
+ <% if content_module.errors.any? %>
+
+
<%= pluralize(content_module.errors.count, "error") %> prohibited this module from being saved:
+
+ <% content_module.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+ <%= form.label :name, class: "label" %>
+ <%= form.text_field :name, class: "input w-full" %>
+
+ <%= form.label :program_id, "Program", class: "label" %>
+ <%= form.collection_select :program_id, Program.order(:name), :id, :name,
+ { include_blank: "Select a program" }, class: "select w-full" %>
+
+ <%= form.label :level, class: "label" %>
+ <%= form.select :level, ContentModule.levels.keys.map { |l| [ l.humanize, l ] },
+ { include_blank: "Select a level" }, class: "select w-full" %>
+
+ <%= form.label :position, class: "label" %>
+ <%= form.number_field :position, class: "input w-full" %>
+
+ <%= form.submit class: "btn btn-primary" %>
+
+<% end %>
diff --git a/app/views/content_modules/edit.html.erb b/app/views/content_modules/edit.html.erb
new file mode 100644
index 0000000..4d35c67
--- /dev/null
+++ b/app/views/content_modules/edit.html.erb
@@ -0,0 +1,56 @@
+<% content_for :title, "Edit Module" %>
+
+
+
+
+
+
+
Edit Module
+ <%= render "form", content_module: @content_module %>
+
+
+
+
+
+
+
Links
+ <%= link_to "Add link", new_content_module_link_path(@content_module), class: "btn btn-sm btn-primary" %>
+
+ <% if @content_module.links.any? %>
+
+
+
+ | Title |
+ Type |
+ Actions |
+
+
+
+ <% @content_module.links.each do |link| %>
+
+ | <%= link.title %> ↗ |
+ <%= link.link_type.humanize %> |
+
+
+ <%= link_to "Edit", edit_link_path(link), class: "btn btn-xs btn-primary" %>
+ <%= button_to "Delete", link_path(link), method: :delete,
+ data: { "turbo-confirm": "Delete this link?" },
+ class: "btn btn-xs btn-error" %>
+
+ |
+
+ <% end %>
+
+
+ <% else %>
+
No links yet.
+ <% end %>
+
+
+
+
diff --git a/app/views/content_modules/index.html.erb b/app/views/content_modules/index.html.erb
new file mode 100644
index 0000000..6fcd4ca
--- /dev/null
+++ b/app/views/content_modules/index.html.erb
@@ -0,0 +1,57 @@
+<% content_for :title, "Content Modules" %>
+
+<%= notice %>
+<%= alert %>
+
+
+ Content Modules
+ <%= link_to "New module", new_content_module_path, class: "btn btn-primary" %>
+
+
+
+ <% @programs.each do |program| %>
+ <%= link_to content_modules_path(program_id: program.id),
+ role: "tab",
+ class: "tab #{"tab-active" if program == @active_program}" do %>
+ <%= program.name %>
+ <% end %>
+ <% end %>
+
+
+<% ContentModule.levels.each_key do |level| %>
+ <% modules = @modules_by_level[level] || [] %>
+
+ <%= level.humanize %>
+ <% if modules.any? %>
+
+
+
+
+ | Name |
+ Links |
+ Actions |
+
+
+
+ <% modules.each do |mod| %>
+
+ | <%= mod.name %> |
+ <%= mod.links.size %> |
+
+
+ <%= link_to "Edit", edit_content_module_path(mod), class: "btn btn-sm btn-primary" %>
+ <%= button_to "Delete", content_module_path(mod), method: :delete,
+ data: { "turbo-confirm": "Delete this module?" },
+ class: "btn btn-sm btn-error" %>
+
+ |
+
+ <% end %>
+
+
+
+ <% else %>
+ No modules for this level yet.
+ <% end %>
+
+<% end %>
diff --git a/app/views/content_modules/new.html.erb b/app/views/content_modules/new.html.erb
new file mode 100644
index 0000000..72dab3e
--- /dev/null
+++ b/app/views/content_modules/new.html.erb
@@ -0,0 +1,16 @@
+<% content_for :title, "New Module" %>
+
+
+
+
+
+
New Module
+ <%= render "form", content_module: @content_module %>
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 8fcd954..bad086a 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -24,6 +24,9 @@
+ <% if controller.is_a?(AdminController) %>
+ <%= render "shared/admin_nav" %>
+ <% end %>
<%= yield %>
diff --git a/app/views/links/_form.html.erb b/app/views/links/_form.html.erb
new file mode 100644
index 0000000..964f54f
--- /dev/null
+++ b/app/views/links/_form.html.erb
@@ -0,0 +1,29 @@
+<%= form_with(model: link.persisted? ? link : [ link.content_module, link ]) do |form| %>
+
+ <% if link.errors.any? %>
+
+
<%= pluralize(link.errors.count, "error") %> prohibited this link from being saved:
+
+ <% link.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+ <%= form.label :title, class: "label" %>
+ <%= form.text_field :title, class: "input w-full" %>
+
+ <%= form.label :url, "URL", class: "label" %>
+ <%= form.url_field :url, class: "input w-full" %>
+
+ <%= form.label :link_type, "Type", class: "label" %>
+ <%= form.select :link_type, Link.link_types.keys.map { |t| [ t.humanize, t ] },
+ { include_blank: "Select a type" }, class: "select w-full" %>
+
+ <%= form.label :position, class: "label" %>
+ <%= form.number_field :position, class: "input w-full" %>
+
+ <%= form.submit class: "btn btn-primary" %>
+
+<% end %>
diff --git a/app/views/links/edit.html.erb b/app/views/links/edit.html.erb
new file mode 100644
index 0000000..4803953
--- /dev/null
+++ b/app/views/links/edit.html.erb
@@ -0,0 +1,17 @@
+<% content_for :title, "Edit Link" %>
+
+
+
+
+
+
Edit Link
+ <%= render "form", link: @link %>
+
+
+
diff --git a/app/views/links/new.html.erb b/app/views/links/new.html.erb
new file mode 100644
index 0000000..fa0abb4
--- /dev/null
+++ b/app/views/links/new.html.erb
@@ -0,0 +1,17 @@
+<% content_for :title, "New Link" %>
+
+
+
+
+
+
New Link
+ <%= render "form", link: @link %>
+
+
+
diff --git a/app/views/shared/_admin_nav.html.erb b/app/views/shared/_admin_nav.html.erb
new file mode 100644
index 0000000..4ecab84
--- /dev/null
+++ b/app/views/shared/_admin_nav.html.erb
@@ -0,0 +1,20 @@
+
diff --git a/config/routes.rb b/config/routes.rb
index 0b0b9f9..06d66af 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,9 @@
resources :students, shallow: true
resources :classrooms, shallow: true, only: %i[edit update]
end
+ resources :content_modules do
+ resources :links, shallow: true
+ end
end
root to: "schools#index"
diff --git a/db/migrate/20260517180805_create_content_modules.rb b/db/migrate/20260517180805_create_content_modules.rb
new file mode 100644
index 0000000..4ab2cfa
--- /dev/null
+++ b/db/migrate/20260517180805_create_content_modules.rb
@@ -0,0 +1,12 @@
+class CreateContentModules < ActiveRecord::Migration[8.1]
+ def change
+ create_table :content_modules do |t|
+ t.references :program, null: false, foreign_key: true
+ t.string :level, null: false
+ t.string :name, null: false
+ t.integer :position
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260517180806_create_links.rb b/db/migrate/20260517180806_create_links.rb
new file mode 100644
index 0000000..4f1f3f7
--- /dev/null
+++ b/db/migrate/20260517180806_create_links.rb
@@ -0,0 +1,13 @@
+class CreateLinks < ActiveRecord::Migration[8.1]
+ def change
+ create_table :links do |t|
+ t.references :content_module, null: false, foreign_key: true
+ t.string :title, null: false
+ t.string :url, null: false
+ t.string :link_type, null: false
+ t.integer :position
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1d60635..ef80c4c 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: 2026_05_16_200133) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_17_180806) do
create_table "classroom_programs", force: :cascade do |t|
t.integer "classroom_id", null: false
t.datetime "created_at", null: false
@@ -33,6 +33,27 @@
t.index ["uuid"], name: "index_classrooms_on_uuid", unique: true
end
+ create_table "content_modules", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "level", null: false
+ t.string "name", null: false
+ t.integer "position"
+ t.integer "program_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["program_id"], name: "index_content_modules_on_program_id"
+ end
+
+ create_table "links", force: :cascade do |t|
+ t.integer "content_module_id", null: false
+ t.datetime "created_at", null: false
+ t.string "link_type", null: false
+ t.integer "position"
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.string "url", null: false
+ t.index ["content_module_id"], name: "index_links_on_content_module_id"
+ end
+
create_table "programs", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
@@ -87,6 +108,8 @@
add_foreign_key "classroom_programs", "classrooms"
add_foreign_key "classroom_programs", "programs"
add_foreign_key "classrooms", "schools"
+ add_foreign_key "content_modules", "programs"
+ add_foreign_key "links", "content_modules"
add_foreign_key "sessions", "users"
add_foreign_key "student_sessions", "students"
add_foreign_key "students", "classrooms"
diff --git a/db/seeds.rb b/db/seeds.rb
index 0d353ea..f0bca69 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -2,6 +2,17 @@
programs = Program.create! [ { name: "Know Your Health" }, { name: "3D Wellness" } ]
+kyh, tdw = programs
+ContentModule.levels.keys.each_with_index do |level, li|
+ [ kyh, tdw ].each do |program|
+ 2.times do |i|
+ mod = program.content_modules.create!(name: "#{program.name} #{level.humanize} Module #{i + 1}", level: level, position: i + 1)
+ mod.links.create!(title: "Survey #{i + 1}", url: "https://example.com/survey/#{program.id}-#{level}-#{i}", link_type: "survey", position: 1)
+ mod.links.create!(title: "Game #{i + 1}", url: "https://example.com/game/#{program.id}-#{level}-#{i}", link_type: "game", position: 2)
+ end
+ end
+end
+
User.find_or_create_by!(email_address: "admin@example.com") do |user|
user.name = "Admin"
user.password = "password"
diff --git a/test/controllers/content_modules_controller_test.rb b/test/controllers/content_modules_controller_test.rb
new file mode 100644
index 0000000..7e13da0
--- /dev/null
+++ b/test/controllers/content_modules_controller_test.rb
@@ -0,0 +1,57 @@
+require "test_helper"
+
+class ContentModulesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @mod = content_modules(:intro)
+ sign_in_as users(:admin)
+ end
+
+ test "should get index" do
+ get content_modules_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get new_content_module_url
+ assert_response :success
+ end
+
+ test "should create content module" do
+ assert_difference "ContentModule.count" do
+ post content_modules_url, params: {
+ content_module: { name: "New Module", program_id: programs(:kyh).id, level: "moderate", position: 1 }
+ }
+ end
+ assert_redirected_to content_modules_url
+ end
+
+ test "should not create with missing name" do
+ assert_no_difference "ContentModule.count" do
+ post content_modules_url, params: {
+ content_module: { name: "", program_id: programs(:kyh).id, level: "basic" }
+ }
+ end
+ assert_response :unprocessable_entity
+ end
+
+ test "should get edit" do
+ get edit_content_module_url(@mod)
+ assert_response :success
+ end
+
+ test "should update content module" do
+ patch content_module_url(@mod), params: {
+ content_module: { name: "Updated Name" }
+ }
+ assert_redirected_to content_modules_url
+ assert_equal "Updated Name", @mod.reload.name
+ end
+
+ test "should destroy content module" do
+ mod = content_modules(:advanced_wellness)
+ assert_difference "ContentModule.count", -1 do
+ delete content_module_url(mod)
+ end
+ assert_redirected_to content_modules_url
+ end
+end
diff --git a/test/controllers/links_controller_test.rb b/test/controllers/links_controller_test.rb
new file mode 100644
index 0000000..78dc4fe
--- /dev/null
+++ b/test/controllers/links_controller_test.rb
@@ -0,0 +1,50 @@
+require "test_helper"
+
+class LinksControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @link = links(:survey_one)
+ @mod = content_modules(:intro)
+ sign_in_as users(:admin)
+ end
+
+ test "should get new" do
+ get new_content_module_link_url(@mod)
+ assert_response :success
+ end
+
+ test "should create link" do
+ assert_difference "Link.count" do
+ post content_module_links_url(@mod), params: {
+ link: { title: "New Survey", url: "https://example.com/new", link_type: "survey", position: 3 }
+ }
+ end
+ assert_redirected_to edit_content_module_url(@mod)
+ end
+
+ test "should not create with missing title" do
+ assert_no_difference "Link.count" do
+ post content_module_links_url(@mod), params: {
+ link: { title: "", url: "https://example.com", link_type: "survey" }
+ }
+ end
+ assert_response :unprocessable_entity
+ end
+
+ test "should get edit" do
+ get edit_link_url(@link)
+ assert_response :success
+ end
+
+ test "should update link" do
+ patch link_url(@link), params: { link: { title: "Updated Title" } }
+ assert_redirected_to edit_content_module_url(@mod)
+ assert_equal "Updated Title", @link.reload.title
+ end
+
+ test "should destroy link" do
+ assert_difference "Link.count", -1 do
+ delete link_url(@link)
+ end
+ assert_redirected_to edit_content_module_url(@mod)
+ end
+end
diff --git a/test/fixtures/content_modules.yml b/test/fixtures/content_modules.yml
new file mode 100644
index 0000000..66e70fa
--- /dev/null
+++ b/test/fixtures/content_modules.yml
@@ -0,0 +1,11 @@
+intro:
+ program: kyh
+ level: basic
+ name: Introduction to Health
+ position: 1
+
+advanced_wellness:
+ program: 3dw
+ level: advanced
+ name: Advanced Wellness
+ position: 1
diff --git a/test/fixtures/links.yml b/test/fixtures/links.yml
new file mode 100644
index 0000000..f1e1d81
--- /dev/null
+++ b/test/fixtures/links.yml
@@ -0,0 +1,13 @@
+survey_one:
+ content_module: intro
+ title: Health Survey
+ url: https://example.com/survey/1
+ link_type: survey
+ position: 1
+
+game_one:
+ content_module: intro
+ title: Health Game
+ url: https://example.com/game/1
+ link_type: game
+ position: 2
diff --git a/test/models/content_module_test.rb b/test/models/content_module_test.rb
new file mode 100644
index 0000000..b637878
--- /dev/null
+++ b/test/models/content_module_test.rb
@@ -0,0 +1,31 @@
+require "test_helper"
+
+class ContentModuleTest < ActiveSupport::TestCase
+ test "is valid with required fields" do
+ mod = ContentModule.new(program: programs(:kyh), level: "basic", name: "Test Module")
+ assert mod.valid?
+ end
+
+ test "is invalid without a name" do
+ mod = ContentModule.new(program: programs(:kyh), level: "basic")
+ assert_not mod.valid?
+ assert_includes mod.errors[:name], "can't be blank"
+ end
+
+ test "is invalid without a level" do
+ mod = ContentModule.new(program: programs(:kyh), name: "Test Module")
+ assert_not mod.valid?
+ end
+
+ test "is invalid without a program" do
+ mod = ContentModule.new(level: "basic", name: "Test Module")
+ assert_not mod.valid?
+ end
+
+ test "destroys associated links" do
+ mod = content_modules(:intro)
+ assert_difference "Link.count", -mod.links.count do
+ mod.destroy!
+ end
+ end
+end
diff --git a/test/models/link_test.rb b/test/models/link_test.rb
new file mode 100644
index 0000000..ec5e4b9
--- /dev/null
+++ b/test/models/link_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class LinkTest < ActiveSupport::TestCase
+ test "is valid with required fields" do
+ link = Link.new(content_module: content_modules(:intro), title: "My Link", url: "https://example.com", link_type: "survey")
+ assert link.valid?
+ end
+
+ test "is invalid without a title" do
+ link = Link.new(content_module: content_modules(:intro), url: "https://example.com", link_type: "survey")
+ assert_not link.valid?
+ assert_includes link.errors[:title], "can't be blank"
+ end
+
+ test "is invalid without a url" do
+ link = Link.new(content_module: content_modules(:intro), title: "My Link", link_type: "survey")
+ assert_not link.valid?
+ assert_includes link.errors[:url], "can't be blank"
+ end
+
+ test "is invalid without a link_type" do
+ link = Link.new(content_module: content_modules(:intro), title: "My Link", url: "https://example.com")
+ assert_not link.valid?
+ end
+end