<%= @beacon.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
Updated
+
<%= @beacon.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
+
+
+
+
+
+
+
+ API Key
+
+
+
+ <% if params[:api_key] %>
+
+
+ ✓ Beacon Provisioned - API Key Generated
+
+
+ <%= params[:api_key] %>
+
+
+
+
+
+
+
+
Save This Key
+
+ Copy this API key and configure it in your beacon deployment. This page will only show the full key immediately after creation. You can regenerate a new key anytime if needed.
+
+
+
+
+ <% else %>
+
+
+ API Key Prefix
+
+ <%= @beacon.api_key_prefix %>***
+
+
+
+
+
+
+
+
API Key Active
+
+ The API key for this beacon is active (shown by prefix above). The full key was displayed when the beacon was first created. If you need a new key, use the regenerate button below.
+
+
+
+
+
+ <%= button_to regenerate_key_beacon_path(@beacon), method: :post,
+ class: "w-full bg-amber-600 hover:bg-amber-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors inline-flex items-center justify-center gap-2",
+ data: { turbo_confirm: "Are you sure? This will invalidate the current API key and generate a new one. Any beacons using the old key will stop working." } do %>
+
+ Regenerate API Key
+ <% end %>
+
+ <%= button_to revoke_key_beacon_path(@beacon), method: :post,
+ class: "w-full bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors inline-flex items-center justify-center gap-2",
+ data: { turbo_confirm: "Are you sure? This will invalidate the current API key. Any beacons using the old key will stop working." } do %>
+
+ Revoke API Key
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+ Document Access Configuration
+
+
+
+
+
+
Basic Configuration
+
+
+
Language
+
+
+ <%= @beacon.language_name %>
+
+
+
+
+
Region
+
+
+ <%= @beacon.region_name %>
+
+
+
+
+
+
+
Access Filters
+
+
+
Providers
+
+ <% if @beacon.providers.any? %>
+
+ <% @beacon.providers.each do |provider| %>
+
+ <%= provider.name %>
+
+ <% end %>
+
+ <% else %>
+ All Providers
+ <% end %>
+
+
+
+
Topics
+
+ <% if @beacon.topics.any? %>
+
+ <% @beacon.topics.each do |topic| %>
+
+ <%= topic.title %>
+
+ <% end %>
+
+ <% else %>
+ All Topics
+ <% end %>
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb
index c65b9a46..aecd77a1 100644
--- a/app/views/layouts/_sidebar.html.erb
+++ b/app/views/layouts/_sidebar.html.erb
@@ -59,6 +59,13 @@
<% end %>
+
+ <%= link_to beacons_path, class: "sidebar__link #{active_link_class(beacons_path)}" do %>
+ 📡
+ Beacon Management
+ <% end %>
+
diff --git a/bin/docker-entrypoint.dev b/bin/docker-entrypoint.dev
index 6022d807..10104335 100755
--- a/bin/docker-entrypoint.dev
+++ b/bin/docker-entrypoint.dev
@@ -9,5 +9,8 @@ bundle check || bundle install
# If running the rails server then create or migrate existing database
./bin/rails db:prepare
+# Remove compiled asset manifest so Propshaft serves assets dynamically in development
+rm -f /app/public/assets/.manifest.json
+
# Execute the main command passed to docker run
exec "$@"
diff --git a/config/routes.rb b/config/routes.rb
index 41fda43a..d044bbbf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -21,6 +21,16 @@
put :provider, on: :collection
end
+ resources :beacons, except: :destroy do
+ collection do
+ get :filter_options
+ end
+ member do
+ post :regenerate_key
+ post :revoke_key
+ end
+ end
+
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
diff --git a/db/seeds.rb b/db/seeds.rb
index a89cf071..6b9540bc 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -81,7 +81,7 @@
{ email: "admin@mail.com", password: "test123", is_admin: true },
{ email: "me@mail.com", password: "test123" },
].map do |user_data|
- user = User.find_or_initialize_by(email: user_data[:email]).tap do |u|
+ User.find_or_initialize_by(email: user_data[:email]).tap do |u|
u.password = user_data[:password]
u.is_admin = user_data[:is_admin] || false
u.providers << Provider.first unless u.is_admin
@@ -95,7 +95,7 @@
end
10.times do
- user = User.find_or_initialize_by(email: Faker::Internet.email).tap do |u|
+ User.find_or_initialize_by(email: Faker::Internet.email).tap do |u|
u.password = Faker::Internet.password
u.is_admin = false
u.providers << Provider.first unless u.is_admin
diff --git a/docs/model_relationships.md b/docs/model_relationships.md
new file mode 100644
index 00000000..0f86e475
--- /dev/null
+++ b/docs/model_relationships.md
@@ -0,0 +1,151 @@
+# Model Relationships
+
+_(A Draft of v3 Architecture, helpful for me to understand and also to onboard others)_
+
+## The big picture
+
+SkillRx delivers continuing medical education (CME) to medical practitioners in remote areas. Understanding that mission makes the model hierarchy intuitive.
+
+**Topic is the protagonist.** A Topic is the actual CME content — the reason the app exists. Everything else exists to produce Topics, organize them, or get the right ones to the right place.
+
+**Provider is the author.** A Provider (an NGO, hospital system, or medical publisher) produces Topics in a given Language. Without Providers there is no content; without content there is no app.
+
+**Beacon is the delivery hero.** A Beacon represents a physical deployment — a device or installation at a remote clinic. It is configured for a specific Language and Region, and acts as a gatekeeper: filtering which Topics (and from which Providers) that location can access.
+
+The narrative arc of the data:
+
+> A **Provider** produces **Topics** (CME materials) in a given **Language**. A **Beacon** is deployed at a remote clinic — configured for a specific **Region** and **Language** — and serves as the gatekeeper that filters which **Topics** (and from which **Providers**) that clinic's device can access.
+
+---
+
+## 1. Content layer
+
+_What the app exists to deliver._
+
+### Topic
+
+The central domain object. Every other model either produces, organizes, or delivers Topics.
+
+- Belongs to one **Language**
+- Belongs to one **Provider**
+- Has many **Beacons** through BeaconTopics (which beacons serve this topic)
+- Has many **Tags** via `acts_as_taggable_on` (for search and discoverability)
+
+### Provider
+
+The organization that authors content.
+
+- Has many **Topics** (a topic belongs to exactly one provider)
+- Has many **Branches** → and through them, many **Regions** (a provider can operate in multiple regions)
+- Has many **Contributors** → and through them, many **Users** (the people who manage that provider's content)
+- Has many **Beacons** through BeaconProviders
+
+### Language
+
+The language a Topic is written in; also used to scope a Beacon's content.
+
+- Has many **Topics**
+- Has many **Providers** through Topics (derived: which providers have content in this language)
+- Has many **Beacons** (each beacon is assigned exactly one language)
+
+---
+
+## 2. Delivery layer
+
+_How content reaches remote locations._
+
+### Beacon
+
+The delivery object through which medical practitioners access topics. It's managed by **Provider(s)** to deliver **Topics** to end users.
+
+- Belongs to one **Language** (required — scopes topics to one language)
+- Belongs to one **Region** (required — scopes providers to those with a branch in this region)
+- Has many **Providers** through BeaconProviders (optional further restriction)
+- Has many **Topics** through BeaconTopics (optional further restriction)
+
+### Region
+
+The geographic area a Beacon serves; also how Providers are scoped to locations.
+
+- Has many **Branches** (a branch is a provider's presence in a region)
+- Has many **Providers** through Branches
+- Has many **Beacons** (each beacon is assigned exactly one region)
+
+### Branch
+
+Links a Provider to a Region. The join that makes geographic filtering possible.
+
+- Belongs to one **Provider**
+- Belongs to one **Region**
+
+---
+
+## 3. People layer
+
+_Who manages the content._
+
+### User
+
+An administrator of the system.
+
+- Has many **Contributors** → and through them, many **Providers**
+- Non-admin users must be affiliated with at least one Provider (enforced by model validation)
+- Admins have no provider requirement
+
+### Contributor
+
+Records a User's affiliation with a Provider.
+
+- Belongs to one **User**
+- Belongs to one **Provider**
+
+---
+
+## 4. Taxonomy layer
+
+_How content is discovered and grouped._
+
+### Tag
+
+A keyword applied to Topics to support search and filtering.
+
+- Applied to Topics via `acts_as_taggable_on`
+- Can be linked to other Tags as **cognates** (semantically equivalent terms — e.g. synonyms across dialects or languages)
+
+### TagCognate
+
+A self-referential join between two Tags that are considered cognates. Relationships are deduplicated (no reverse duplicates allowed). When a tag is added to a topic, its cognates are automatically added as well.
+
+- Belongs to one **Tag** (as `tag`)
+- Belongs to one **Tag** (as `cognate`)
+
+---
+
+## Join / pivot model summary
+
+| Model | Joins | Layer |
+| ------------------ | ---------------------------- | -------- |
+| **Branch** | Provider ↔ Region | Delivery |
+| **Contributor** | User ↔ Provider | People |
+| **BeaconProvider** | Beacon ↔ Provider | Delivery |
+| **BeaconTopic** | Beacon ↔ Topic | Delivery |
+| **TagCognate** | Tag ↔ Tag (self-referential) | Taxonomy |
+
+---
+
+## Diagram
+
+```
+[People]
+User ──── Contributor ──────────────────────────┐
+ ↓
+[Content] Provider ──── Branch ──── Region
+Language ──── Topic ───────────────────────↑ ↑
+ │ │
+ [Tag] ── TagCognate │
+ │
+[Delivery] Beacon ──────────────────────┘
+ (belongs_to Language, Region)
+ (has_many Providers via BeaconProvider)
+ (has_many Topics via BeaconTopic)
+```
diff --git a/spec/requests/beacons_spec.rb b/spec/requests/beacons_spec.rb
new file mode 100644
index 00000000..656c79f2
--- /dev/null
+++ b/spec/requests/beacons_spec.rb
@@ -0,0 +1,176 @@
+require "rails_helper"
+
+RSpec.describe "/beacons", type: :request do
+ let(:user) { create(:user, :admin) }
+ let(:region) { create(:region) }
+ let(:language) { create(:language) }
+ let(:valid_attributes) do
+ { name: "New Beacon",
+ language_id: language.id,
+ region_id: region.id,
+ }
+ end
+ let(:invalid_attributes) { { name: "" } }
+
+ before do
+ sign_in(user)
+ end
+
+ describe "GET /index" do
+ it "renders a successful response" do
+ create(:beacon)
+
+ get beacons_url
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /show" do
+ it "renders a successful response" do
+ beacon = create(:beacon)
+
+ get beacon_url(beacon)
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /new" do
+ it "renders a successful response" do
+ get new_beacon_url
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe "GET /edit" do
+ let(:beacon) { create(:beacon) }
+
+ it "renders a successful response" do
+ get edit_beacon_url(beacon)
+
+ expect(response).to be_successful
+ end
+ end
+
+ describe "POST /create" do
+ context "with valid parameters" do
+ it "creates a new beacon" do
+ expect {
+ post beacons_url, params: { beacon: valid_attributes }
+ }.to change(Beacon, :count).by(1)
+ end
+
+ it "redirects to the created beacon" do
+ post beacons_url, params: { beacon: valid_attributes }
+
+ expect(response).to have_http_status(302)
+ expect(assigns(:beacon)).to eq(Beacon.last)
+ end
+ end
+
+ context "with invalid parameters" do
+ it "does not create a new beacon" do
+ expect {
+ post beacons_url, params: { beacon: invalid_attributes }
+ }.to change(Beacon, :count).by(0)
+ end
+
+ it "renders a response with 422 status (i.e. to display the 'new' template)" do
+ post beacons_url, params: { beacon: invalid_attributes }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+ end
+
+ describe "PATCH /update" do
+ context "with valid parameters" do
+ let(:updated_region) { create(:region) }
+ let(:updated_language) { create(:language) }
+ let(:new_attributes) do
+ { name: "Updated Beacon",
+ language_id: updated_language.id,
+ region_id: updated_region.id,
+ }
+ end
+
+ it "updates the requested beacon" do
+ beacon = create(:beacon)
+
+ patch beacon_url(beacon), params: { beacon: new_attributes }
+ beacon.reload
+
+ expect(beacon.name).to eq("Updated Beacon")
+ expect(beacon.language).to eq(updated_language)
+ expect(beacon.region).to eq(updated_region)
+ end
+
+ it "redirects to the beacon" do
+ beacon = create(:beacon)
+
+ patch beacon_url(beacon), params: { beacon: new_attributes }
+ beacon.reload
+
+ expect(response).to redirect_to(beacon_url(beacon))
+ end
+ end
+
+ context "with invalid parameters" do
+ it "renders a response with 422 status (i.e. to display the 'edit' template)" do
+ beacon = create(:beacon)
+
+ patch beacon_url(beacon), params: { beacon: invalid_attributes }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+ end
+
+ describe "POST /regenerate_key" do
+ let(:beacon) { create(:beacon) }
+ subject { post regenerate_key_beacon_url(beacon) }
+
+ it "redirects to the beacon" do
+ subject
+
+ expect(response).to have_http_status(302)
+ expect(assigns(:beacon)).to eq(beacon)
+ end
+
+ context "when there is an error" do
+ before { allow(Beacons::KeyRegenerator.new).to receive(:call).and_raise("Error") }
+
+ it "redirects to the beacon" do
+ subject
+
+ expect(response).to have_http_status(302)
+ expect(assigns(:beacon)).to eq(beacon)
+ end
+ end
+ end
+
+ describe "POST /revoke_key" do
+ include ActiveSupport::Testing::TimeHelpers
+
+ let(:beacon) { create(:beacon) }
+ subject { post revoke_key_beacon_url(beacon) }
+
+ it "redirects to the beacon" do
+ subject
+
+ expect(response).to redirect_to(beacon_url(beacon))
+ end
+
+ context "when there is an error" do
+ before { allow(beacon).to receive(:revoke!).and_raise("Error") }
+
+ it "redirects to the beacon" do
+ subject
+
+ expect(response).to redirect_to(beacon_url(beacon))
+ end
+ end
+ end
+end