diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..9e27b13 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude AI Coding Rules + +Follow the coding standards defined in `.agent_rules.md` for all code changes. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89c8b73 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Environment configuration for SkillRx Beacon +# +# Copy this file to .env and fill in the values: +# cp .env.example .env +# +# IMPORTANT: Never commit .env to version control! + +# Rails master key (required for production) +# Generate with: bin/rails secret +RAILS_MASTER_KEY= + +# Rails environment (development, test, production) +RAILS_ENV=production + +# Server port (default: 3000 for development, 80 for Docker with Thruster) +PORT=3000 + +# Content directory path (where medical content files are stored) +CONTENT_PATH=/path/to/content + +# Optional: Database path (defaults to storage/production.sqlite3) +# DATABASE_PATH=/var/lib/skillrx/production.sqlite3 + +# Optional: Log level (debug, info, warn, error, fatal) +# RAILS_LOG_LEVEL=info + +# Optional: Enable Solid Queue in Puma process +SOLID_QUEUE_IN_PUMA=1 + +# Optional: Number of Puma threads (default: 3) +# RAILS_MAX_THREADS=3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f597a..de4ea02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,60 @@ jobs: - name: Lint code for consistent style run: bin/rubocop -f github + test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare && bin/rspec + + system-test: + runs-on: ubuntu-latest + + # services: + # redis: + # image: valkey/valkey:8 + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run System Tests + env: + RAILS_ENV: test + # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare && bin/rspec + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index e953825..4ad4ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,9 @@ # Ignore bundler config. /.bundle -# Ignore all environment files. +# Ignore all environment files (except example). /.env* +!/.env.example # Ignore all logfiles and tempfiles. /log/* @@ -36,3 +37,5 @@ /app/assets/builds/* !/app/assets/builds/.keep + +.agent_rules.md diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.ruby-version b/.ruby-version index fcdb2e1..90cdbdc 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.0 +ruby-4.0.1 diff --git a/Dockerfile b/Dockerfile index db0454f..98795d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.0 +ARG RUBY_VERSION=4.0.1 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here @@ -16,7 +16,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives @@ -32,7 +32,7 @@ FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems diff --git a/Dockerfile.simple b/Dockerfile.simple new file mode 100644 index 0000000..67b09ae --- /dev/null +++ b/Dockerfile.simple @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1 +# check=error=true +# +# Simplified Dockerfile for resource-constrained environments (Raspberry Pi, etc.) +# Does not use Thruster - runs Puma directly on port 3000 +# +# Build: +# docker build -f Dockerfile.simple -t skillrx . +# +# Run: +# docker run -d -p 3000:3000 \ +# -e RAILS_MASTER_KEY= \ +# -v skillrx_storage:/rails/storage \ +# --name skillrx skillrx + +ARG RUBY_VERSION=4.0.1 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +WORKDIR /rails + +# Install minimal packages (no jemalloc for smaller image) +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development:test" + +# Build stage +FROM base AS build + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git + +COPY . . + +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + +# Final stage +FROM base + +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +EXPOSE 3000 +CMD ["./bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"] diff --git a/Gemfile b/Gemfile index edfc6b7..bfabc96 100644 --- a/Gemfile +++ b/Gemfile @@ -1,33 +1,72 @@ -source "https://gem.coop" +source "https://rubygems.org" -gem "bootsnap", require: false -gem "image_processing", "~> 1.2" -gem "importmap-rails" -gem "jbuilder" -gem "kamal", require: false +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.2" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" -gem "pg", "~> 1.1" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" -gem "rails", "~> 8.1.2" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails" -gem "solid_cache" -gem "solid_queue" -gem "solid_cable" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" +# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] gem "tailwindcss-rails" -gem "thruster", require: false +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +gem "image_processing", "~> 1.2" + group :development, :test do - gem "brakeman", require: false - gem "bundler-audit", require: false + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false end group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "rspec-rails", "~> 8.0", groups: [ :development, :test ] + +gem "factory_bot_rails", "~> 6.5", groups: [ :development, :test ] + +gem "bcrypt", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index 10982b8..b3b59d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,5 +1,5 @@ GEM - remote: https://gem.coop/ + remote: https://rubygems.org/ specs: action_text-trix (2.1.16) railties @@ -75,19 +75,31 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.21) bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.21.1) msgpack (~> 1.2) - brakeman (8.0.1) + brakeman (8.0.2) racc builder (3.3.0) bundler-audit (0.9.3) bundler (>= 1.2.0) thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -95,6 +107,7 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + diff-lcs (1.6.2) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -102,6 +115,11 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.3-aarch64-linux-musl) ffi (1.17.3-arm-linux-gnu) @@ -128,9 +146,6 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.14.1) - actionview (>= 7.0.0) - activesupport (>= 7.0.0) json (2.18.0) kamal (2.10.1) activesupport (>= 7.0) @@ -156,6 +171,7 @@ GEM net-pop net-smtp marcel (1.1.0) + matrix (0.4.3) mini_magick (5.3.1) logger mini_mime (1.1.5) @@ -196,12 +212,6 @@ GEM parser (3.3.10.1) ast (~> 2.4.1) racc - pg (1.6.3) - pg (1.6.3-aarch64-linux) - pg (1.6.3-aarch64-linux-musl) - pg (1.6.3-arm64-darwin) - pg (1.6.3-x86_64-linux) - pg (1.6.3-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -213,6 +223,7 @@ GEM psych (5.3.1) date stringio + public_suffix (7.0.2) puma (7.2.0) nio4r (~> 2.0) raabro (1.4.0) @@ -264,6 +275,24 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) + rexml (3.4.4) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.2) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.7) rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -296,7 +325,14 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + rubyzip (3.2.2) securerandom (0.4.1) + selenium-webdriver (4.40.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -313,6 +349,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.0-aarch64-linux-gnu) + sqlite3 (2.9.0-aarch64-linux-musl) + sqlite3 (2.9.0-arm-linux-gnu) + sqlite3 (2.9.0-arm-linux-musl) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -354,10 +397,13 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + websocket (1.2.11) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) zeitwerk (2.7.4) PLATFORMS @@ -366,28 +412,32 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl - arm64-darwin-24 + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1) bootsnap brakeman bundler-audit + capybara debug + factory_bot_rails (~> 6.5) image_processing (~> 1.2) importmap-rails - jbuilder kamal - pg (~> 1.1) propshaft puma (>= 5.0) rails (~> 8.1.2) + rspec-rails (~> 8.0) rubocop-rails-omakase + selenium-webdriver solid_cable solid_cache solid_queue + sqlite3 (>= 2.1) stimulus-rails tailwindcss-rails thruster @@ -408,26 +458,32 @@ CHECKSUMS activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44 activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76 activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 - brakeman (8.0.1) sha256=c68ce0ac35a6295027c4eab8b4ac597d2a0bfc82f0d62dcd334bbf944d352f70 + brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077 + factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068 ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2 ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668 @@ -442,7 +498,6 @@ CHECKSUMS importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 - jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc @@ -451,6 +506,7 @@ CHECKSUMS loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb @@ -473,17 +529,12 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688 - pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 - pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea - pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c - pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f - pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d - pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f @@ -500,6 +551,12 @@ CHECKSUMS rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-rails (8.0.2) sha256=113139a53f5d068d4f48d1c29ad5f982013ed9b0daa69d7f7b266eda5d433ace + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 @@ -507,10 +564,19 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 + sqlite3 (2.9.0-aarch64-linux-gnu) sha256=cfe1e0216f46d7483839719bf827129151e6c680317b99d7b8fc1597a3e13473 + sqlite3 (2.9.0-aarch64-linux-musl) sha256=56a35cb2d70779afc2ac191baf2c2148242285ecfed72f9b021218c5c4917913 + sqlite3 (2.9.0-arm-linux-gnu) sha256=a19a21504b0d7c8c825fbbf37b358ae316b6bd0d0134c619874060b2eef05435 + sqlite3 (2.9.0-arm-linux-musl) sha256=fca5b26197c70e3363115d3faaea34d7b2ad9c7f5fa8d8312e31b64e7556ee07 + sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3 + sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 + sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 @@ -535,9 +601,11 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b BUNDLED WITH - 4.0.5 + 4.0.3 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e93e9a --- /dev/null +++ b/Makefile @@ -0,0 +1,100 @@ +# Makefile for SkillRx Beacon deployment tasks +# +# Usage: +# make build - Build Docker image +# make up - Start containers +# make down - Stop containers +# make logs - View container logs +# make shell - Open shell in container +# make db-prepare - Prepare database +# make import - Import content from XML + +.PHONY: build up down logs shell db-prepare import test clean help + +# Docker image name +IMAGE_NAME ?= skillrx-beacon +DOCKER_COMPOSE ?= docker compose + +# Default target +help: + @echo "SkillRx Beacon - Deployment Tasks" + @echo "" + @echo "Docker Commands:" + @echo " make build - Build Docker image" + @echo " make build-simple - Build simplified Docker image (for Pi)" + @echo " make up - Start containers (production)" + @echo " make up-dev - Start containers (development)" + @echo " make down - Stop containers" + @echo " make logs - View container logs" + @echo " make shell - Open shell in container" + @echo "" + @echo "Database Commands:" + @echo " make db-prepare - Prepare database" + @echo " make db-migrate - Run migrations" + @echo " make db-seed - Seed database" + @echo " make import - Import content from XML" + @echo "" + @echo "Development Commands:" + @echo " make test - Run test suite" + @echo " make lint - Run linter" + @echo " make clean - Clean generated files" + @echo "" + +# Docker commands +build: + docker build -t $(IMAGE_NAME) . + +build-simple: + docker build -f Dockerfile.simple -t $(IMAGE_NAME):simple . + +up: + $(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.production.yml up -d + +up-dev: + $(DOCKER_COMPOSE) -f docker-compose.yml -f docker-compose.development.yml up + +down: + $(DOCKER_COMPOSE) down + +logs: + $(DOCKER_COMPOSE) logs -f + +shell: + $(DOCKER_COMPOSE) exec web /bin/bash + +# Database commands +db-prepare: + $(DOCKER_COMPOSE) run --rm web bin/rails db:prepare + +db-migrate: + $(DOCKER_COMPOSE) run --rm web bin/rails db:migrate + +db-seed: + $(DOCKER_COMPOSE) run --rm web bin/rails db:seed + +import: + $(DOCKER_COMPOSE) run --rm web bin/rails data:import_content + +# Development commands +test: + bin/rspec + +lint: + bin/rubocop + +clean: + rm -rf log/*.log tmp/cache tmp/pids tmp/sockets + rm -rf public/assets app/assets/builds/* + +# Production deployment (non-Docker) +deploy-setup: + sudo ./bin/setup-production + +deploy-restart: + sudo systemctl restart skillrx + +deploy-status: + sudo systemctl status skillrx + +deploy-logs: + sudo journalctl -u skillrx -f diff --git a/Procfile.dev b/Procfile.dev index da151fe..b31e89b 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bin/rails server +web: bin/rails server -p 4000 css: bin/rails tailwindcss:watch diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..a5adf05 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,4 @@ +class Admin::BaseController < ApplicationController + before_action :authenticate_admin! + layout "admin" +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 0000000..e51b14c --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,28 @@ +class Admin::DashboardController < Admin::BaseController + def index + @period = (params[:period] || 30).to_i + @stats_service = StatsService.new(days: @period) + + @overview = @stats_service.overview + @activity_summary = @stats_service.activity_summary + @top_users = @stats_service.top_users(limit: 10) + @hot_topics = @stats_service.hot_topics(limit: 10) + @popular_searches = @stats_service.popular_searches(limit: 15) + @failed_searches = @stats_service.failed_searches(limit: 10) + @logins_per_day = @stats_service.logins_per_day + @recent_activities = @stats_service.recent_activities(limit: 20) + @content_by_provider = @stats_service.content_by_provider + end + + def activity_log + @activities = UserActivityLog.includes(:user, :topic) + .order(created_at: :desc) + .limit(100) + end + + def admin_log + @activities = AdminActivityLog.includes(:admin) + .order(created_at: :desc) + .limit(100) + end +end diff --git a/app/controllers/admin/local_files_controller.rb b/app/controllers/admin/local_files_controller.rb new file mode 100644 index 0000000..671414a --- /dev/null +++ b/app/controllers/admin/local_files_controller.rb @@ -0,0 +1,116 @@ +class Admin::LocalFilesController < Admin::BaseController + before_action :set_local_file, only: [ :show, :destroy ] + + def index + @local_files = LocalFile.includes(:admin, file_attachment: :blob) + .order(created_at: :desc) + @folder_tree = build_folder_tree(@local_files) + end + + def new + @local_file = LocalFile.new + end + + def create + uploaded_files = Array(params[:files]) + + if uploaded_files.empty? + redirect_to new_admin_local_file_path, alert: "Please select at least one file to upload." + return + end + + success_count = 0 + error_count = 0 + + uploaded_files.each do |file| + local_file = LocalFile.new( + admin: current_admin, + folder_path: determine_folder_path(file, params[:folder_path]) + ) + local_file.file.attach(file) + + if local_file.save + success_count += 1 + else + error_count += 1 + end + end + + log_admin_activity("upload", details: "Uploaded #{success_count} files") + + if error_count.zero? + redirect_to admin_local_files_path, notice: "Successfully uploaded #{success_count} file(s)." + else + redirect_to admin_local_files_path, + alert: "Uploaded #{success_count} file(s), but #{error_count} failed." + end + end + + def show + unless @local_file.file.attached? + redirect_to admin_local_files_path, alert: "File not found." + return + end + + redirect_to rails_blob_path(@local_file.file, disposition: "inline"), allow_other_host: true + end + + def destroy + filename = @local_file.file.filename.to_s if @local_file.file.attached? + @local_file.destroy + + log_admin_activity("delete", details: "Deleted file: #{filename || 'unknown'}") + + respond_to do |format| + format.turbo_stream { render turbo_stream: turbo_stream.remove("local_file_#{params[:id]}") } + format.html { redirect_to admin_local_files_path, notice: "File deleted successfully." } + end + end + + def destroy_folder + folder_path = params[:folder_path] + files = LocalFile.where("folder_path LIKE ?", "#{folder_path}%") + count = files.count + files.destroy_all + + log_admin_activity("delete", details: "Deleted folder: #{folder_path} (#{count} files)") + + redirect_to admin_local_files_path, notice: "Deleted folder and #{count} file(s)." + end + + private + + def set_local_file + @local_file = LocalFile.find(params[:id]) + end + + def determine_folder_path(file, base_folder) + base = base_folder.presence || "/" + base = "/#{base}" unless base.start_with?("/") + + # Handle webkitRelativePath for directory uploads + if file.respond_to?(:original_filename) && file.original_filename.include?("/") + # Extract directory from the relative path + relative_dir = File.dirname(file.original_filename) + File.join(base, relative_dir) + else + base + end + end + + def build_folder_tree(local_files) + tree = {} + + local_files.each do |file| + path_parts = file.folder_path.split("/").reject(&:blank?) + current = tree + + path_parts.each do |part| + current[part] ||= { files: [], subfolders: {} } + current = current[part][:subfolders] + end + end + + tree + end +end diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb new file mode 100644 index 0000000..028f1a9 --- /dev/null +++ b/app/controllers/admin/sessions_controller.rb @@ -0,0 +1,25 @@ +class Admin::SessionsController < ApplicationController + layout "admin" + + def new + redirect_to admin_root_path if admin_signed_in? + end + + def create + admin = Admin.find_by(login_id: params[:login_id]&.downcase&.strip) + + if admin&.authenticate(params[:password]) + sign_in_admin(admin) + log_admin_activity("login") + redirect_to admin_root_path, notice: "Welcome back, #{admin.first_name}!" + else + flash.now[:alert] = "Invalid login ID or password." + render :new, status: :unprocessable_entity + end + end + + def destroy + sign_out_admin + redirect_to admin_login_path, notice: "You have been signed out." + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..8e5d1be 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,8 @@ class ApplicationController < ActionController::Base + include Authentication + include AdminAuthentication + include ActivityLogging + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/audio_player_controller.rb b/app/controllers/audio_player_controller.rb new file mode 100644 index 0000000..f0c0673 --- /dev/null +++ b/app/controllers/audio_player_controller.rb @@ -0,0 +1,41 @@ +class AudioPlayerController < ApplicationController + include BrowserDetection + + before_action :set_topic_file + before_action :verify_file_type + + def show + unless @topic_file.file.attached? + redirect_to audio_not_found_path and return + end + + @topic = @topic_file.topic + @is_favorite = user_signed_in? && current_user.favorites.exists?(topic: @topic) + + log_user_activity("view", topic: @topic, file_type: "mp3") if user_signed_in? + end + + def stream + unless @topic_file.file.attached? + head :not_found and return + end + + send_data @topic_file.file.download, + type: @topic_file.file.content_type, + disposition: "inline" + end + + private + + def set_topic_file + @topic_file = TopicFile.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to audio_not_found_path + end + + def verify_file_type + unless @topic_file.file_type == "mp3" + redirect_to not_found_path + end + end +end diff --git a/app/controllers/concerns/activity_logging.rb b/app/controllers/concerns/activity_logging.rb new file mode 100644 index 0000000..ca3c40b --- /dev/null +++ b/app/controllers/concerns/activity_logging.rb @@ -0,0 +1,58 @@ +module ActivityLogging + extend ActiveSupport::Concern + + private + + def log_user_activity(action_type, topic: nil, file_type: nil, search_term: nil, search_found: nil) + return unless current_user + + UserActivityLog.create( + user: current_user, + action_type: action_type, + topic: topic, + file_type: file_type, + search_term: search_term, + search_found: search_found, + os: detect_os, + browser: detect_browser, + ip_address: request.remote_ip + ) + end + + def log_admin_activity(action_type, details: nil) + return unless current_admin + + AdminActivityLog.create( + admin: current_admin, + action_type: action_type, + details: details, + os: detect_os, + browser: detect_browser, + ip_address: request.remote_ip + ) + end + + def detect_os + user_agent = request.user_agent.to_s + case user_agent + when /Windows/i then "Windows" + when /Macintosh|Mac OS/i then "macOS" + when /Linux/i then "Linux" + when /Android/i then "Android" + when /iPhone|iPad/i then "iOS" + else "Unknown" + end + end + + def detect_browser + user_agent = request.user_agent.to_s + case user_agent + when /Chrome/i then "Chrome" + when /Firefox/i then "Firefox" + when /Safari/i then "Safari" + when /Edge/i then "Edge" + when /MSIE|Trident/i then "Internet Explorer" + else "Unknown" + end + end +end diff --git a/app/controllers/concerns/admin_authentication.rb b/app/controllers/concerns/admin_authentication.rb new file mode 100644 index 0000000..fab19fd --- /dev/null +++ b/app/controllers/concerns/admin_authentication.rb @@ -0,0 +1,33 @@ +module AdminAuthentication + extend ActiveSupport::Concern + + included do + helper_method :current_admin, :admin_signed_in? + end + + def current_admin + return @current_admin if defined?(@current_admin) + + @current_admin = session[:admin_id] && Admin.find_by(id: session[:admin_id]) + end + + def admin_signed_in? + current_admin.present? + end + + def authenticate_admin! + unless admin_signed_in? + redirect_to admin_login_path, alert: "Please sign in to access admin area." + end + end + + def sign_in_admin(admin) + session[:admin_id] = admin.id + @current_admin = admin + end + + def sign_out_admin + session.delete(:admin_id) + @current_admin = nil + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..fc35c2f --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,44 @@ +module Authentication + extend ActiveSupport::Concern + + included do + helper_method :current_user, :user_signed_in? + end + + def current_user + return @current_user if defined?(@current_user) + + @current_user = session[:user_id] && User.find_by(id: session[:user_id]) + end + + def user_signed_in? + current_user.present? + end + + def authenticate_user! + unless user_signed_in? + store_location + redirect_to login_path, alert: "Please sign in to continue." + end + end + + def sign_in(user) + session[:user_id] = user.id + @current_user = user + end + + def sign_out_user + session.delete(:user_id) + @current_user = nil + end + + private + + def store_location + session[:return_to] = request.fullpath if request.get? || request.head? + end + + def stored_location_or(default) + session.delete(:return_to) || default + end +end diff --git a/app/controllers/concerns/browser_detection.rb b/app/controllers/concerns/browser_detection.rb new file mode 100644 index 0000000..f22551b --- /dev/null +++ b/app/controllers/concerns/browser_detection.rb @@ -0,0 +1,22 @@ +module BrowserDetection + extend ActiveSupport::Concern + + included do + before_action :reject_ie_browser + helper_method :mobile_device? + end + + def ie_browser? + request.user_agent&.match?(/MSIE|Trident/) + end + + def mobile_device? + request.user_agent&.match?(/Mobile|Android|iPhone|iPad/) + end + + private + + def reject_ie_browser + redirect_to unsupported_browser_path if ie_browser? + end +end diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb new file mode 100644 index 0000000..1bca11d --- /dev/null +++ b/app/controllers/errors_controller.rb @@ -0,0 +1,17 @@ +class ErrorsController < ApplicationController + def not_found + render status: :not_found + end + + def audio_not_found + render status: :not_found + end + + def pdf_not_found + render status: :not_found + end + + def unsupported_browser + render layout: false + end +end diff --git a/app/controllers/local_files_controller.rb b/app/controllers/local_files_controller.rb new file mode 100644 index 0000000..71ba52f --- /dev/null +++ b/app/controllers/local_files_controller.rb @@ -0,0 +1,27 @@ +class LocalFilesController < ApplicationController + before_action :authenticate_user! + before_action :set_local_file, only: [ :show ] + + def index + @local_files = LocalFile.includes(:admin, file_attachment: :blob) + .order(:folder_path, :created_at) + @folders = @local_files.group_by(&:folder_path).sort + end + + def show + unless @local_file.file.attached? + redirect_to local_files_path, alert: "File not found." + return + end + + log_user_activity("view_local_file", file_type: @local_file.file.content_type) + + redirect_to rails_blob_path(@local_file.file, disposition: "inline"), allow_other_host: true + end + + private + + def set_local_file + @local_file = LocalFile.find(params[:id]) + end +end diff --git a/app/controllers/pdf_viewer_controller.rb b/app/controllers/pdf_viewer_controller.rb new file mode 100644 index 0000000..1fb574b --- /dev/null +++ b/app/controllers/pdf_viewer_controller.rb @@ -0,0 +1,42 @@ +class PdfViewerController < ApplicationController + include BrowserDetection + + before_action :set_topic_file + before_action :verify_file_type + + def show + unless @topic_file.file.attached? + redirect_to pdf_not_found_path and return + end + + @topic = @topic_file.topic + @is_favorite = user_signed_in? && current_user.favorites.exists?(topic: @topic) + + log_user_activity("view", topic: @topic, file_type: "pdf") if user_signed_in? + end + + def download + unless @topic_file.file.attached? + head :not_found and return + end + + send_data @topic_file.file.download, + type: "application/pdf", + filename: @topic_file.filename, + disposition: "attachment" + end + + private + + def set_topic_file + @topic_file = TopicFile.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to pdf_not_found_path + end + + def verify_file_type + unless @topic_file.file_type == "pdf" + redirect_to not_found_path + end + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 0000000..b7d64f8 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,24 @@ +class RegistrationsController < ApplicationController + def new + redirect_to root_path if user_signed_in? + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save + sign_in(@user) + log_user_activity("login") + redirect_to root_path, notice: "Welcome, #{@user.first_name}! Your login ID is: #{@user.login_id}" + else + render :new, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:first_name, :last_name) + end +end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 0000000..641998a --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,39 @@ +class SearchController < ApplicationController + def index + @query = params[:q] + @search_service = SearchService.new(@query) + @topics = @search_service.search if @query.present? + + log_search if @query.present? && user_signed_in? + end + + def autocomplete + search_service = SearchService.new(params[:q]) + suggestions = search_service.autocomplete_suggestions + + render json: suggestions + end + + def results + @query = params[:q] + @search_service = SearchService.new(@query) + @topics = @search_service.search + + log_search if user_signed_in? + + respond_to do |format| + format.html + format.turbo_stream + end + end + + private + + def log_search + log_user_activity( + "search", + search_term: @query, + search_found: @search_service.found? + ) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..da7adfe --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,24 @@ +class SessionsController < ApplicationController + def new + redirect_to root_path if user_signed_in? + end + + def create + user = User.find_by(login_id: params[:login_id]&.downcase&.strip) + + if user + user.increment!(:login_count) + sign_in(user) + log_user_activity("login") + redirect_to stored_location_or(root_path), notice: "Welcome back, #{user.first_name}!" + else + flash.now[:alert] = "User not found. Please check your login ID or sign up." + render :new, status: :unprocessable_entity + end + end + + def destroy + sign_out_user + redirect_to root_path, notice: "You have been signed out." + end +end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb new file mode 100644 index 0000000..05b04e0 --- /dev/null +++ b/app/controllers/topics_controller.rb @@ -0,0 +1,77 @@ +class TopicsController < ApplicationController + before_action :authenticate_user!, only: [ :favorites, :toggle_favorite ] + before_action :set_topic, only: [ :show, :toggle_favorite ] + + def index + @years = Topic.distinct.pluck(:year).compact.sort.reverse + @content_providers = ContentProvider.all + end + + def show + @topic.increment!(:view_count) + log_user_activity("view", topic: @topic) if user_signed_in? + @is_favorite = user_signed_in? && current_user.favorites.exists?(topic: @topic) + end + + def by_year + @year = params[:year].to_i + @month = params[:month] + + @topics = Topic.includes(:content_provider, :authors, :topic_files) + .by_year(@year) + + @topics = @topics.by_month(@month) if @month.present? + @topics = @topics.order(month: :asc, title: :asc) + + @months = Topic.by_year(@year).distinct.pluck(:month).compact.sort_by do |m| + Date::MONTHNAMES.index(m) || 0 + end + + respond_to do |format| + format.html + format.turbo_stream + end + end + + def new_uploads + @topics = Topic.includes(:content_provider, :authors, :topic_files) + .new_uploads + .order(created_at: :desc) + end + + def top_topics + @topics = Topic.includes(:content_provider, :authors, :topic_files) + .top_topics + end + + def favorites + @topics = Topic.includes(:content_provider, :authors, :topic_files) + .favorites_for(current_user) + .order(created_at: :desc) + end + + def toggle_favorite + favorite = current_user.favorites.find_by(topic: @topic) + + if favorite + favorite.destroy + @is_favorite = false + log_user_activity("unfavorite", topic: @topic) + else + current_user.favorites.create(topic: @topic) + @is_favorite = true + log_user_activity("favorite", topic: @topic) + end + + respond_to do |format| + format.turbo_stream + format.html { redirect_to @topic } + end + end + + private + + def set_topic + @topic = Topic.includes(:content_provider, :authors, :tags, :topic_files).find(params[:id]) + end +end diff --git a/app/helpers/admin/base_helper.rb b/app/helpers/admin/base_helper.rb new file mode 100644 index 0000000..f24ad87 --- /dev/null +++ b/app/helpers/admin/base_helper.rb @@ -0,0 +1,2 @@ +module Admin::BaseHelper +end diff --git a/app/helpers/admin/dashboard_helper.rb b/app/helpers/admin/dashboard_helper.rb new file mode 100644 index 0000000..4052b7c --- /dev/null +++ b/app/helpers/admin/dashboard_helper.rb @@ -0,0 +1,2 @@ +module Admin::DashboardHelper +end diff --git a/app/helpers/admin/sessions_helper.rb b/app/helpers/admin/sessions_helper.rb new file mode 100644 index 0000000..760cb3d --- /dev/null +++ b/app/helpers/admin/sessions_helper.rb @@ -0,0 +1,2 @@ +module Admin::SessionsHelper +end diff --git a/app/helpers/audio_player_helper.rb b/app/helpers/audio_player_helper.rb new file mode 100644 index 0000000..58e1018 --- /dev/null +++ b/app/helpers/audio_player_helper.rb @@ -0,0 +1,2 @@ +module AudioPlayerHelper +end diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb new file mode 100644 index 0000000..8e3b415 --- /dev/null +++ b/app/helpers/errors_helper.rb @@ -0,0 +1,2 @@ +module ErrorsHelper +end diff --git a/app/helpers/favorites_helper.rb b/app/helpers/favorites_helper.rb new file mode 100644 index 0000000..4e9a950 --- /dev/null +++ b/app/helpers/favorites_helper.rb @@ -0,0 +1,2 @@ +module FavoritesHelper +end diff --git a/app/helpers/pdf_viewer_helper.rb b/app/helpers/pdf_viewer_helper.rb new file mode 100644 index 0000000..2981257 --- /dev/null +++ b/app/helpers/pdf_viewer_helper.rb @@ -0,0 +1,2 @@ +module PdfViewerHelper +end diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb new file mode 100644 index 0000000..b100376 --- /dev/null +++ b/app/helpers/registrations_helper.rb @@ -0,0 +1,2 @@ +module RegistrationsHelper +end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb new file mode 100644 index 0000000..b3ce20a --- /dev/null +++ b/app/helpers/search_helper.rb @@ -0,0 +1,2 @@ +module SearchHelper +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 0000000..309f8b2 --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +end diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb new file mode 100644 index 0000000..488eed5 --- /dev/null +++ b/app/helpers/topics_helper.rb @@ -0,0 +1,2 @@ +module TopicsHelper +end diff --git a/app/javascript/controllers/audio_player_controller.js b/app/javascript/controllers/audio_player_controller.js new file mode 100644 index 0000000..7e6640e --- /dev/null +++ b/app/javascript/controllers/audio_player_controller.js @@ -0,0 +1,95 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="audio-player" +export default class extends Controller { + static targets = [ + "audio", "loading", "controls", + "playButton", "playIcon", "pauseIcon", + "progressBar", "progress", + "currentTime", "duration", + "volumeSlider", "volumeIcon", "muteIcon" + ] + + connect() { + this.isPlaying = false + this.isMuted = false + } + + loaded() { + this.loadingTarget.classList.add("hidden") + this.controlsTarget.classList.remove("hidden") + this.durationTarget.textContent = this.formatTime(this.audioTarget.duration) + } + + togglePlay() { + if (this.isPlaying) { + this.audioTarget.pause() + this.playIconTarget.classList.remove("hidden") + this.pauseIconTarget.classList.add("hidden") + } else { + this.audioTarget.play() + this.playIconTarget.classList.add("hidden") + this.pauseIconTarget.classList.remove("hidden") + } + this.isPlaying = !this.isPlaying + } + + updateProgress() { + const percent = (this.audioTarget.currentTime / this.audioTarget.duration) * 100 + this.progressTarget.style.width = `${percent}%` + this.currentTimeTarget.textContent = this.formatTime(this.audioTarget.currentTime) + } + + seek(event) { + const rect = this.progressBarTarget.getBoundingClientRect() + const percent = (event.clientX - rect.left) / rect.width + this.audioTarget.currentTime = percent * this.audioTarget.duration + } + + ended() { + this.isPlaying = false + this.playIconTarget.classList.remove("hidden") + this.pauseIconTarget.classList.add("hidden") + this.audioTarget.currentTime = 0 + } + + changeVolume() { + const volume = this.volumeSliderTarget.value / 100 + this.audioTarget.volume = volume + + if (volume === 0) { + this.showMuteIcon() + } else { + this.showVolumeIcon() + } + } + + toggleMute() { + this.isMuted = !this.isMuted + this.audioTarget.muted = this.isMuted + + if (this.isMuted) { + this.showMuteIcon() + } else { + this.showVolumeIcon() + } + } + + showMuteIcon() { + this.volumeIconTarget.classList.add("hidden") + this.muteIconTarget.classList.remove("hidden") + } + + showVolumeIcon() { + this.volumeIconTarget.classList.remove("hidden") + this.muteIconTarget.classList.add("hidden") + } + + formatTime(seconds) { + if (isNaN(seconds)) return "0:00" + + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, "0")}` + } +} diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js new file mode 100644 index 0000000..9733465 --- /dev/null +++ b/app/javascript/controllers/autocomplete_controller.js @@ -0,0 +1,159 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="autocomplete" +export default class extends Controller { + static targets = ["input", "results"] + static values = { url: String } + + connect() { + this.selectedIndex = -1 + this.debounceTimer = null + + // Close dropdown when clicking outside + document.addEventListener("click", this.handleClickOutside.bind(this)) + } + + disconnect() { + document.removeEventListener("click", this.handleClickOutside.bind(this)) + } + + search() { + clearTimeout(this.debounceTimer) + + const query = this.inputTarget.value.trim() + + if (query.length < 2) { + this.hideResults() + return + } + + this.debounceTimer = setTimeout(() => { + this.fetchSuggestions(query) + }, 200) + } + + async fetchSuggestions(query) { + try { + const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`) + const suggestions = await response.json() + + if (suggestions.length > 0) { + this.showResults(suggestions) + } else { + this.hideResults() + } + } catch (error) { + console.error("Autocomplete error:", error) + this.hideResults() + } + } + + showResults(suggestions) { + this.selectedIndex = -1 + + const html = suggestions.map((item, index) => ` +
+ ${this.typeLabel(item.type)} + ${this.highlightMatch(item.value, this.inputTarget.value)} +
+ `).join("") + + this.resultsTarget.innerHTML = html + this.resultsTarget.classList.remove("hidden") + } + + hideResults() { + this.resultsTarget.classList.add("hidden") + this.resultsTarget.innerHTML = "" + this.selectedIndex = -1 + } + + select(event) { + const value = event.currentTarget.dataset.value + this.inputTarget.value = value + this.hideResults() + this.inputTarget.form.submit() + } + + highlight(event) { + this.selectedIndex = parseInt(event.currentTarget.dataset.index) + this.updateHighlight() + } + + navigate(event) { + const items = this.resultsTarget.querySelectorAll("[data-index]") + + if (items.length === 0) return + + switch (event.key) { + case "ArrowDown": + event.preventDefault() + this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1) + this.updateHighlight() + break + case "ArrowUp": + event.preventDefault() + this.selectedIndex = Math.max(this.selectedIndex - 1, 0) + this.updateHighlight() + break + case "Enter": + if (this.selectedIndex >= 0) { + event.preventDefault() + const selectedItem = items[this.selectedIndex] + this.inputTarget.value = selectedItem.dataset.value + this.hideResults() + this.inputTarget.form.submit() + } + break + case "Escape": + this.hideResults() + break + } + } + + updateHighlight() { + const items = this.resultsTarget.querySelectorAll("[data-index]") + items.forEach((item, index) => { + if (index === this.selectedIndex) { + item.classList.add("bg-gray-100") + } else { + item.classList.remove("bg-gray-100") + } + }) + } + + handleClickOutside(event) { + if (!this.element.contains(event.target)) { + this.hideResults() + } + } + + typeLabel(type) { + const labels = { + tag: "Tag", + topic: "Topic", + author: "Author" + } + return labels[type] || type + } + + highlightMatch(text, query) { + const regex = new RegExp(`(${this.escapeRegex(query)})`, "gi") + return text.replace(regex, "$1") + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } + + escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } +} diff --git a/app/javascript/controllers/favorite_toggle_controller.js b/app/javascript/controllers/favorite_toggle_controller.js new file mode 100644 index 0000000..c7f899f --- /dev/null +++ b/app/javascript/controllers/favorite_toggle_controller.js @@ -0,0 +1,22 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="favorite-toggle" +// Provides visual feedback when toggling favorites +export default class extends Controller { + static targets = ["button", "icon"] + + connect() { + // Turbo handles the actual toggle via turbo_stream + } + + toggle(event) { + // Add loading state + this.buttonTarget.disabled = true + this.iconTarget.classList.add("animate-pulse") + } + + // Called after Turbo Stream replaces the element + disconnect() { + // Cleanup if needed + } +} diff --git a/app/javascript/controllers/file_upload_controller.js b/app/javascript/controllers/file_upload_controller.js new file mode 100644 index 0000000..242b50d --- /dev/null +++ b/app/javascript/controllers/file_upload_controller.js @@ -0,0 +1,130 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="file-upload" +export default class extends Controller { + static targets = [ + "fileInput", + "dropzone", + "dropzoneInner", + "preview", + "fileList", + "totalSize", + "submitButton", + "folderPath", + "typeFiles", + "typeFolder", + "instructions" + ] + + connect() { + this.files = [] + } + + toggleUploadType(event) { + const isFolder = event.target.value === "folder" + + if (isFolder) { + this.fileInputTarget.setAttribute("webkitdirectory", "") + this.fileInputTarget.setAttribute("directory", "") + this.instructionsTarget.textContent = "Select a folder to upload all its contents." + } else { + this.fileInputTarget.removeAttribute("webkitdirectory") + this.fileInputTarget.removeAttribute("directory") + this.instructionsTarget.textContent = "Upload any file type. Maximum 100MB per file." + } + + // Clear current selection + this.fileInputTarget.value = "" + this.clearPreview() + } + + filesSelected(event) { + this.files = Array.from(event.target.files) + this.updatePreview() + } + + dragover(event) { + event.preventDefault() + } + + dragenter(event) { + event.preventDefault() + this.dropzoneInnerTarget.classList.add("border-indigo-500", "bg-indigo-50") + } + + dragleave(event) { + event.preventDefault() + this.dropzoneInnerTarget.classList.remove("border-indigo-500", "bg-indigo-50") + } + + drop(event) { + event.preventDefault() + this.dropzoneInnerTarget.classList.remove("border-indigo-500", "bg-indigo-50") + + const droppedFiles = event.dataTransfer.files + if (droppedFiles.length > 0) { + // Create a new DataTransfer to set on the file input + const dataTransfer = new DataTransfer() + for (const file of droppedFiles) { + dataTransfer.items.add(file) + } + this.fileInputTarget.files = dataTransfer.files + this.files = Array.from(droppedFiles) + this.updatePreview() + } + } + + updatePreview() { + if (this.files.length === 0) { + this.clearPreview() + return + } + + this.previewTarget.classList.remove("hidden") + this.submitButtonTarget.disabled = false + + // Build file list + let html = "" + let totalSize = 0 + + this.files.forEach((file, index) => { + totalSize += file.size + html += ` +
  • +
    + + + + ${this.escapeHtml(file.name)} +
    + ${this.formatFileSize(file.size)} +
  • + ` + }) + + this.fileListTarget.innerHTML = html + this.totalSizeTarget.textContent = `${this.files.length} file(s) selected (${this.formatFileSize(totalSize)} total)` + } + + clearPreview() { + this.previewTarget.classList.add("hidden") + this.fileListTarget.innerHTML = "" + this.totalSizeTarget.textContent = "" + this.submitButtonTarget.disabled = true + this.files = [] + } + + formatFileSize(bytes) { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } +} diff --git a/app/javascript/controllers/folder_tree_controller.js b/app/javascript/controllers/folder_tree_controller.js new file mode 100644 index 0000000..e7c8976 --- /dev/null +++ b/app/javascript/controllers/folder_tree_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="folder-tree" +// Simple controller for folder tree interactions +export default class extends Controller { + connect() { + // Future: Add expand/collapse functionality for folders + } + + toggleFolder(event) { + const folderId = event.currentTarget.dataset.folderId + const filesContainer = document.getElementById(`folder-files-${folderId}`) + + if (filesContainer) { + filesContainer.classList.toggle("hidden") + + // Toggle chevron icon + const chevron = event.currentTarget.querySelector(".chevron-icon") + if (chevron) { + chevron.classList.toggle("rotate-90") + } + } + } +} diff --git a/app/javascript/controllers/pdf_viewer_controller.js b/app/javascript/controllers/pdf_viewer_controller.js new file mode 100644 index 0000000..68ec73a --- /dev/null +++ b/app/javascript/controllers/pdf_viewer_controller.js @@ -0,0 +1,116 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="pdf-viewer" +// Uses PDF.js for rendering - loads dynamically +export default class extends Controller { + static targets = ["loading", "container", "error", "currentPage", "totalPages", "zoomLevel"] + static values = { url: String } + + async connect() { + this.currentPage = 1 + this.scale = 1.5 + this.pdfDoc = null + + if (this.urlValue) { + await this.loadPdf() + } + } + + async loadPdf() { + try { + // Dynamically load PDF.js from CDN + if (!window.pdfjsLib) { + await this.loadPdfJs() + } + + window.pdfjsLib.GlobalWorkerOptions.workerSrc = + "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js" + + this.pdfDoc = await window.pdfjsLib.getDocument(this.urlValue).promise + this.totalPagesTarget.textContent = this.pdfDoc.numPages + + this.loadingTarget.classList.add("hidden") + this.containerTarget.classList.remove("hidden") + + await this.renderAllPages() + } catch (error) { + console.error("PDF load error:", error) + this.loadingTarget.classList.add("hidden") + this.errorTarget.classList.remove("hidden") + } + } + + async loadPdfJs() { + return new Promise((resolve, reject) => { + const script = document.createElement("script") + script.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" + script.onload = resolve + script.onerror = reject + document.head.appendChild(script) + }) + } + + async renderAllPages() { + this.containerTarget.innerHTML = "" + + for (let pageNum = 1; pageNum <= this.pdfDoc.numPages; pageNum++) { + const page = await this.pdfDoc.getPage(pageNum) + const viewport = page.getViewport({ scale: this.scale * 2 }) // 2x for quality + + const canvas = document.createElement("canvas") + canvas.className = "shadow-lg" + canvas.style.width = `${viewport.width / 2}px` + canvas.style.height = `${viewport.height / 2}px` + canvas.width = viewport.width + canvas.height = viewport.height + + const context = canvas.getContext("2d") + await page.render({ canvasContext: context, viewport: viewport }).promise + + this.containerTarget.appendChild(canvas) + } + } + + previousPage() { + if (this.currentPage > 1) { + this.currentPage-- + this.currentPageTarget.textContent = this.currentPage + this.scrollToPage(this.currentPage) + } + } + + nextPage() { + if (this.pdfDoc && this.currentPage < this.pdfDoc.numPages) { + this.currentPage++ + this.currentPageTarget.textContent = this.currentPage + this.scrollToPage(this.currentPage) + } + } + + scrollToPage(pageNum) { + const canvases = this.containerTarget.querySelectorAll("canvas") + if (canvases[pageNum - 1]) { + canvases[pageNum - 1].scrollIntoView({ behavior: "smooth", block: "start" }) + } + } + + async zoomIn() { + if (this.scale < 3) { + this.scale += 0.25 + this.updateZoomLevel() + await this.renderAllPages() + } + } + + async zoomOut() { + if (this.scale > 0.5) { + this.scale -= 0.25 + this.updateZoomLevel() + await this.renderAllPages() + } + } + + updateZoomLevel() { + this.zoomLevelTarget.textContent = `${Math.round(this.scale * 100)}%` + } +} diff --git a/app/javascript/controllers/tree_controller.js b/app/javascript/controllers/tree_controller.js new file mode 100644 index 0000000..94cf01a --- /dev/null +++ b/app/javascript/controllers/tree_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="tree" +// Handles expandable/collapsible tree navigation +export default class extends Controller { + static targets = ["item", "children"] + + connect() { + // Initialize all items as collapsed + this.childrenTargets.forEach(children => { + children.classList.add("hidden") + }) + } + + toggle(event) { + const item = event.currentTarget + const children = item.nextElementSibling + + if (children && children.dataset.treeTarget === "children") { + children.classList.toggle("hidden") + + // Update the expand/collapse icon + const icon = item.querySelector("[data-icon]") + if (icon) { + icon.classList.toggle("rotate-90") + } + } + } +} diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 0000000..531e2af --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,10 @@ +class Admin < ApplicationRecord + has_secure_password + + has_many :local_files, dependent: :destroy + has_many :activity_logs, class_name: "AdminActivityLog", dependent: :destroy + + validates :first_name, presence: true + validates :last_name, presence: true + validates :login_id, presence: true, uniqueness: true +end diff --git a/app/models/admin_activity_log.rb b/app/models/admin_activity_log.rb new file mode 100644 index 0000000..71ba5c4 --- /dev/null +++ b/app/models/admin_activity_log.rb @@ -0,0 +1,11 @@ +class AdminActivityLog < ApplicationRecord + belongs_to :admin + + ACTION_TYPES = %w[login upload delete].freeze + + validates :action_type, presence: true, inclusion: { in: ACTION_TYPES } + + scope :logins, -> { where(action_type: "login") } + scope :uploads, -> { where(action_type: "upload") } + scope :deletes, -> { where(action_type: "delete") } +end diff --git a/app/models/author.rb b/app/models/author.rb new file mode 100644 index 0000000..f924d11 --- /dev/null +++ b/app/models/author.rb @@ -0,0 +1,6 @@ +class Author < ApplicationRecord + has_many :topic_authors, dependent: :destroy + has_many :topics, through: :topic_authors + + validates :name, presence: true +end diff --git a/app/models/content_provider.rb b/app/models/content_provider.rb new file mode 100644 index 0000000..767c118 --- /dev/null +++ b/app/models/content_provider.rb @@ -0,0 +1,5 @@ +class ContentProvider < ApplicationRecord + has_many :topics, dependent: :destroy + + validates :name, presence: true +end diff --git a/app/models/favorite.rb b/app/models/favorite.rb new file mode 100644 index 0000000..3e74d79 --- /dev/null +++ b/app/models/favorite.rb @@ -0,0 +1,6 @@ +class Favorite < ApplicationRecord + belongs_to :user + belongs_to :topic + + validates :user_id, uniqueness: { scope: :topic_id } +end diff --git a/app/models/local_file.rb b/app/models/local_file.rb new file mode 100644 index 0000000..d8b72c8 --- /dev/null +++ b/app/models/local_file.rb @@ -0,0 +1,7 @@ +class LocalFile < ApplicationRecord + belongs_to :admin + + has_one_attached :file + + validates :folder_path, presence: true +end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 0000000..9fde4a0 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,6 @@ +class Tag < ApplicationRecord + has_many :topic_tags, dependent: :destroy + has_many :topics, through: :topic_tags + + validates :name, presence: true, uniqueness: true +end diff --git a/app/models/topic.rb b/app/models/topic.rb new file mode 100644 index 0000000..5d78414 --- /dev/null +++ b/app/models/topic.rb @@ -0,0 +1,21 @@ +class Topic < ApplicationRecord + belongs_to :content_provider + + has_many :topic_files, dependent: :destroy + has_many :topic_authors, dependent: :destroy + has_many :authors, through: :topic_authors + has_many :topic_tags, dependent: :destroy + has_many :tags, through: :topic_tags + has_many :favorites, dependent: :destroy + has_many :favorited_by_users, through: :favorites, source: :user + + validates :title, presence: true + validates :year, presence: true + validates :topic_external_id, uniqueness: true, allow_nil: true + + scope :by_year, ->(year) { where(year: year) } + scope :by_month, ->(month) { where(month: month) } + scope :new_uploads, -> { where("created_at > ?", 30.days.ago) } + scope :top_topics, -> { order(view_count: :desc).limit(50) } + scope :favorites_for, ->(user) { joins(:favorites).where(favorites: { user: user }) } +end diff --git a/app/models/topic_author.rb b/app/models/topic_author.rb new file mode 100644 index 0000000..d482bfc --- /dev/null +++ b/app/models/topic_author.rb @@ -0,0 +1,4 @@ +class TopicAuthor < ApplicationRecord + belongs_to :topic + belongs_to :author +end diff --git a/app/models/topic_file.rb b/app/models/topic_file.rb new file mode 100644 index 0000000..aa6c7d9 --- /dev/null +++ b/app/models/topic_file.rb @@ -0,0 +1,11 @@ +class TopicFile < ApplicationRecord + belongs_to :topic + + has_one_attached :file + + validates :filename, presence: true + validates :file_type, presence: true, inclusion: { in: %w[pdf mp3] } + + scope :pdfs, -> { where(file_type: "pdf") } + scope :mp3s, -> { where(file_type: "mp3") } +end diff --git a/app/models/topic_tag.rb b/app/models/topic_tag.rb new file mode 100644 index 0000000..349add4 --- /dev/null +++ b/app/models/topic_tag.rb @@ -0,0 +1,4 @@ +class TopicTag < ApplicationRecord + belongs_to :topic + belongs_to :tag +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..021c2cd --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,34 @@ +class User < ApplicationRecord + has_many :favorites, dependent: :destroy + has_many :favorite_topics, through: :favorites, source: :topic + has_many :activity_logs, class_name: "UserActivityLog", dependent: :destroy + has_many :user_activity_logs, dependent: :destroy + + validates :first_name, presence: true + validates :last_name, presence: true + validates :login_id, presence: true, uniqueness: true + + before_validation :generate_login_id, on: :create + + private + + def generate_login_id + return if login_id.present? + return unless first_name.present? && last_name.present? + + base_login = "#{first_name}.#{last_name}".downcase.gsub(/\s+/, "") + self.login_id = unique_login_id(base_login) + end + + def unique_login_id(base) + candidate = base + counter = 1 + + while User.exists?(login_id: candidate) + candidate = "#{base}#{counter}" + counter += 1 + end + + candidate + end +end diff --git a/app/models/user_activity_log.rb b/app/models/user_activity_log.rb new file mode 100644 index 0000000..d7ac00f --- /dev/null +++ b/app/models/user_activity_log.rb @@ -0,0 +1,14 @@ +class UserActivityLog < ApplicationRecord + belongs_to :user + belongs_to :topic, optional: true + + ACTION_TYPES = %w[login view search favorite unfavorite view_local_file].freeze + + validates :action_type, presence: true, inclusion: { in: ACTION_TYPES } + + scope :logins, -> { where(action_type: "login") } + scope :views, -> { where(action_type: "view") } + scope :searches, -> { where(action_type: "search") } + scope :favorites, -> { where(action_type: "favorite") } + scope :unfavorites, -> { where(action_type: "unfavorite") } +end diff --git a/app/services/admins_importer.rb b/app/services/admins_importer.rb new file mode 100644 index 0000000..672c691 --- /dev/null +++ b/app/services/admins_importer.rb @@ -0,0 +1,44 @@ +class AdminsImporter + DEFAULT_PASSWORD = "changeme123" + + attr_reader :file_path + + def initialize(file_path) + @file_path = file_path + end + + def import + parsed_admins = AdminsXmlParser.new(file_path).parse + results = { created: 0, updated: 0, errors: [], password_reset_needed: [] } + + parsed_admins.each do |admin_data| + import_admin(admin_data, results) + end + + results + end + + private + + def import_admin(admin_data, results) + admin = Admin.find_or_initialize_by(login_id: admin_data[:login_id]) + is_new = admin.new_record? + + admin.assign_attributes( + first_name: admin_data[:first_name], + last_name: admin_data[:last_name] + ) + + if is_new + admin.password = DEFAULT_PASSWORD + admin.password_confirmation = DEFAULT_PASSWORD + results[:password_reset_needed] << admin_data[:login_id] + end + + if admin.save + is_new ? results[:created] += 1 : results[:updated] += 1 + else + results[:errors] << "Admin #{admin_data[:login_id]}: #{admin.errors.full_messages.join(', ')}" + end + end +end diff --git a/app/services/admins_xml_parser.rb b/app/services/admins_xml_parser.rb new file mode 100644 index 0000000..25df7b3 --- /dev/null +++ b/app/services/admins_xml_parser.rb @@ -0,0 +1,12 @@ +class AdminsXmlParser < XmlParser + def parse + document.xpath("//ADMIN_DETAIL").map do |node| + { + first_name: node.at_xpath("USER_FNAME")&.text&.strip, + last_name: node.at_xpath("USER_LNAME")&.text&.strip, + login_id: node.at_xpath("USER_ID")&.text&.strip&.downcase, + legacy_password_hash: node.at_xpath("PASSWORD")&.text&.strip + } + end + end +end diff --git a/app/services/content_importer.rb b/app/services/content_importer.rb new file mode 100644 index 0000000..210b8c6 --- /dev/null +++ b/app/services/content_importer.rb @@ -0,0 +1,106 @@ +class ContentImporter + attr_reader :file_path + + def initialize(file_path) + @file_path = file_path + end + + def import + parsed_content = ContentXmlParser.new(file_path).parse + results = { + providers: { created: 0 }, + topics: { created: 0, updated: 0 }, + files: { created: 0 }, + authors: { created: 0 }, + tags: { created: 0 }, + errors: [] + } + + parsed_content.each do |provider_data| + import_provider(provider_data, results) + end + + results + end + + def topic_id_map + Topic.pluck(:topic_external_id, :id).to_h + end + + private + + def import_provider(provider_data, results) + provider = ContentProvider.find_or_create_by!(name: provider_data[:name]) + results[:providers][:created] += 1 if provider.previously_new_record? + + provider_data[:topics].each do |topic_data| + import_topic(provider, topic_data, results) + end + end + + def import_topic(provider, topic_data, results) + topic = Topic.find_or_initialize_by(topic_external_id: topic_data[:topic_external_id]) + is_new = topic.new_record? + + topic.assign_attributes( + content_provider: provider, + title: topic_data[:title], + year: topic_data[:year], + month: topic_data[:month], + volume: topic_data[:volume], + issue: topic_data[:issue], + view_count: topic_data[:view_count] + ) + + if topic.save + is_new ? results[:topics][:created] += 1 : results[:topics][:updated] += 1 + import_files(topic, topic_data[:files], results) + import_authors(topic, topic_data[:authors], results) + import_tags(topic, topic_data[:tags], results) + else + results[:errors] << "Topic #{topic_data[:topic_external_id]}: #{topic.errors.full_messages.join(', ')}" + end + end + + def import_files(topic, files_data, results) + files_data.each do |file_data| + next if file_data[:file_type].nil? + + topic_file = TopicFile.find_or_initialize_by( + topic: topic, + filename: file_data[:filename] + ) + + if topic_file.new_record? + topic_file.assign_attributes( + file_size: file_data[:file_size], + file_type: file_data[:file_type] + ) + + if topic_file.save + results[:files][:created] += 1 + else + results[:errors] << "File #{file_data[:filename]}: #{topic_file.errors.full_messages.join(', ')}" + end + end + end + end + + def import_authors(topic, author_names, results) + author_names.each do |name| + author = Author.find_or_create_by!(name: name) + results[:authors][:created] += 1 if author.previously_new_record? + + TopicAuthor.find_or_create_by!(topic: topic, author: author) + end + end + + def import_tags(topic, tag_names, results) + tag_names.each do |name| + tag = Tag.find_or_create_by!(name: name) + results[:tags][:created] += 1 if tag.previously_new_record? + + TopicTag.find_or_create_by!(topic: topic, tag: tag) + end + end +end diff --git a/app/services/content_xml_parser.rb b/app/services/content_xml_parser.rb new file mode 100644 index 0000000..46b6072 --- /dev/null +++ b/app/services/content_xml_parser.rb @@ -0,0 +1,93 @@ +class ContentXmlParser < XmlParser + def parse + providers = [] + + document.xpath("//Content_Provider").each do |provider_node| + provider_name = provider_node["name"] + topics = parse_topics(provider_node) + providers << { name: provider_name, topics: topics } + end + + providers + end + + private + + def parse_topics(provider_node) + topics = [] + + provider_node.xpath(".//title").each do |title_node| + year_node = title_node.ancestors("topic_year").first + month_node = title_node.ancestors("topic_month").first + + topics << { + title: title_node["name"], + year: year_node&.attr("year")&.to_i, + month: parse_month(month_node&.attr("month")), + topic_external_id: title_node.at_xpath("topic_id")&.text&.strip, + view_count: title_node.at_xpath("counter")&.text&.to_i || 0, + volume: title_node.at_xpath("topic_volume")&.text&.strip, + issue: title_node.at_xpath("topic_issue")&.text&.strip, + files: parse_files(title_node), + authors: parse_authors(title_node), + tags: parse_tags(title_node) + } + end + + topics + end + + def parse_month(month_string) + return nil if month_string.blank? + + month_string.split("_").last + end + + def parse_files(title_node) + files = [] + files_node = title_node.at_xpath("topic_files") + return files unless files_node + + files_node.children.each do |file_node| + next unless file_node.element? && file_node.name.start_with?("file_name_") + + filename = file_node.text&.strip + next if filename.blank? + + files << { + filename: filename, + file_size: file_node["file_size"]&.to_i, + file_type: determine_file_type(filename) + } + end + + files + end + + def parse_authors(title_node) + authors = [] + authors_node = title_node.at_xpath("topic_authors") + return authors unless authors_node + + authors_node.children.each do |author_node| + next unless author_node.element? && author_node.name.start_with?("topic_author_") + + name = author_node.text&.strip + authors << name unless name.blank? + end + + authors + end + + def parse_tags(title_node) + tags_text = title_node.at_xpath("topic_tags")&.text&.strip + return [] if tags_text.blank? || tags_text == "N/A" + + tags_text.split(",").map(&:strip).reject(&:blank?) + end + + def determine_file_type(filename) + extension = File.extname(filename).downcase.delete(".") + %w[pdf mp3].include?(extension) ? extension : nil + end +end diff --git a/app/services/search_service.rb b/app/services/search_service.rb new file mode 100644 index 0000000..062c2a9 --- /dev/null +++ b/app/services/search_service.rb @@ -0,0 +1,57 @@ +class SearchService + attr_reader :query + + def initialize(query) + @query = query&.strip + end + + def search + return Topic.none if query.blank? + + Topic.includes(:content_provider, :authors, :tags, :topic_files) + .left_joins(:tags, :authors) + .where(search_conditions) + .distinct + .order(view_count: :desc) + end + + def autocomplete_suggestions(limit: 10) + return [] if query.blank? || query.length < 2 + + suggestions = [] + + # Tag suggestions + suggestions += Tag.where("name LIKE ?", "#{query}%") + .limit(5) + .pluck(:name) + .map { |name| { type: "tag", value: name } } + + # Title suggestions + suggestions += Topic.where("title LIKE ?", "%#{query}%") + .limit(5) + .pluck(:title) + .map { |title| { type: "topic", value: title } } + + # Author suggestions + suggestions += Author.where("name LIKE ?", "%#{query}%") + .limit(5) + .pluck(:name) + .map { |name| { type: "author", value: name } } + + suggestions.uniq { |s| s[:value].downcase }.first(limit) + end + + def found? + search.exists? + end + + private + + def search_conditions + sanitized_query = "%#{query}%" + + Topic.arel_table[:title].matches(sanitized_query) + .or(Tag.arel_table[:name].matches(sanitized_query)) + .or(Author.arel_table[:name].matches(sanitized_query)) + end +end diff --git a/app/services/stats_service.rb b/app/services/stats_service.rb new file mode 100644 index 0000000..8ff99e0 --- /dev/null +++ b/app/services/stats_service.rb @@ -0,0 +1,177 @@ +class StatsService + def initialize(days: 30) + @days = days + @start_date = days.days.ago.beginning_of_day + end + + # Overview statistics + def overview + { + total_users: User.count, + total_topics: Topic.count, + total_files: TopicFile.count, + total_authors: Author.count, + total_tags: Tag.count, + total_favorites: Favorite.count, + total_local_files: LocalFile.count + } + end + + # Activity summary for the period + def activity_summary + base_query = UserActivityLog.where("created_at >= ?", @start_date) + + { + total_activities: base_query.count, + logins: base_query.logins.count, + views: base_query.views.count, + searches: base_query.searches.count, + favorites: base_query.favorites.count, + unfavorites: base_query.unfavorites.count, + unique_users: base_query.distinct.count(:user_id) + } + end + + # Top users by activity count + def top_users(limit: 10) + User.joins(:user_activity_logs) + .where("user_activity_logs.created_at >= ?", @start_date) + .group("users.id") + .select("users.*, COUNT(user_activity_logs.id) as activity_count") + .order("activity_count DESC") + .limit(limit) + end + + # Most viewed topics + def hot_topics(limit: 10) + Topic.includes(:content_provider, :authors) + .order(view_count: :desc) + .limit(limit) + end + + # Recently viewed topics (in the period) + def recently_viewed_topics(limit: 10) + Topic.joins(:user_activity_logs) + .where("user_activity_logs.action_type = ?", "view") + .where("user_activity_logs.created_at >= ?", @start_date) + .group("topics.id") + .select("topics.*, COUNT(user_activity_logs.id) as recent_views") + .order("recent_views DESC") + .limit(limit) + end + + # Most favorited topics + def most_favorited_topics(limit: 10) + Topic.joins(:favorites) + .group("topics.id") + .select("topics.*, COUNT(favorites.id) as favorites_count") + .order("favorites_count DESC") + .limit(limit) + end + + # Popular search terms + def popular_searches(limit: 20) + UserActivityLog.searches + .where("created_at >= ?", @start_date) + .where.not(search_term: [ nil, "" ]) + .group(:search_term) + .order("count_all DESC") + .limit(limit) + .count + end + + # Failed searches (no results found) + def failed_searches(limit: 10) + UserActivityLog.searches + .where("created_at >= ?", @start_date) + .where(search_found: false) + .where.not(search_term: [ nil, "" ]) + .group(:search_term) + .order("count_all DESC") + .limit(limit) + .count + end + + # Logins per day for chart + def logins_per_day + UserActivityLog.logins + .where("created_at >= ?", @start_date) + .group_by_day(:created_at) + .count + end + + # Activity per day for chart + def activity_per_day + UserActivityLog.where("created_at >= ?", @start_date) + .group_by_day(:created_at) + .count + end + + # Recent activity log entries + def recent_activities(limit: 50) + UserActivityLog.includes(:user, :topic) + .order(created_at: :desc) + .limit(limit) + end + + # Admin activity log entries + def admin_activities(limit: 50) + AdminActivityLog.includes(:admin) + .order(created_at: :desc) + .limit(limit) + end + + # Content by year + def content_by_year + Topic.group(:year) + .order(year: :desc) + .count + end + + # Content by provider + def content_by_provider + Topic.joins(:content_provider) + .group("content_providers.name") + .count + end + + private + + # Helper to group by day (simple implementation without groupdate gem) + def group_by_day(relation, column) + relation.group(date_sql_for_column(column)) + end + + # Return pre-defined SQL for allowed columns to prevent SQL injection + # No string interpolation - each column has a literal SQL expression + DATE_SQL_EXPRESSIONS = { + "created_at" => Arel.sql("DATE(created_at)"), + "updated_at" => Arel.sql("DATE(updated_at)") + }.freeze + + def date_sql_for_column(column) + column_name = column.to_s + DATE_SQL_EXPRESSIONS.fetch(column_name) do + raise ArgumentError, "Invalid column for date grouping: #{column_name}" + end + end +end + +# Extend ActiveRecord to add group_by_day if groupdate gem is not available +module GroupByDayExtension + # Pre-defined SQL expressions for allowed columns (no interpolation) + DATE_SQL_EXPRESSIONS = { + "created_at" => Arel.sql("DATE(created_at)"), + "updated_at" => Arel.sql("DATE(updated_at)") + }.freeze + + def group_by_day(column) + column_name = column.to_s + sql_expr = DATE_SQL_EXPRESSIONS.fetch(column_name) do + raise ArgumentError, "Invalid column for date grouping: #{column_name}" + end + group(sql_expr) + end +end + +ActiveRecord::Relation.include(GroupByDayExtension) diff --git a/app/services/users_importer.rb b/app/services/users_importer.rb new file mode 100644 index 0000000..028206d --- /dev/null +++ b/app/services/users_importer.rb @@ -0,0 +1,50 @@ +class UsersImporter + attr_reader :file_path, :topic_id_map + + def initialize(file_path, topic_id_map: {}) + @file_path = file_path + @topic_id_map = topic_id_map + end + + def import + parsed_users = UsersXmlParser.new(file_path).parse + results = { created: 0, updated: 0, errors: [] } + + parsed_users.each do |user_data| + import_user(user_data, results) + end + + results + end + + private + + def import_user(user_data, results) + user = User.find_or_initialize_by(login_id: user_data[:login_id]) + is_new = user.new_record? + + user.assign_attributes( + first_name: user_data[:first_name], + last_name: user_data[:last_name], + login_count: user_data[:login_count] + ) + + if user.save + import_favorites(user, user_data[:favorites]) + is_new ? results[:created] += 1 : results[:updated] += 1 + else + results[:errors] << "User #{user_data[:login_id]}: #{user.errors.full_messages.join(', ')}" + end + end + + def import_favorites(user, favorite_topic_ids) + return if favorite_topic_ids.blank? + + favorite_topic_ids.each do |external_id| + topic_id = topic_id_map[external_id] + next unless topic_id + + Favorite.find_or_create_by(user: user, topic_id: topic_id) + end + end +end diff --git a/app/services/users_xml_parser.rb b/app/services/users_xml_parser.rb new file mode 100644 index 0000000..94828f8 --- /dev/null +++ b/app/services/users_xml_parser.rb @@ -0,0 +1,21 @@ +class UsersXmlParser < XmlParser + def parse + document.xpath("//USER_DETAIL").map do |node| + { + first_name: node.at_xpath("USER_FNAME")&.text&.strip, + last_name: node.at_xpath("USER_LNAME")&.text&.strip, + login_id: node.at_xpath("USER_ID")&.text&.strip&.downcase, + login_count: node.at_xpath("LOGIN_COUNTER")&.text&.to_i || 0, + favorites: parse_favorites(node.at_xpath("FAVOURITE")&.text) + } + end + end + + private + + def parse_favorites(favorites_text) + return [] if favorites_text.blank? + + favorites_text.split(",").map(&:strip).reject(&:blank?) + end +end diff --git a/app/services/xml_parser.rb b/app/services/xml_parser.rb new file mode 100644 index 0000000..922732b --- /dev/null +++ b/app/services/xml_parser.rb @@ -0,0 +1,19 @@ +require "nokogiri" + +class XmlParser + attr_reader :file_path + + def initialize(file_path) + @file_path = file_path + end + + def parse + raise NotImplementedError, "Subclasses must implement #parse" + end + + private + + def document + @document ||= Nokogiri::XML(File.read(file_path)) + end +end diff --git a/app/views/admin/dashboard/_activity_badge.html.erb b/app/views/admin/dashboard/_activity_badge.html.erb new file mode 100644 index 0000000..666163f --- /dev/null +++ b/app/views/admin/dashboard/_activity_badge.html.erb @@ -0,0 +1,22 @@ +<%# locals: (action_type:) %> +<% + badge_config = case action_type + when "login" + { bg: "bg-indigo-100", text: "text-indigo-800", label: "Login" } + when "view" + { bg: "bg-blue-100", text: "text-blue-800", label: "View" } + when "search" + { bg: "bg-green-100", text: "text-green-800", label: "Search" } + when "favorite" + { bg: "bg-red-100", text: "text-red-800", label: "Favorite" } + when "unfavorite" + { bg: "bg-gray-100", text: "text-gray-800", label: "Unfavorite" } + when "view_local_file" + { bg: "bg-purple-100", text: "text-purple-800", label: "Local File" } + else + { bg: "bg-gray-100", text: "text-gray-800", label: action_type&.humanize || "Unknown" } + end +%> + + <%= badge_config[:label] %> + diff --git a/app/views/admin/dashboard/_stat_card.html.erb b/app/views/admin/dashboard/_stat_card.html.erb new file mode 100644 index 0000000..56489cd --- /dev/null +++ b/app/views/admin/dashboard/_stat_card.html.erb @@ -0,0 +1,45 @@ +<%# locals: (icon:, label:, value:, color: "gray") %> +
    +
    +
    +
    + <% case icon %> + <% when "users" %> + + + + <% when "document" %> + + + + <% when "file" %> + + + + <% when "heart" %> + + + + <% when "tag" %> + + + + <% when "login" %> + + + + <% else %> + + + + <% end %> +
    +
    +
    +
    <%= label %>
    +
    <%= number_with_delimiter(value) %>
    +
    +
    +
    +
    +
    diff --git a/app/views/admin/dashboard/activity_log.html.erb b/app/views/admin/dashboard/activity_log.html.erb new file mode 100644 index 0000000..954bb52 --- /dev/null +++ b/app/views/admin/dashboard/activity_log.html.erb @@ -0,0 +1,69 @@ +
    +
    +

    User Activity Log

    + <%= link_to "Back to Dashboard", admin_root_path, class: "text-sm text-indigo-600 hover:text-indigo-800" %> +
    + +
    +
    + + + + + + + + + + + + + + <% @activities.each do |activity| %> + + + + + + + + + + <% end %> + <% if @activities.empty? %> + + + + <% end %> + +
    TimeUserActionDetailsBrowserOSIP
    + <%= activity.created_at.strftime("%b %d, %Y %I:%M %p") %> + + <%= activity.user&.first_name %> <%= activity.user&.last_name %> +
    <%= activity.user&.login_id %>
    +
    + <%= render "admin/dashboard/activity_badge", action_type: activity.action_type %> + + <% if activity.topic %> + <%= activity.topic.title %> + <% if activity.file_type.present? %> + (<%= activity.file_type %>) + <% end %> + <% elsif activity.search_term.present? %> + "<%= activity.search_term %>" + <% if activity.search_found %> + (found) + <% else %> + (no results) + <% end %> + <% end %> + + <%= activity.browser %> + + <%= activity.os %> + + <%= activity.ip_address %> +
    No activity recorded yet
    +
    +
    +
    diff --git a/app/views/admin/dashboard/admin_log.html.erb b/app/views/admin/dashboard/admin_log.html.erb new file mode 100644 index 0000000..0bfd387 --- /dev/null +++ b/app/views/admin/dashboard/admin_log.html.erb @@ -0,0 +1,59 @@ +
    +
    +

    Admin Activity Log

    + <%= link_to "Back to Dashboard", admin_root_path, class: "text-sm text-indigo-600 hover:text-indigo-800" %> +
    + +
    +
    + + + + + + + + + + + + + + <% @activities.each do |activity| %> + + + + + + + + + + <% end %> + <% if @activities.empty? %> + + + + <% end %> + +
    TimeAdminActionDetailsBrowserOSIP
    + <%= activity.created_at.strftime("%b %d, %Y %I:%M %p") %> + + <%= activity.admin&.first_name %> <%= activity.admin&.last_name %> +
    <%= activity.admin&.login_id %>
    +
    + + <%= activity.action_type&.humanize %> + + + <%= activity.details %> + + <%= activity.browser %> + + <%= activity.os %> + + <%= activity.ip_address %> +
    No admin activity recorded yet
    +
    +
    +
    diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb new file mode 100644 index 0000000..9668875 --- /dev/null +++ b/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,259 @@ +
    + +
    +

    Dashboard

    +
    + Period: + <% [ 7, 30, 90 ].each do |days| %> + <%= link_to "#{days} days", + admin_root_path(period: days), + class: "px-3 py-1 text-sm rounded-md #{@period == days ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %> + <% end %> +
    +
    + + +
    + <%= render "admin/dashboard/stat_card", icon: "users", label: "Total Users", value: @overview[:total_users], color: "indigo" %> + <%= render "admin/dashboard/stat_card", icon: "document", label: "Topics", value: @overview[:total_topics], color: "blue" %> + <%= render "admin/dashboard/stat_card", icon: "file", label: "Media Files", value: @overview[:total_files], color: "green" %> + <%= render "admin/dashboard/stat_card", icon: "heart", label: "Favorites", value: @overview[:total_favorites], color: "red" %> +
    + + +
    +

    Activity Summary (Last <%= @period %> Days)

    +
    +
    +
    <%= @activity_summary[:total_activities] %>
    +
    Total Activities
    +
    +
    +
    <%= @activity_summary[:logins] %>
    +
    Logins
    +
    +
    +
    <%= @activity_summary[:views] %>
    +
    Topic Views
    +
    +
    +
    <%= @activity_summary[:searches] %>
    +
    Searches
    +
    +
    +
    <%= @activity_summary[:favorites] %>
    +
    Favorites Added
    +
    +
    +
    <%= @activity_summary[:unfavorites] %>
    +
    Unfavorited
    +
    +
    +
    <%= @activity_summary[:unique_users] %>
    +
    Active Users
    +
    +
    +
    + + + <% if @logins_per_day.any? %> +
    +

    Login Activity

    +
    + <% max_logins = [ @logins_per_day.values.max || 1, 1 ].max %> + <% @logins_per_day.each do |date, count| %> +
    +
    +
    + <%= date.strftime('%m/%d') %> +
    +
    + <% end %> +
    +
    + <% end %> + +
    + +
    +
    +

    Top Active Users

    +
    +
      + <% @top_users.each_with_index do |user, index| %> +
    • +
      + + <%= index + 1 %> + + + <%= user.first_name %> <%= user.last_name %> + +
      + <%= user.activity_count %> activities +
    • + <% end %> + <% if @top_users.empty? %> +
    • No activity recorded yet
    • + <% end %> +
    +
    + + +
    +
    +

    Most Viewed Topics

    +
    +
      + <% @hot_topics.each_with_index do |topic, index| %> +
    • +
      +
      + + <%= index + 1 %> + +
      +

      <%= topic.title %>

      +

      <%= topic.content_provider&.name %> | <%= topic.year %>

      +
      +
      + + + + + + <%= topic.view_count %> + +
      +
    • + <% end %> + <% if @hot_topics.empty? %> +
    • No topics yet
    • + <% end %> +
    +
    +
    + +
    + +
    +
    +

    Popular Searches

    +
    +
    + <% if @popular_searches.any? %> +
    + <% max_count = @popular_searches.values.max || 1 %> + <% @popular_searches.each do |term, count| %> + <% size = ((count.to_f / max_count) * 100).round %> + + <%= term %> + (<%= count %>) + + <% end %> +
    + <% else %> +

    No searches recorded yet

    + <% end %> +
    +
    + + +
    +
    +

    Searches with No Results

    +

    Consider adding content for these terms

    +
    +
    + <% if @failed_searches.any? %> +
      + <% @failed_searches.each do |term, count| %> +
    • + <%= term %> + <%= count %> failed +
    • + <% end %> +
    + <% else %> +

    No failed searches

    + <% end %> +
    +
    +
    + + + <% if @content_by_provider.any? %> +
    +

    Content by Provider

    +
    + <% total = @content_by_provider.values.sum %> + <% @content_by_provider.each do |provider, count| %> +
    +
    + <%= provider %> + <%= count %> topics (<%= (count.to_f / total * 100).round(1) %>%) +
    +
    +
    +
    +
    + <% end %> +
    +
    + <% end %> + + +
    +
    +

    Recent Activity

    + <%= link_to "View All", admin_activity_log_path, class: "text-sm text-indigo-600 hover:text-indigo-800" %> +
    +
    + + + + + + + + + + + <% @recent_activities.each do |activity| %> + + + + + + + <% end %> + <% if @recent_activities.empty? %> + + + + <% end %> + +
    TimeUserActionDetails
    + <%= time_ago_in_words(activity.created_at) %> ago + + <%= activity.user&.first_name %> <%= activity.user&.last_name %> + + <%= render "admin/dashboard/activity_badge", action_type: activity.action_type %> + + <% if activity.topic %> + <%= activity.topic.title %> + <% elsif activity.search_term.present? %> + "<%= activity.search_term %>" + <% unless activity.search_found %> + (no results) + <% end %> + <% end %> +
    No activity recorded yet
    +
    +
    +
    diff --git a/app/views/admin/local_files/_file_row.html.erb b/app/views/admin/local_files/_file_row.html.erb new file mode 100644 index 0000000..8b44a93 --- /dev/null +++ b/app/views/admin/local_files/_file_row.html.erb @@ -0,0 +1,70 @@ +
  • +
    +
    + <% if local_file.file.attached? %> + <% content_type = local_file.file.content_type %> + <% if content_type&.start_with?("image/") %> + + + + <% elsif content_type == "application/pdf" %> + + + + <% elsif content_type&.start_with?("audio/") %> + + + + <% elsif content_type&.start_with?("video/") %> + + + + <% else %> + + + + <% end %> + <% end %> + +
    +

    + <%= local_file.file.filename if local_file.file.attached? %> +

    +

    + <% if local_file.file.attached? %> + <%= number_to_human_size(local_file.file.byte_size) %> | + <%= local_file.file.content_type %> + <% end %> + + Uploaded by <%= local_file.admin.first_name %> <%= local_file.admin.last_name %> + on <%= local_file.created_at.strftime("%b %d, %Y at %I:%M %p") %> + +

    +
    +
    + +
    + <% if local_file.file.attached? %> + <%= link_to admin_local_file_path(local_file), + class: "text-indigo-600 hover:text-indigo-800 p-2", + title: "View/Download", + target: "_blank" do %> + + + + + <% end %> + <% end %> + + <%= button_to admin_local_file_path(local_file), + method: :delete, + class: "text-red-600 hover:text-red-800 p-2", + title: "Delete", + data: { turbo_confirm: "Are you sure you want to delete this file?", turbo_stream: true } do %> + + + + <% end %> +
    +
    +
  • diff --git a/app/views/admin/local_files/index.html.erb b/app/views/admin/local_files/index.html.erb new file mode 100644 index 0000000..e6e81e0 --- /dev/null +++ b/app/views/admin/local_files/index.html.erb @@ -0,0 +1,78 @@ +
    +
    +

    Local Files

    + <%= link_to new_admin_local_file_path, + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" do %> + + + + Upload Files + <% end %> +
    + + <% if @local_files.any? %> +
    +
    +
    +

    + <%= @local_files.count %> file(s) uploaded +

    +
    + Total size: <%= number_to_human_size(@local_files.sum { |f| f.file.blob&.byte_size || 0 }) %> +
    +
    +
    + +
    + +
    +
    + + + + All Files +
    +
    + + +
      + <% @local_files.group_by(&:folder_path).sort.each do |folder_path, files| %> +
    • +
      +
      + + + + <%= folder_path %> + (<%= files.count %> files) +
      + <%= button_to destroy_folder_admin_local_files_path(folder_path: folder_path), + method: :delete, + class: "text-red-600 hover:text-red-800 text-sm", + data: { turbo_confirm: "Delete this folder and all #{files.count} files?" } do %> + + + + <% end %> +
      +
    • + + <% files.each do |local_file| %> + <%= render "admin/local_files/file_row", local_file: local_file %> + <% end %> + <% end %> +
    +
    +
    + <% else %> +
    + + + +

    No files uploaded yet

    +

    Upload files to make them available for users to access locally.

    + <%= link_to "Upload Files", new_admin_local_file_path, + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" %> +
    + <% end %> +
    diff --git a/app/views/admin/local_files/new.html.erb b/app/views/admin/local_files/new.html.erb new file mode 100644 index 0000000..50a0e2d --- /dev/null +++ b/app/views/admin/local_files/new.html.erb @@ -0,0 +1,113 @@ +
    +
    + <%= link_to admin_local_files_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" do %> + ← Back to files + <% end %> +
    + +
    +
    +

    Upload Files

    +

    + Upload individual files or entire folders to make them available locally. +

    +
    + +
    + <%= form_with url: admin_local_files_path, method: :post, multipart: true, class: "space-y-6" do |f| %> + +
    + +
    + + / + + +
    +

    Leave empty to upload to root folder.

    +
    + + +
    + + +
    + + +
    +
    + + + + +
    + + or drag and drop +
    + +

    + Upload any file type. Maximum 100MB per file. +

    +
    +
    + + + + + +
    + <%= link_to "Cancel", admin_local_files_path, + class: "px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50" %> + +
    + <% end %> +
    +
    +
    diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb new file mode 100644 index 0000000..79b3785 --- /dev/null +++ b/app/views/admin/sessions/new.html.erb @@ -0,0 +1,47 @@ +
    +
    +
    +

    + Admin Login +

    +
    + + <%= form_with url: admin_login_path, method: :post, class: "mt-8 space-y-6" do |f| %> + <% if flash[:alert] %> +
    +

    <%= flash[:alert] %>

    +
    + <% end %> + +
    +
    + <%= f.label :login_id, "Login ID", class: "block text-sm font-medium text-gray-300" %> +
    + <%= f.text_field :login_id, + class: "appearance-none block w-full px-3 py-2 border border-gray-600 rounded-md shadow-sm bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + autofocus: true, + required: true %> +
    +
    + +
    + <%= f.label :password, class: "block text-sm font-medium text-gray-300" %> +
    + <%= f.password_field :password, + class: "appearance-none block w-full px-3 py-2 border border-gray-600 rounded-md shadow-sm bg-gray-700 text-white placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + required: true %> +
    +
    +
    + +
    + <%= f.submit "Sign in", + class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer" %> +
    + <% end %> + +
    + <%= link_to "Back to User Login", login_path, class: "text-sm text-gray-400 hover:text-gray-200" %> +
    +
    +
    diff --git a/app/views/audio_player/show.html.erb b/app/views/audio_player/show.html.erb new file mode 100644 index 0000000..1e9b6a0 --- /dev/null +++ b/app/views/audio_player/show.html.erb @@ -0,0 +1,124 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to topic".html_safe, topic_path(@topic), class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    + +
    +
    +
    +
    + + <%= @topic.content_provider.name %> + + | + <%= @topic.year %> - <%= @topic.month %> +
    +

    <%= @topic.title %>

    + <% if @topic.authors.any? %> +

    <%= @topic.authors.map(&:name).join(", ") %>

    + <% end %> +
    + + <% if user_signed_in? %> +
    + <%= render "topics/favorite_button", topic: @topic, is_favorite: @is_favorite %> +
    + <% end %> +
    +
    + + +
    +
    +

    <%= @topic_file.filename %>

    +
    + + +
    + + + + +
    + + + +
    +
    +
    +
    diff --git a/app/views/errors/audio_not_found.html.erb b/app/views/errors/audio_not_found.html.erb new file mode 100644 index 0000000..ba15032 --- /dev/null +++ b/app/views/errors/audio_not_found.html.erb @@ -0,0 +1,12 @@ +
    +
    + + + +

    Audio Not Found

    +

    + The audio file you're looking for doesn't exist or has been removed. +

    + <%= link_to "Go back to browsing", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" %> +
    +
    diff --git a/app/views/errors/not_found.html.erb b/app/views/errors/not_found.html.erb new file mode 100644 index 0000000..0a6418b --- /dev/null +++ b/app/views/errors/not_found.html.erb @@ -0,0 +1,10 @@ +
    +
    +

    404

    +

    Page Not Found

    +

    + The page you're looking for doesn't exist or has been moved. +

    + <%= link_to "Go back to browsing", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" %> +
    +
    diff --git a/app/views/errors/pdf_not_found.html.erb b/app/views/errors/pdf_not_found.html.erb new file mode 100644 index 0000000..1fd8ab0 --- /dev/null +++ b/app/views/errors/pdf_not_found.html.erb @@ -0,0 +1,12 @@ +
    +
    + + + +

    PDF Not Found

    +

    + The PDF file you're looking for doesn't exist or has been removed. +

    + <%= link_to "Go back to browsing", root_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" %> +
    +
    diff --git a/app/views/errors/unsupported_browser.html.erb b/app/views/errors/unsupported_browser.html.erb new file mode 100644 index 0000000..a63972b --- /dev/null +++ b/app/views/errors/unsupported_browser.html.erb @@ -0,0 +1,76 @@ + + + + + + Browser Not Supported - SkillRx Beacon + + + +
    + + + +

    Browser Not Supported

    +

    + Internet Explorer is not supported. Please use a modern browser for the best experience. +

    + +
    + + diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..a2bf2dc --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,68 @@ + + + + <%= content_for(:title) || "Admin - SkillRx Beacon" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <% if admin_signed_in? %> + + <% end %> + +
    + <% if flash[:notice] %> +
    +

    <%= flash[:notice] %>

    +
    + <% end %> + + <% if flash[:alert] && !action_name.in?(%w[new create]) %> +
    +

    <%= flash[:alert] %>

    +
    + <% end %> + + <%= yield %> +
    + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 0d623e2..6556d47 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,8 +23,34 @@ <%= javascript_importmap_tags %> - -
    + + <% if flash[:notice] %> +
    +
    +
    + + + +

    <%= flash[:notice] %>

    +
    +
    +
    + <% end %> + + <% if flash[:alert] %> +
    +
    +
    + + + +

    <%= flash[:alert] %>

    +
    +
    +
    + <% end %> + +
    <%= yield %>
    diff --git a/app/views/local_files/index.html.erb b/app/views/local_files/index.html.erb new file mode 100644 index 0000000..3f6942f --- /dev/null +++ b/app/views/local_files/index.html.erb @@ -0,0 +1,93 @@ +
    + <%= render "shared/navigation" %> + +
    +

    Local Files

    + + <% if @local_files.any? %> +
    +
    +

    + Browse files uploaded by administrators for local access. +

    +
    + + <% @folders.each do |folder_path, files| %> +
    + +
    + + + + <%= folder_path %> + (<%= files.count %> files) +
    + + +
      + <% files.each do |local_file| %> +
    • +
      +
      + <% if local_file.file.attached? %> + <% content_type = local_file.file.content_type %> + <% if content_type&.start_with?("image/") %> + + + + <% elsif content_type == "application/pdf" %> + + + + <% elsif content_type&.start_with?("audio/") %> + + + + <% else %> + + + + <% end %> + <% end %> + +
      +

      + <%= local_file.file.filename if local_file.file.attached? %> +

      +

      + <% if local_file.file.attached? %> + <%= number_to_human_size(local_file.file.byte_size) %> + <% end %> +

      +
      +
      + + <% if local_file.file.attached? %> + <%= link_to local_file_path(local_file), + class: "inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", + target: "_blank" do %> + + + + + View + <% end %> + <% end %> +
      +
    • + <% end %> +
    +
    + <% end %> +
    + <% else %> +
    + + + +

    No local files available

    +

    Administrators have not uploaded any files for local access yet.

    +
    + <% end %> +
    +
    diff --git a/app/views/pdf_viewer/show.html.erb b/app/views/pdf_viewer/show.html.erb new file mode 100644 index 0000000..a1c17d2 --- /dev/null +++ b/app/views/pdf_viewer/show.html.erb @@ -0,0 +1,117 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to topic".html_safe, topic_path(@topic), class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    + +
    +
    +
    +

    <%= @topic.title %>

    +

    <%= @topic_file.filename %>

    +
    +
    + +
    + <% if user_signed_in? %> +
    + <%= render "topics/favorite_button", topic: @topic, is_favorite: @is_favorite %> +
    + <% end %> + + <% unless mobile_device? %> + <% if @topic_file.file.attached? %> + <%= link_to rails_blob_path(@topic_file.file, disposition: "attachment"), + class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50", + download: @topic_file.filename do %> + + + + Download + <% end %> + <% end %> + <% end %> +
    +
    + + +
    +
    + + + + Page 1 of - + + + +
    + +
    + + + 100% + + +
    +
    + + +
    + +
    + + + + +
    + + + + + + +
    +
    +
    +
    diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 0000000..c8d9f04 --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,57 @@ +
    +
    +
    +

    + Create your account +

    +

    + Already have an account? + <%= link_to "Sign in", login_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %> +

    +
    + + <%= form_with model: @user, url: signup_path, method: :post, class: "mt-8 space-y-6" do |f| %> + <% if @user.errors.any? %> +
    +
      + <% @user.errors.full_messages.each do |message| %> +
    • <%= message %>
    • + <% end %> +
    +
    + <% end %> + +
    +
    + <%= f.label :first_name, class: "block text-sm font-medium text-gray-700" %> +
    + <%= f.text_field :first_name, + class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + placeholder: "John", + autofocus: true, + required: true %> +
    +
    + +
    + <%= f.label :last_name, class: "block text-sm font-medium text-gray-700" %> +
    + <%= f.text_field :last_name, + class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + placeholder: "Doe", + required: true %> +
    +
    +
    + +

    + Your login ID will be automatically generated from your name (e.g., john.doe) +

    + +
    + <%= f.submit "Create account", + class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer" %> +
    + <% end %> +
    +
    diff --git a/app/views/search/autocomplete.html.erb b/app/views/search/autocomplete.html.erb new file mode 100644 index 0000000..1aedac3 --- /dev/null +++ b/app/views/search/autocomplete.html.erb @@ -0,0 +1,4 @@ +
    +

    Search#autocomplete

    +

    Find me in app/views/search/autocomplete.html.erb

    +
    diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb new file mode 100644 index 0000000..c01cdf8 --- /dev/null +++ b/app/views/search/index.html.erb @@ -0,0 +1,76 @@ +
    + <%= render "shared/navigation" %> + +
    +

    Search

    + + +
    + <%= form_with url: search_path, method: :get, class: "relative" do |f| %> +
    +
    + + + +
    + <%= f.text_field :q, + value: @query, + class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-lg", + placeholder: "Search topics, tags, or authors...", + autocomplete: "off", + data: { + autocomplete_target: "input", + action: "input->autocomplete#search keydown->autocomplete#navigate" + } %> +
    + + + + <% end %> +
    + + + <% if @query.present? %> +
    +
    +

    + Search results for "<%= @query %>" +

    + + <%= @topics&.count || 0 %> results + +
    + + <% if @topics&.any? %> +
    + <% @topics.each do |topic| %> + <%= render "topics/topic_card", topic: topic, show_favorite: true %> + <% end %> +
    + <% else %> +
    + + + +

    No results found for "<%= @query %>"

    +

    Try different keywords or check your spelling.

    +
    + <% end %> +
    + <% else %> + +
    +

    Popular Tags

    +
    + <% Tag.joins(:topics).group("tags.id").order("COUNT(topics.id) DESC").limit(20).each do |tag| %> + <%= link_to tag.name, search_path(q: tag.name), + class: "px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-700 hover:text-indigo-700 rounded-full text-sm" %> + <% end %> +
    +
    + <% end %> +
    +
    diff --git a/app/views/search/results.html.erb b/app/views/search/results.html.erb new file mode 100644 index 0000000..06e9c01 --- /dev/null +++ b/app/views/search/results.html.erb @@ -0,0 +1,34 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to search".html_safe, search_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    +
    +

    + Search results for "<%= @query %>" +

    + + <%= @topics.count %> results + +
    + + <% if @topics.any? %> +
    + <% @topics.each do |topic| %> + <%= render "topics/topic_card", topic: topic, show_favorite: true %> + <% end %> +
    + <% else %> +
    + + + +

    No results found for "<%= @query %>"

    +

    Try different keywords or check your spelling.

    +
    + <% end %> +
    +
    +
    diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..c03e745 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,42 @@ +
    +
    +
    +

    + Sign in to your account +

    +

    + Or + <%= link_to "create a new account", signup_path, class: "font-medium text-indigo-600 hover:text-indigo-500" %> +

    +
    + + <%= form_with url: login_path, method: :post, class: "mt-8 space-y-6" do |f| %> + <% if flash[:alert] %> +
    +

    <%= flash[:alert] %>

    +
    + <% end %> + +
    + <%= f.label :login_id, "Login ID", class: "block text-sm font-medium text-gray-700" %> +
    + <%= f.text_field :login_id, + class: "appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + placeholder: "firstname.lastname", + autofocus: true, + required: true %> +
    +

    Enter your login ID (e.g., john.doe)

    +
    + +
    + <%= f.submit "Sign in", + class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 cursor-pointer" %> +
    + <% end %> + +
    + <%= link_to "Admin Login", admin_login_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
    +
    +
    diff --git a/app/views/shared/_navigation.html.erb b/app/views/shared/_navigation.html.erb new file mode 100644 index 0000000..50e6da2 --- /dev/null +++ b/app/views/shared/_navigation.html.erb @@ -0,0 +1,36 @@ + diff --git a/app/views/topics/_favorite_button.html.erb b/app/views/topics/_favorite_button.html.erb new file mode 100644 index 0000000..dd7fee3 --- /dev/null +++ b/app/views/topics/_favorite_button.html.erb @@ -0,0 +1,19 @@ +<% if is_favorite %> + <%= button_to toggle_favorite_topic_path(topic), + method: :post, + class: "p-2 rounded-full hover:bg-gray-100", + data: { turbo_stream: true } do %> + + + + <% end %> +<% else %> + <%= button_to toggle_favorite_topic_path(topic), + method: :post, + class: "p-2 rounded-full hover:bg-gray-100", + data: { turbo_stream: true } do %> + + + + <% end %> +<% end %> diff --git a/app/views/topics/_topic_card.html.erb b/app/views/topics/_topic_card.html.erb new file mode 100644 index 0000000..cfdd594 --- /dev/null +++ b/app/views/topics/_topic_card.html.erb @@ -0,0 +1,60 @@ +
    +
    +
    +
    + + <%= topic.content_provider.name %> + + | + <%= topic.year %> - <%= topic.month %> +
    + + <%= link_to topic_path(topic), class: "block group" do %> +

    + <%= topic.title %> +

    + <% end %> + + <% if topic.authors.any? %> +

    + <%= topic.authors.map(&:name).join(", ") %> +

    + <% end %> + +
    + <% if topic.topic_files.any? %> + + <% mp3_count = topic.topic_files.count { |f| f.file_type == "mp3" } %> + <% pdf_count = topic.topic_files.count { |f| f.file_type == "pdf" } %> + <% if mp3_count > 0 %> + + + + <%= mp3_count %> audio + <% end %> + <% if pdf_count > 0 %> + + + + <%= pdf_count %> PDF + <% end %> + + <% end %> + + + + + + + <%= topic.view_count %> + +
    +
    + + <% if defined?(show_favorite) && show_favorite && user_signed_in? %> +
    + <%= render "topics/favorite_button", topic: topic, is_favorite: current_user.favorites.exists?(topic: topic) %> +
    + <% end %> +
    +
    diff --git a/app/views/topics/by_year.html.erb b/app/views/topics/by_year.html.erb new file mode 100644 index 0000000..de5564f --- /dev/null +++ b/app/views/topics/by_year.html.erb @@ -0,0 +1,41 @@ +<%= turbo_frame_tag "topics_content" do %> +
    +
    +

    + <%= @year %> + <% if @month.present? %> + - <%= @month %> + <% end %> +

    + <%= @topics.count %> topics +
    + + <% if @months.any? && @month.blank? %> +
    + <% @months.each do |month| %> + <%= link_to month, + by_year_topics_path(year: @year, month: month), + class: "px-3 py-1 bg-gray-100 hover:bg-indigo-100 text-gray-700 hover:text-indigo-700 rounded-full text-sm", + data: { turbo_frame: "topics_content" } %> + <% end %> +
    + <% elsif @month.present? %> +
    + <%= link_to "← All months in #{@year}".html_safe, + by_year_topics_path(year: @year), + class: "text-indigo-600 hover:text-indigo-800 text-sm", + data: { turbo_frame: "topics_content" } %> +
    + <% end %> + +
    + <% @topics.each do |topic| %> + <%= render "topics/topic_card", topic: topic, show_favorite: true %> + <% end %> +
    + + <% if @topics.empty? %> +

    No topics found for this period.

    + <% end %> +
    +<% end %> diff --git a/app/views/topics/favorites.html.erb b/app/views/topics/favorites.html.erb new file mode 100644 index 0000000..0a4073d --- /dev/null +++ b/app/views/topics/favorites.html.erb @@ -0,0 +1,35 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to browsing".html_safe, root_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    +
    +
    + + + +

    My Favorites

    +
    + <%= @topics.count %> topics +
    + +
    + <% @topics.each do |topic| %> + <%= render "topics/topic_card", topic: topic, show_favorite: true %> + <% end %> +
    + + <% if @topics.empty? %> +
    + + + +

    You haven't added any favorites yet.

    +

    Click the heart icon on any topic to add it to your favorites.

    +
    + <% end %> +
    +
    +
    diff --git a/app/views/topics/index.html.erb b/app/views/topics/index.html.erb new file mode 100644 index 0000000..6b67dba --- /dev/null +++ b/app/views/topics/index.html.erb @@ -0,0 +1,93 @@ +
    + <%= render "shared/navigation" %> + +
    +

    Browse Content

    + +
    + +
    +
    +

    Browse By

    + + + +
    + +

    By Year

    +
    + <% @years.each do |year| %> +
    + <%= link_to by_year_topics_path(year: year), + class: "block px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100", + data: { turbo_frame: "topics_content" } do %> + + + + + <%= year %> + + <% end %> +
    + <% end %> +
    +
    +
    + + +
    + <%= turbo_frame_tag "topics_content" do %> +
    +

    Welcome to SkillRx Beacon

    +

    + Select a browse option from the sidebar to view content. +

    + +
    +
    +

    Content Providers

    +

    <%= @content_providers.count %>

    +
    +
    +

    Total Topics

    +

    <%= Topic.count %>

    +
    +
    +
    + <% end %> +
    +
    +
    +
    diff --git a/app/views/topics/new_uploads.html.erb b/app/views/topics/new_uploads.html.erb new file mode 100644 index 0000000..72b0482 --- /dev/null +++ b/app/views/topics/new_uploads.html.erb @@ -0,0 +1,29 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to browsing".html_safe, root_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    +
    +
    + + + +

    New Uploads

    +
    + Last 30 days | <%= @topics.count %> topics +
    + +
    + <% @topics.each do |topic| %> + <%= render "topics/topic_card", topic: topic, show_favorite: true %> + <% end %> +
    + + <% if @topics.empty? %> +

    No new uploads in the last 30 days.

    + <% end %> +
    +
    +
    diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb new file mode 100644 index 0000000..9b25da8 --- /dev/null +++ b/app/views/topics/show.html.erb @@ -0,0 +1,107 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to browsing".html_safe, :back, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    +
    +
    +
    +
    + + <%= @topic.content_provider.name %> + + | + <%= @topic.year %> - <%= @topic.month %> + <% if @topic.volume.present? %> + | + Vol. <%= @topic.volume %>, Issue <%= @topic.issue %> + <% end %> +
    + +

    <%= @topic.title %>

    + + <% if @topic.authors.any? %> +
    + + + + <%= @topic.authors.map(&:name).join(", ") %> +
    + <% end %> + + <% if @topic.tags.any? %> +
    + <% @topic.tags.each do |tag| %> + + <%= tag.name %> + + <% end %> +
    + <% end %> +
    + + <% if user_signed_in? %> +
    + <%= render "topics/favorite_button", topic: @topic, is_favorite: @is_favorite %> +
    + <% end %> +
    + +
    +

    Available Files

    + +
    + <% @topic.topic_files.each do |file| %> +
    +
    + <% if file.file_type == "mp3" %> + + + + <% else %> + + + + <% end %> + +
    +

    <%= file.filename %>

    +

    + <%= file.file_type.upcase %> | + <%= number_to_human_size(file.file_size) if file.file_size %> +

    +
    +
    + + <% if file.file_type == "mp3" %> + <%= link_to audio_topic_file_path(file), + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700" do %> + + + + Play Audio + <% end %> + <% else %> + <%= link_to pdf_topic_file_path(file), + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700" do %> + + + + + View PDF + <% end %> + <% end %> +
    + <% end %> +
    +
    + +
    + Views: <%= @topic.view_count %> +
    +
    +
    +
    +
    diff --git a/app/views/topics/toggle_favorite.turbo_stream.erb b/app/views/topics/toggle_favorite.turbo_stream.erb new file mode 100644 index 0000000..17adb41 --- /dev/null +++ b/app/views/topics/toggle_favorite.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace "favorite_button_#{@topic.id}" do %> + <%= render "topics/favorite_button", topic: @topic, is_favorite: @is_favorite %> +<% end %> diff --git a/app/views/topics/top_topics.html.erb b/app/views/topics/top_topics.html.erb new file mode 100644 index 0000000..af5451a --- /dev/null +++ b/app/views/topics/top_topics.html.erb @@ -0,0 +1,36 @@ +
    + <%= render "shared/navigation" %> + +
    + <%= link_to "← Back to browsing".html_safe, root_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> + +
    +
    +
    + + + +

    Top Topics

    +
    + Top 50 most viewed +
    + +
    + <% @topics.each_with_index do |topic, index| %> +
    + + <%= index + 1 %> + +
    + <%= render "topics/topic_card", topic: topic, show_favorite: true %> +
    +
    + <% end %> +
    + + <% if @topics.empty? %> +

    No topics found.

    + <% end %> +
    +
    +
    diff --git a/bin/dev b/bin/dev deleted file mode 100755 index ad72c7d..0000000 --- a/bin/dev +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env sh - -if ! gem list foreman -i --silent; then - echo "Installing foreman..." - gem install foreman -fi - -# Default to port 3000 if not specified -export PORT="${PORT:-3000}" - -# Let the debug gem allow remote connections, -# but avoid loading until `debugger` is called -export RUBY_DEBUG_OPEN="true" -export RUBY_DEBUG_LAZY="true" - -exec foreman start -f Procfile.dev "$@" diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..93e191c --- /dev/null +++ b/bin/rspec @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..eb7b83c --- /dev/null +++ b/bin/server @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +overmind start -r all -f Procfile.dev diff --git a/bin/setup b/bin/setup index 81be011..10a1ade 100755 --- a/bin/setup +++ b/bin/setup @@ -30,6 +30,6 @@ FileUtils.chdir APP_ROOT do unless ARGV.include?("--skip-server") puts "\n== Starting development server ==" STDOUT.flush # flush the output before exec(2) so that it displays - exec "bin/dev" + exec "bin/server" end end diff --git a/bin/setup-production b/bin/setup-production new file mode 100755 index 0000000..c468a4e --- /dev/null +++ b/bin/setup-production @@ -0,0 +1,106 @@ +#!/bin/bash +# +# Production Setup Script for SkillRx Beacon +# +# This script sets up the application for production deployment +# on a Raspberry Pi or similar mini computer. +# +# Usage: +# ./bin/setup-production +# +# Prerequisites: +# - Ruby 4.0+ installed (via rbenv, asdf, or system) +# - SQLite3 installed +# - Node.js installed (for asset compilation) + +set -e + +APP_DIR="/opt/skillrx" +CURRENT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +echo "=== SkillRx Beacon Production Setup ===" +echo "" + +# Check if running as root for system-level setup +if [ "$EUID" -eq 0 ]; then + echo "Creating skillrx user..." + if ! id -u skillrx >/dev/null 2>&1; then + useradd --system --home-dir "$APP_DIR" --shell /bin/bash skillrx + fi + + echo "Creating application directory..." + mkdir -p "$APP_DIR" + mkdir -p "$APP_DIR/storage" + mkdir -p "$APP_DIR/log" + mkdir -p "$APP_DIR/tmp/pids" + mkdir -p "$APP_DIR/content" + + echo "Copying application files..." + rsync -av --exclude='.git' --exclude='storage/*.sqlite3' --exclude='log/*' \ + --exclude='tmp/*' --exclude='node_modules' \ + "$CURRENT_DIR/" "$APP_DIR/" + + echo "Setting permissions..." + chown -R skillrx:skillrx "$APP_DIR" + chmod 750 "$APP_DIR" + chmod -R 755 "$APP_DIR/bin" + + echo "Installing systemd service..." + cp "$APP_DIR/config/skillrx.service" /etc/systemd/system/ + systemctl daemon-reload + systemctl enable skillrx + + echo "" + echo "System setup complete!" + echo "" + echo "Next steps (run as skillrx user):" + echo " 1. cd $APP_DIR" + echo " 2. Create .env file with RAILS_MASTER_KEY" + echo " 3. Run: bundle install --deployment --without development test" + echo " 4. Run: bin/rails db:prepare RAILS_ENV=production" + echo " 5. Run: bin/rails assets:precompile RAILS_ENV=production" + echo " 6. Run: bin/rails data:import_content RAILS_ENV=production" + echo " 7. Start service: sudo systemctl start skillrx" + echo "" + exit 0 +fi + +# Non-root setup (application-level) +echo "Running application setup..." + +# Install dependencies +echo "Installing gems..." +bundle config set --local deployment true +bundle config set --local without 'development test' +bundle install + +# Generate credentials if not present +if [ ! -f "config/master.key" ]; then + echo "Generating new credentials..." + EDITOR="cat" bin/rails credentials:edit + echo "" + echo "IMPORTANT: Save the master.key file securely!" + echo " Location: config/master.key" + echo "" +fi + +# Prepare database +echo "Preparing database..." +bin/rails db:prepare RAILS_ENV=production + +# Precompile assets +echo "Precompiling assets..." +bin/rails assets:precompile RAILS_ENV=production + +# Create necessary directories +mkdir -p storage log tmp/pids + +echo "" +echo "Application setup complete!" +echo "" +echo "To start the server:" +echo " bin/rails server -e production -b 0.0.0.0 -p 3000" +echo "" +echo "Or with systemd (as root):" +echo " sudo systemctl start skillrx" +echo "" diff --git a/config/application.rb b/config/application.rb index 5aae155..254b79a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,18 +1,6 @@ require_relative "boot" -require "rails" -# Pick the frameworks you want: -require "active_model/railtie" -require "active_job/railtie" -require "active_record/railtie" -require "active_storage/engine" -require "action_controller/railtie" -require "action_mailer/railtie" -require "action_mailbox/engine" -require "action_text/engine" -require "action_view/railtie" -require "action_cable/engine" -# require "rails/test_unit/railtie" +require "rails/all" # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. @@ -35,8 +23,5 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") - - # Don't generate system test files. - config.generators.system_tests = nil end end diff --git a/config/ci.rb b/config/ci.rb index 239b343..1712cc1 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -8,7 +8,11 @@ step "Security: Gem audit", "bin/bundler-audit" step "Security: Importmap vulnerability audit", "bin/importmap audit" step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + # Optional: Run system tests + # step "Tests: System", "bin/rails test:system" # Optional: set a green GitHub commit status to unblock PR merge. # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 291627d..687bca1 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -ACv6t43/kA5bwwSSaecqpCIqJW5fw9/l+Pr/VLZuIF4Y759NsBQ2v96tyspxIqqOVcv2VF6ySuy7KWHv0odS6M11gpmHEdLV4W0t8xifuehoZ4WWZvYFfg17Ghx85ApJCroQ5afaAUdNmeaIXZtEHFq8lziydGyUxKeww6ef7iUBdmdUozBot5GH3qkKAYEMqqDYgipxUEo1+dlyJEL82mG5b9FPnmpq3+C6guuE3W8qr+jHNDJD+fOOvLlppCdNskzQ8d276+Lpzg99zniqXIBIi6menOrFIv071uXSvzfzIJ4Tti/EnZwhLnk8vuiw9cZ7zdWTaVDDMyZKBhdypqO2v5oBJkPJ+ehEG9Ua5vztbY7jvR/CA/POqnv60OXLgpQhAi/bwXVsCOApsWPKs/On1bSO3snK+vKxLIHZQ/4tcZid9tL9dSnAlJsBytOmt9X0NRHyrhQZG6/p3qFGyuNugtIRxEUT6/dt6c3o0h0mGPQ2JkVC82Qr--7HWk+fqFYR4MuWUw--FCCra/Mh/IWpefaE4bouUg== \ No newline at end of file +lAvEZTzAz6MqaMzPAuEIHGkw3rpi9/68/animW98hdxRPdXuCs1KfQQ4UgaPnZUdIYoM1GVV1T91/XuQI3FUQF1Up17loHhc1H2zKZpVPl2F3c1cLln45Ns/NlwMUgLCo1zNW2caAPzPWoEyQA3ng54XLQ6ubjSl+aFQ97OpxkELtppsLCFTZb1QN6/4fxNKqZcH89kPp3iW4zITZBqIOHDo5bBrRxl2fAkDwFrofpKu6aKg64rmqXZE/0e9gK42GYULnj+R4S4Xfs1BFyEETX8akSbOl9yQ8U+yFhPUhuWyf1m3WEQgPxxj/9Fxq2NwPzv9plsmVEqOuizZlsVnFUam6Rm/qxqDdUDBvaYDmXDd+d/Os3wonRWK/NTDnjJq8UBVRLycxPrvl7h2j0058DO84SpWFOURWGB1xvaRTBEQqSKqAIDUv6qYXsbX7Ls26gzj7IF/dnO6OIrjClFQdX8Q/ghRtCzjQBzycgRZr2GK1jvoE9xTv2bk--2kmaE1uzO6P42jXO--DiR7OzE3CXsfDoxeR6Y3OQ== diff --git a/config/database.yml b/config/database.yml index 3086ef1..302d638 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,104 +1,40 @@ -# PostgreSQL. Versions 9.3 and up are supported. +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 # -# Install the pg driver: -# gem install pg -# On macOS with Homebrew: -# gem install pg -- --with-pg-config=/opt/homebrew/bin/pg_config -# On Windows: -# gem install pg -# Choose the win32 build. -# Install PostgreSQL and put its /bin directory on your path. -# -# Configure Using Gemfile -# gem "pg" +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" # default: &default - adapter: postgresql - encoding: unicode - # For details on connection pooling, see Rails configuration guide - # https://guides.rubyonrails.org/configuring.html#database-pooling + adapter: sqlite3 max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - + timeout: 5000 development: <<: *default - database: skillrx_beacon_development - - # The specified database role being used to connect to PostgreSQL. - # To create additional roles in PostgreSQL see `$ createuser --help`. - # When left blank, PostgreSQL will use the default role. This is - # the same name as the operating system user running Rails. - #username: skillrx_beacon - - # The password associated with the PostgreSQL role (username). - #password: - - # Connect on a TCP socket. Omitted by default since the client uses a - # domain socket that doesn't need configuration. Windows does not have - # domain sockets, so uncomment these lines. - #host: localhost - - # The TCP port the server listens on. Defaults to 5432. - # If your server runs on a different port number, change accordingly. - #port: 5432 - - # Schema search path. The server defaults to $user,public - #schema_search_path: myapp,sharedapp,public - - # Minimum log levels, in increasing order: - # debug5, debug4, debug3, debug2, debug1, - # log, notice, warning, error, fatal, and panic - # Defaults to warning. - #min_messages: notice + database: storage/development.sqlite3 # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: <<: *default - database: skillrx_beacon_test + database: storage/test.sqlite3 -# As with config/credentials.yml, you never want to store sensitive information, -# like your database password, in your source code. If your source code is -# ever seen by anyone, they now have access to your database. -# -# Instead, provide the password or a full connection URL as an environment -# variable when you boot the app. For example: -# -# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" -# -# If the connection URL is provided in the special DATABASE_URL environment -# variable, Rails will automatically merge its configuration values on top of -# the values provided in this file. Alternatively, you can specify a connection -# URL environment variable explicitly: -# -# production: -# url: <%= ENV["MY_APP_DATABASE_URL"] %> -# -# Connection URLs for non-primary databases can also be configured using -# environment variables. The variable name is formed by concatenating the -# connection name with `_DATABASE_URL`. For example: -# -# CACHE_DATABASE_URL="postgres://cacheuser:cachepass@localhost/cachedatabase" -# -# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database -# for a full overview on how database connection configuration can be specified. -# +# Store production database in the storage/ directory, which by default +# is mounted as a persistent Docker volume in config/deploy.yml. production: - primary: &primary_production + primary: <<: *default - database: skillrx_beacon_production - username: skillrx_beacon - password: <%= ENV["SKILLRX_BEACON_DATABASE_PASSWORD"] %> + database: storage/production.sqlite3 cache: - <<: *primary_production - database: skillrx_beacon_production_cache + <<: *default + database: storage/production_cache.sqlite3 migrations_paths: db/cache_migrate queue: - <<: *primary_production - database: skillrx_beacon_production_queue + <<: *default + database: storage/production_queue.sqlite3 migrations_paths: db/queue_migrate cable: - <<: *primary_production - database: skillrx_beacon_production_cable + <<: *default + database: storage/production_cable.sqlite3 migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml index cb038af..9d6a811 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -85,7 +85,7 @@ builder: # # # Pass arguments and secrets to the Docker build process # args: - # RUBY_VERSION: 4.0.0 + # RUBY_VERSION: ruby-4.0.1 # secrets: # - GITHUB_TOKEN # - RAILS_MASTER_KEY diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..8a0e4ab --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,3 @@ +Rails.application.config.session_store :cookie_store, + key: "_skillrx_beacon_session", + expire_after: 1.year diff --git a/config/routes.rb b/config/routes.rb index 48254e8..bc7ce34 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,14 +1,69 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + # User authentication (passwordless) + get "login", to: "sessions#new", as: :login + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy", as: :logout - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check + get "signup", to: "registrations#new", as: :signup + post "signup", to: "registrations#create" + + # Content browsing + resources :topics, only: [ :index, :show ] do + collection do + get :by_year + get :new_uploads + get :top_topics + get :favorites + end + member do + post :toggle_favorite + end + end + + # Local files (user-facing, read-only) + resources :local_files, only: [ :index, :show ] + + # Search + get "search", to: "search#index", as: :search + get "search/autocomplete", to: "search#autocomplete", as: :search_autocomplete + get "search/results", to: "search#results", as: :search_results + + # Media players + resources :topic_files, only: [] do + member do + get :audio, to: "audio_player#show" + get :pdf, to: "pdf_viewer#show" + end + end + + # Error pages + get "errors/not_found", to: "errors#not_found", as: :not_found + get "errors/audio_not_found", to: "errors#audio_not_found", as: :audio_not_found + get "errors/pdf_not_found", to: "errors#pdf_not_found", as: :pdf_not_found + get "errors/unsupported_browser", to: "errors#unsupported_browser", as: :unsupported_browser - # 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 + # Admin authentication and management + namespace :admin do + get "login", to: "sessions#new", as: :login + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy", as: :logout + + resources :local_files, only: [ :index, :new, :create, :show, :destroy ] do + collection do + delete :destroy_folder + end + end + + # Activity logs + get "activity_log", to: "dashboard#activity_log", as: :activity_log + get "admin_log", to: "dashboard#admin_log", as: :admin_log + + root to: "dashboard#index" + end + + # Health check + get "up" => "rails/health#show", as: :rails_health_check - # Defines the root path route ("/") - # root "posts#index" + # Root path + root "topics#index" end diff --git a/config/skillrx.service b/config/skillrx.service new file mode 100644 index 0000000..24d4791 --- /dev/null +++ b/config/skillrx.service @@ -0,0 +1,61 @@ +# systemd service file for SkillRx Beacon +# +# Installation: +# sudo cp config/skillrx.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable skillrx +# sudo systemctl start skillrx +# +# Commands: +# sudo systemctl status skillrx # Check status +# sudo systemctl restart skillrx # Restart service +# sudo journalctl -u skillrx -f # View logs + +[Unit] +Description=SkillRx Beacon - Medical Content Application +Documentation=https://github.com/RubyForGood/skillrx-beacon +After=network.target + +[Service] +Type=simple +User=skillrx +Group=skillrx + +# Application directory +WorkingDirectory=/opt/skillrx + +# Environment variables +Environment=RAILS_ENV=production +Environment=RAILS_LOG_TO_STDOUT=1 +Environment=RAILS_SERVE_STATIC_FILES=1 +Environment=SOLID_QUEUE_IN_PUMA=1 +Environment=PORT=3000 +Environment=PIDFILE=/opt/skillrx/tmp/pids/server.pid + +# Read secret key from environment file +EnvironmentFile=-/opt/skillrx/.env + +# Start command +ExecStart=/opt/skillrx/bin/rails server -b 0.0.0.0 -p 3000 + +# Graceful shutdown +ExecStop=/bin/kill -SIGTERM $MAINPID +TimeoutStopSec=30 + +# Restart policy +Restart=always +RestartSec=5 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/opt/skillrx/storage /opt/skillrx/log /opt/skillrx/tmp + +# Resource limits (suitable for Raspberry Pi) +MemoryMax=512M +TasksMax=50 + +[Install] +WantedBy=multi-user.target diff --git a/db/migrate/20260130220155_create_active_storage_tables.active_storage.rb b/db/migrate/20260130220155_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..6bd8bd0 --- /dev/null +++ b/db/migrate/20260130220155_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260130220323_create_users.rb b/db/migrate/20260130220323_create_users.rb new file mode 100644 index 0000000..660fa47 --- /dev/null +++ b/db/migrate/20260130220323_create_users.rb @@ -0,0 +1,13 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :first_name + t.string :last_name + t.string :login_id + t.integer :login_count, default: 0 + + t.timestamps + end + add_index :users, :login_id, unique: true + end +end diff --git a/db/migrate/20260130220427_create_admins.rb b/db/migrate/20260130220427_create_admins.rb new file mode 100644 index 0000000..fe44133 --- /dev/null +++ b/db/migrate/20260130220427_create_admins.rb @@ -0,0 +1,13 @@ +class CreateAdmins < ActiveRecord::Migration[8.1] + def change + create_table :admins do |t| + t.string :first_name + t.string :last_name + t.string :login_id + t.string :password_digest + + t.timestamps + end + add_index :admins, :login_id, unique: true + end +end diff --git a/db/migrate/20260130220506_create_content_providers.rb b/db/migrate/20260130220506_create_content_providers.rb new file mode 100644 index 0000000..78a9090 --- /dev/null +++ b/db/migrate/20260130220506_create_content_providers.rb @@ -0,0 +1,9 @@ +class CreateContentProviders < ActiveRecord::Migration[8.1] + def change + create_table :content_providers do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20260130220535_create_topics.rb b/db/migrate/20260130220535_create_topics.rb new file mode 100644 index 0000000..9e8c30e --- /dev/null +++ b/db/migrate/20260130220535_create_topics.rb @@ -0,0 +1,16 @@ +class CreateTopics < ActiveRecord::Migration[8.1] + def change + create_table :topics do |t| + t.references :content_provider, null: false, foreign_key: true + t.integer :year + t.string :month + t.string :title + t.string :volume + t.string :issue + t.integer :view_count, default: 0 + t.string :topic_external_id + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221012_create_topic_files.rb b/db/migrate/20260130221012_create_topic_files.rb new file mode 100644 index 0000000..40d2ad7 --- /dev/null +++ b/db/migrate/20260130221012_create_topic_files.rb @@ -0,0 +1,12 @@ +class CreateTopicFiles < ActiveRecord::Migration[8.1] + def change + create_table :topic_files do |t| + t.references :topic, null: false, foreign_key: true + t.string :filename + t.integer :file_size + t.string :file_type + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221035_create_authors.rb b/db/migrate/20260130221035_create_authors.rb new file mode 100644 index 0000000..1b0e055 --- /dev/null +++ b/db/migrate/20260130221035_create_authors.rb @@ -0,0 +1,9 @@ +class CreateAuthors < ActiveRecord::Migration[8.1] + def change + create_table :authors do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221047_create_topic_authors.rb b/db/migrate/20260130221047_create_topic_authors.rb new file mode 100644 index 0000000..846bbb8 --- /dev/null +++ b/db/migrate/20260130221047_create_topic_authors.rb @@ -0,0 +1,10 @@ +class CreateTopicAuthors < ActiveRecord::Migration[8.1] + def change + create_table :topic_authors do |t| + t.references :topic, null: false, foreign_key: true + t.references :author, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221231_create_tags.rb b/db/migrate/20260130221231_create_tags.rb new file mode 100644 index 0000000..14ec1b4 --- /dev/null +++ b/db/migrate/20260130221231_create_tags.rb @@ -0,0 +1,10 @@ +class CreateTags < ActiveRecord::Migration[8.1] + def change + create_table :tags do |t| + t.string :name + + t.timestamps + end + add_index :tags, :name, unique: true + end +end diff --git a/db/migrate/20260130221235_create_topic_tags.rb b/db/migrate/20260130221235_create_topic_tags.rb new file mode 100644 index 0000000..6fee747 --- /dev/null +++ b/db/migrate/20260130221235_create_topic_tags.rb @@ -0,0 +1,10 @@ +class CreateTopicTags < ActiveRecord::Migration[8.1] + def change + create_table :topic_tags do |t| + t.references :topic, null: false, foreign_key: true + t.references :tag, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221528_create_favorites.rb b/db/migrate/20260130221528_create_favorites.rb new file mode 100644 index 0000000..1d4e9f2 --- /dev/null +++ b/db/migrate/20260130221528_create_favorites.rb @@ -0,0 +1,10 @@ +class CreateFavorites < ActiveRecord::Migration[8.1] + def change + create_table :favorites do |t| + t.references :user, null: false, foreign_key: true + t.references :topic, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221704_create_local_files.rb b/db/migrate/20260130221704_create_local_files.rb new file mode 100644 index 0000000..0fd771a --- /dev/null +++ b/db/migrate/20260130221704_create_local_files.rb @@ -0,0 +1,10 @@ +class CreateLocalFiles < ActiveRecord::Migration[8.1] + def change + create_table :local_files do |t| + t.references :admin, null: false, foreign_key: true + t.string :folder_path + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221833_create_user_activity_logs.rb b/db/migrate/20260130221833_create_user_activity_logs.rb new file mode 100644 index 0000000..9e024db --- /dev/null +++ b/db/migrate/20260130221833_create_user_activity_logs.rb @@ -0,0 +1,17 @@ +class CreateUserActivityLogs < ActiveRecord::Migration[8.1] + def change + create_table :user_activity_logs do |t| + t.references :user, null: false, foreign_key: true + t.string :action_type + t.references :topic, null: true, foreign_key: true + t.string :file_type + t.string :search_term + t.boolean :search_found + t.string :os + t.string :browser + t.string :ip_address + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221920_create_admin_activity_logs.rb b/db/migrate/20260130221920_create_admin_activity_logs.rb new file mode 100644 index 0000000..667231c --- /dev/null +++ b/db/migrate/20260130221920_create_admin_activity_logs.rb @@ -0,0 +1,14 @@ +class CreateAdminActivityLogs < ActiveRecord::Migration[8.1] + def change + create_table :admin_activity_logs do |t| + t.references :admin, null: false, foreign_key: true + t.string :action_type + t.text :details + t.string :os + t.string :browser + t.string :ip_address + + t.timestamps + end + end +end diff --git a/db/migrate/20260130221956_add_indexes_to_tables.rb b/db/migrate/20260130221956_add_indexes_to_tables.rb new file mode 100644 index 0000000..38c7bca --- /dev/null +++ b/db/migrate/20260130221956_add_indexes_to_tables.rb @@ -0,0 +1,9 @@ +class AddIndexesToTables < ActiveRecord::Migration[8.1] + def change + add_index :topics, [ :year, :month ] + add_index :topics, :view_count + add_index :topics, :topic_external_id + add_index :user_activity_logs, :action_type + add_index :admin_activity_logs, :action_type + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..c9d85b6 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,187 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_01_30_221956) do + create_table "active_storage_attachments", force: :cascade do |t| + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "admin_activity_logs", force: :cascade do |t| + t.string "action_type" + t.integer "admin_id", null: false + t.string "browser" + t.datetime "created_at", null: false + t.text "details" + t.string "ip_address" + t.string "os" + t.datetime "updated_at", null: false + t.index ["action_type"], name: "index_admin_activity_logs_on_action_type" + t.index ["admin_id"], name: "index_admin_activity_logs_on_admin_id" + end + + create_table "admins", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "first_name" + t.string "last_name" + t.string "login_id" + t.string "password_digest" + t.datetime "updated_at", null: false + t.index ["login_id"], name: "index_admins_on_login_id", unique: true + end + + create_table "authors", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + + create_table "content_providers", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + end + + create_table "favorites", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "topic_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["topic_id"], name: "index_favorites_on_topic_id" + t.index ["user_id"], name: "index_favorites_on_user_id" + end + + create_table "local_files", force: :cascade do |t| + t.integer "admin_id", null: false + t.datetime "created_at", null: false + t.string "folder_path" + t.datetime "updated_at", null: false + t.index ["admin_id"], name: "index_local_files_on_admin_id" + end + + create_table "tags", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.datetime "updated_at", null: false + t.index ["name"], name: "index_tags_on_name", unique: true + end + + create_table "topic_authors", force: :cascade do |t| + t.integer "author_id", null: false + t.datetime "created_at", null: false + t.integer "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["author_id"], name: "index_topic_authors_on_author_id" + t.index ["topic_id"], name: "index_topic_authors_on_topic_id" + end + + create_table "topic_files", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "file_size" + t.string "file_type" + t.string "filename" + t.integer "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_topic_files_on_topic_id" + end + + create_table "topic_tags", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "tag_id", null: false + t.integer "topic_id", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_topic_tags_on_tag_id" + t.index ["topic_id"], name: "index_topic_tags_on_topic_id" + end + + create_table "topics", force: :cascade do |t| + t.integer "content_provider_id", null: false + t.datetime "created_at", null: false + t.string "issue" + t.string "month" + t.string "title" + t.string "topic_external_id" + t.datetime "updated_at", null: false + t.integer "view_count", default: 0 + t.string "volume" + t.integer "year" + t.index ["content_provider_id"], name: "index_topics_on_content_provider_id" + t.index ["topic_external_id"], name: "index_topics_on_topic_external_id" + t.index ["view_count"], name: "index_topics_on_view_count" + t.index ["year", "month"], name: "index_topics_on_year_and_month" + end + + create_table "user_activity_logs", force: :cascade do |t| + t.string "action_type" + t.string "browser" + t.datetime "created_at", null: false + t.string "file_type" + t.string "ip_address" + t.string "os" + t.boolean "search_found" + t.string "search_term" + t.integer "topic_id" + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["action_type"], name: "index_user_activity_logs_on_action_type" + t.index ["topic_id"], name: "index_user_activity_logs_on_topic_id" + t.index ["user_id"], name: "index_user_activity_logs_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "first_name" + t.string "last_name" + t.integer "login_count", default: 0 + t.string "login_id" + t.datetime "updated_at", null: false + t.index ["login_id"], name: "index_users_on_login_id", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "admin_activity_logs", "admins" + add_foreign_key "favorites", "topics" + add_foreign_key "favorites", "users" + add_foreign_key "local_files", "admins" + add_foreign_key "topic_authors", "authors" + add_foreign_key "topic_authors", "topics" + add_foreign_key "topic_files", "topics" + add_foreign_key "topic_tags", "tags" + add_foreign_key "topic_tags", "topics" + add_foreign_key "topics", "content_providers" + add_foreign_key "user_activity_logs", "topics" + add_foreign_key "user_activity_logs", "users" +end diff --git a/docker-compose.development.yml b/docker-compose.development.yml new file mode 100644 index 0000000..0e0416c --- /dev/null +++ b/docker-compose.development.yml @@ -0,0 +1,24 @@ +# Development overrides for Docker Compose +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.development.yml up + +services: + web: + build: + context: . + args: + - RUBY_VERSION=4.0.1 + ports: + - "3000:3000" + environment: + - RAILS_ENV=development + volumes: + # Mount source code for live reloading + - .:/rails + - bundle:/usr/local/bundle + command: ["./bin/rails", "server", "-b", "0.0.0.0"] + +volumes: + bundle: + driver: local diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..35155c0 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,23 @@ +# Production overrides for Docker Compose +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.production.yml up -d + +services: + web: + environment: + - RAILS_ENV=production + - RAILS_LOG_TO_STDOUT=1 + - RAILS_SERVE_STATIC_FILES=1 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + # Suitable for Raspberry Pi or mini computers + memory: 512M + reservations: + memory: 256M diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b244d4e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# Docker Compose for SkillRx Beacon +# +# Usage: +# Development: docker compose up +# Production: docker compose -f docker-compose.yml -f docker-compose.production.yml up -d +# +# Build: +# docker compose build +# +# First-time setup: +# docker compose run --rm web bin/rails db:prepare +# docker compose run --rm web bin/rails data:import_content + +services: + web: + build: . + ports: + - "${PORT:-3000}:80" + environment: + - RAILS_ENV=${RAILS_ENV:-production} + - RAILS_MASTER_KEY=${RAILS_MASTER_KEY} + - SOLID_QUEUE_IN_PUMA=1 + volumes: + # Persist SQLite databases + - storage:/rails/storage + # Persist ActiveStorage uploads + - uploads:/rails/storage/uploads + # Mount content directory (medical content files) + - ${CONTENT_PATH:-./content}:/rails/content:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/up"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + storage: + driver: local + uploads: + driver: local diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake new file mode 100644 index 0000000..69b7cff --- /dev/null +++ b/lib/tasks/data.rake @@ -0,0 +1,71 @@ +namespace :data do + desc "Import users from legacy XML file" + task import_users: :environment do |_task, args| + file_path = args.extras.first || default_xml_path("users.xml") + + puts "Importing users from #{file_path}..." + + topic_id_map = Topic.pluck(:topic_external_id, :id).to_h + results = UsersImporter.new(file_path, topic_id_map: topic_id_map).import + + puts "Users import complete:" + puts " Created: #{results[:created]}" + puts " Updated: #{results[:updated]}" + puts " Errors: #{results[:errors].count}" + results[:errors].each { |error| puts " - #{error}" } + end + + desc "Import admins from legacy XML file" + task import_admins: :environment do |_task, args| + file_path = args.extras.first || default_xml_path("admin.xml") + + puts "Importing admins from #{file_path}..." + + results = AdminsImporter.new(file_path).import + + puts "Admins import complete:" + puts " Created: #{results[:created]}" + puts " Updated: #{results[:updated]}" + puts " Errors: #{results[:errors].count}" + results[:errors].each { |error| puts " - #{error}" } + + if results[:password_reset_needed].any? + puts "\nPassword reset needed for:" + results[:password_reset_needed].each { |login| puts " - #{login}" } + puts "\nDefault password: 'changeme123'" + end + end + + desc "Import content (topics, files, authors, tags) from legacy XML file" + task import_content: :environment do |_task, args| + file_path = args.extras.first || default_xml_path("Server_XML.xml") + + puts "Importing content from #{file_path}..." + + results = ContentImporter.new(file_path).import + + puts "Content import complete:" + puts " Providers created: #{results[:providers][:created]}" + puts " Topics created: #{results[:topics][:created]}" + puts " Topics updated: #{results[:topics][:updated]}" + puts " Files created: #{results[:files][:created]}" + puts " Authors created: #{results[:authors][:created]}" + puts " Tags created: #{results[:tags][:created]}" + puts " Errors: #{results[:errors].count}" + results[:errors].each { |error| puts " - #{error}" } + end + + desc "Import all data from legacy XML files" + task import_all: :environment do + Rake::Task["data:import_content"].invoke + Rake::Task["data:import_users"].invoke + Rake::Task["data:import_admins"].invoke + end + + def default_xml_path(filename) + legacy_path = Rails.root.join("..", "CMES-Pi-BOOM_English_Updated", "assets", "XML", filename) + return legacy_path.to_s if File.exist?(legacy_path) + + Rails.root.join("assets", "XML", filename).to_s + end +end diff --git a/spec/factories/admin_activity_logs.rb b/spec/factories/admin_activity_logs.rb new file mode 100644 index 0000000..908cb91 --- /dev/null +++ b/spec/factories/admin_activity_logs.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :admin_activity_log do + admin { nil } + action_type { "MyString" } + details { "MyText" } + os { "MyString" } + browser { "MyString" } + ip_address { "MyString" } + end +end diff --git a/spec/factories/admins.rb b/spec/factories/admins.rb new file mode 100644 index 0000000..a8240c0 --- /dev/null +++ b/spec/factories/admins.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :admin do + sequence(:first_name) { |n| "Admin#{n}" } + sequence(:last_name) { |n| "User#{n}" } + sequence(:login_id) { |n| "admin#{n}" } + password { "password123" } + password_confirmation { "password123" } + end +end diff --git a/spec/factories/authors.rb b/spec/factories/authors.rb new file mode 100644 index 0000000..e13c18f --- /dev/null +++ b/spec/factories/authors.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :author do + name { "MyString" } + end +end diff --git a/spec/factories/content_providers.rb b/spec/factories/content_providers.rb new file mode 100644 index 0000000..6bc223f --- /dev/null +++ b/spec/factories/content_providers.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :content_provider do + sequence(:name) { |n| "Provider #{n}" } + end +end diff --git a/spec/factories/favorites.rb b/spec/factories/favorites.rb new file mode 100644 index 0000000..10aab64 --- /dev/null +++ b/spec/factories/favorites.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :favorite do + user { nil } + topic { nil } + end +end diff --git a/spec/factories/local_files.rb b/spec/factories/local_files.rb new file mode 100644 index 0000000..560f37a --- /dev/null +++ b/spec/factories/local_files.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :local_file do + admin + folder_path { "/" } + end +end diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb new file mode 100644 index 0000000..4228256 --- /dev/null +++ b/spec/factories/tags.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :tag do + name { "MyString" } + end +end diff --git a/spec/factories/topic_authors.rb b/spec/factories/topic_authors.rb new file mode 100644 index 0000000..0f75ad7 --- /dev/null +++ b/spec/factories/topic_authors.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :topic_author do + topic { nil } + author { nil } + end +end diff --git a/spec/factories/topic_files.rb b/spec/factories/topic_files.rb new file mode 100644 index 0000000..e6ef9f1 --- /dev/null +++ b/spec/factories/topic_files.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :topic_file do + association :topic + sequence(:filename) { |n| "file_#{n}.mp3" } + file_size { 1024000 } + file_type { "mp3" } + end +end diff --git a/spec/factories/topic_tags.rb b/spec/factories/topic_tags.rb new file mode 100644 index 0000000..af10b33 --- /dev/null +++ b/spec/factories/topic_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :topic_tag do + topic { nil } + tag { nil } + end +end diff --git a/spec/factories/topics.rb b/spec/factories/topics.rb new file mode 100644 index 0000000..33a5eaf --- /dev/null +++ b/spec/factories/topics.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :topic do + association :content_provider + year { 2024 } + month { "January" } + sequence(:title) { |n| "Topic #{n}" } + volume { "1" } + issue { "1" } + view_count { 0 } + sequence(:topic_external_id) { |n| "ext_#{n}" } + end +end diff --git a/spec/factories/user_activity_logs.rb b/spec/factories/user_activity_logs.rb new file mode 100644 index 0000000..22f762f --- /dev/null +++ b/spec/factories/user_activity_logs.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :user_activity_log do + user { nil } + action_type { "MyString" } + topic { nil } + file_type { "MyString" } + search_term { "MyString" } + search_found { false } + os { "MyString" } + browser { "MyString" } + ip_address { "MyString" } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..32b0c34 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :user do + sequence(:first_name) { |n| "User#{n}" } + sequence(:last_name) { |n| "Test#{n}" } + login_id { nil } + login_count { 0 } + end +end diff --git a/spec/helpers/admin/base_helper_spec.rb b/spec/helpers/admin/base_helper_spec.rb new file mode 100644 index 0000000..1ae9441 --- /dev/null +++ b/spec/helpers/admin/base_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Admin::BaseHelper. For example: +# +# describe Admin::BaseHelper 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 Admin::BaseHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/admin/dashboard_helper_spec.rb b/spec/helpers/admin/dashboard_helper_spec.rb new file mode 100644 index 0000000..628ccf8 --- /dev/null +++ b/spec/helpers/admin/dashboard_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Admin::DashboardHelper. For example: +# +# describe Admin::DashboardHelper 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 Admin::DashboardHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/admin/sessions_helper_spec.rb b/spec/helpers/admin/sessions_helper_spec.rb new file mode 100644 index 0000000..2bde3f2 --- /dev/null +++ b/spec/helpers/admin/sessions_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Admin::SessionsHelper. For example: +# +# describe Admin::SessionsHelper 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 Admin::SessionsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/audio_player_helper_spec.rb b/spec/helpers/audio_player_helper_spec.rb new file mode 100644 index 0000000..ea9b223 --- /dev/null +++ b/spec/helpers/audio_player_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the AudioPlayerHelper. For example: +# +# describe AudioPlayerHelper 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 AudioPlayerHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/errors_helper_spec.rb b/spec/helpers/errors_helper_spec.rb new file mode 100644 index 0000000..e359203 --- /dev/null +++ b/spec/helpers/errors_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the ErrorsHelper. For example: +# +# describe ErrorsHelper 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 ErrorsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/pdf_viewer_helper_spec.rb b/spec/helpers/pdf_viewer_helper_spec.rb new file mode 100644 index 0000000..94922b7 --- /dev/null +++ b/spec/helpers/pdf_viewer_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the PdfViewerHelper. For example: +# +# describe PdfViewerHelper 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 PdfViewerHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/registrations_helper_spec.rb b/spec/helpers/registrations_helper_spec.rb new file mode 100644 index 0000000..befe623 --- /dev/null +++ b/spec/helpers/registrations_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the RegistrationsHelper. For example: +# +# describe RegistrationsHelper 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 RegistrationsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb new file mode 100644 index 0000000..74b6daf --- /dev/null +++ b/spec/helpers/search_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the SearchHelper. For example: +# +# describe SearchHelper 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 SearchHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb new file mode 100644 index 0000000..9484198 --- /dev/null +++ b/spec/helpers/sessions_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the SessionsHelper. For example: +# +# describe SessionsHelper 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 SessionsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/topics_helper_spec.rb b/spec/helpers/topics_helper_spec.rb new file mode 100644 index 0000000..6adfadb --- /dev/null +++ b/spec/helpers/topics_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the TopicsHelper. For example: +# +# describe TopicsHelper 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 TopicsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/admin_activity_log_spec.rb b/spec/models/admin_activity_log_spec.rb new file mode 100644 index 0000000..19da45d --- /dev/null +++ b/spec/models/admin_activity_log_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe AdminActivityLog, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/admin_spec.rb b/spec/models/admin_spec.rb new file mode 100644 index 0000000..3fd73fb --- /dev/null +++ b/spec/models/admin_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Admin, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/author_spec.rb b/spec/models/author_spec.rb new file mode 100644 index 0000000..f19debd --- /dev/null +++ b/spec/models/author_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Author, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/content_provider_spec.rb b/spec/models/content_provider_spec.rb new file mode 100644 index 0000000..2b95c1a --- /dev/null +++ b/spec/models/content_provider_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ContentProvider, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/favorite_spec.rb b/spec/models/favorite_spec.rb new file mode 100644 index 0000000..ed5fa59 --- /dev/null +++ b/spec/models/favorite_spec.rb @@ -0,0 +1,52 @@ +require "rails_helper" + +RSpec.describe Favorite, type: :model do + let(:user) { create(:user) } + let(:content_provider) { create(:content_provider) } + let(:topic) { create(:topic, content_provider: content_provider) } + + describe "associations" do + it "belongs to user" do + favorite = Favorite.new(user: user, topic: topic) + expect(favorite.user).to eq(user) + end + + it "belongs to topic" do + favorite = Favorite.new(user: user, topic: topic) + expect(favorite.topic).to eq(topic) + end + end + + describe "validations" do + it "requires user" do + favorite = Favorite.new(topic: topic) + expect(favorite).not_to be_valid + end + + it "requires topic" do + favorite = Favorite.new(user: user) + expect(favorite).not_to be_valid + end + + it "prevents duplicate favorites for same user and topic" do + Favorite.create!(user: user, topic: topic) + duplicate = Favorite.new(user: user, topic: topic) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to include("has already been taken") + end + + it "allows same topic to be favorited by different users" do + other_user = create(:user) + Favorite.create!(user: user, topic: topic) + favorite = Favorite.new(user: other_user, topic: topic) + expect(favorite).to be_valid + end + + it "allows same user to favorite different topics" do + other_topic = create(:topic, content_provider: content_provider) + Favorite.create!(user: user, topic: topic) + favorite = Favorite.new(user: user, topic: other_topic) + expect(favorite).to be_valid + end + end +end diff --git a/spec/models/local_file_spec.rb b/spec/models/local_file_spec.rb new file mode 100644 index 0000000..f43aefa --- /dev/null +++ b/spec/models/local_file_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe LocalFile, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb new file mode 100644 index 0000000..0d0fcb0 --- /dev/null +++ b/spec/models/tag_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Tag, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/topic_author_spec.rb b/spec/models/topic_author_spec.rb new file mode 100644 index 0000000..5bfba8b --- /dev/null +++ b/spec/models/topic_author_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe TopicAuthor, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/topic_file_spec.rb b/spec/models/topic_file_spec.rb new file mode 100644 index 0000000..4dc7744 --- /dev/null +++ b/spec/models/topic_file_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe TopicFile, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb new file mode 100644 index 0000000..b8c5a8e --- /dev/null +++ b/spec/models/topic_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Topic, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/topic_tag_spec.rb b/spec/models/topic_tag_spec.rb new file mode 100644 index 0000000..85a4ddc --- /dev/null +++ b/spec/models/topic_tag_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe TopicTag, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/user_activity_log_spec.rb b/spec/models/user_activity_log_spec.rb new file mode 100644 index 0000000..3966c1c --- /dev/null +++ b/spec/models/user_activity_log_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe UserActivityLog, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..47a31bb --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..9ff5090 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,75 @@ +# This file is copied to spec/ when you run 'rails generate rspec:install' +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +# return unless Rails.env.test? +require 'rspec/rails' +# Add additional requires below this line. Rails is not loaded until this point! + +# Requires supporting ruby files with custom matchers and macros, etc, in +# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are +# run as spec files by default. This means that files in spec/support that end +# in _spec.rb will both be required and run as specs, causing the specs to be +# run twice. It is recommended that you do not name files matching this glob to +# end with _spec.rb. You can configure this pattern with the --pattern +# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. +# +# The following line is provided for convenience purposes. It has the downside +# of increasing the boot-up time by auto-requiring all files in the support +# directory. Alternatively, in the individual `*_spec.rb` files, manually +# require only the support files necessary. +# +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } + +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. +# If you are not using ActiveRecord, you can remove these lines. +begin + ActiveRecord::Migration.maintain_test_schema! +rescue ActiveRecord::PendingMigrationError => e + abort e.to_s.strip +end +RSpec.configure do |config| + # Include FactoryBot methods + config.include FactoryBot::Syntax::Methods + + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # You can uncomment this line to turn off ActiveRecord support entirely. + # config.use_active_record = false + + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: + # + # RSpec.describe UsersController, type: :request do + # # ... + # end + # + # The different available types are documented in the features, such as in + # https://rspec.info/features/8-0/rspec-rails + # + # You can also this infer these behaviours automatically by location, e.g. + # /spec/models would pull in the same behaviour as `type: :model` but this + # behaviour is considered legacy and will be removed in a future version. + # + # To enable this behaviour uncomment the line below. + # config.infer_spec_type_from_file_location! + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") +end diff --git a/spec/requests/admin/base_spec.rb b/spec/requests/admin/base_spec.rb new file mode 100644 index 0000000..5cb0c44 --- /dev/null +++ b/spec/requests/admin/base_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Admin::Bases", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/admin/dashboard_spec.rb b/spec/requests/admin/dashboard_spec.rb new file mode 100644 index 0000000..126a7e9 --- /dev/null +++ b/spec/requests/admin/dashboard_spec.rb @@ -0,0 +1,128 @@ +require "rails_helper" + +RSpec.describe "Admin::Dashboard", type: :request do + let!(:admin) { create(:admin, login_id: "sysadmin", password: "password123") } + + def sign_in_admin + post admin_login_path, params: { login_id: "sysadmin", password: "password123" } + end + + describe "GET /admin" do + context "when not authenticated" do + it "redirects to login" do + get admin_root_path + expect(response).to redirect_to(admin_login_path) + end + end + + context "when authenticated" do + before { sign_in_admin } + + it "returns http success" do + get admin_root_path + expect(response).to have_http_status(:success) + end + + it "displays the dashboard" do + get admin_root_path + expect(response.body).to include("Dashboard") + end + + it "shows overview statistics" do + create_list(:user, 3) + get admin_root_path + expect(response.body).to include("Total Users") + expect(response.body).to include("3") + end + + it "accepts period parameter" do + get admin_root_path(period: 7) + expect(response).to have_http_status(:success) + expect(response.body).to include("7 days") + end + + it "shows activity summary" do + get admin_root_path + expect(response.body).to include("Activity Summary") + end + + it "shows top users section" do + get admin_root_path + expect(response.body).to include("Top Active Users") + end + + it "shows hot topics section" do + get admin_root_path + expect(response.body).to include("Most Viewed Topics") + end + + it "shows popular searches section" do + get admin_root_path + expect(response.body).to include("Popular Searches") + end + end + end + + describe "GET /admin/activity_log" do + context "when not authenticated" do + it "redirects to login" do + get admin_activity_log_path + expect(response).to redirect_to(admin_login_path) + end + end + + context "when authenticated" do + before { sign_in_admin } + + it "returns http success" do + get admin_activity_log_path + expect(response).to have_http_status(:success) + end + + it "displays activity log" do + get admin_activity_log_path + expect(response.body).to include("User Activity Log") + end + + it "shows activity entries" do + user = create(:user) + UserActivityLog.create!(user: user, action_type: "login", ip_address: "127.0.0.1") + + get admin_activity_log_path + expect(response.body).to include(user.first_name) + expect(response.body).to include("Login") + end + end + end + + describe "GET /admin/admin_log" do + context "when not authenticated" do + it "redirects to login" do + get admin_admin_log_path + expect(response).to redirect_to(admin_login_path) + end + end + + context "when authenticated" do + before { sign_in_admin } + + it "returns http success" do + get admin_admin_log_path + expect(response).to have_http_status(:success) + end + + it "displays admin activity log" do + get admin_admin_log_path + expect(response.body).to include("Admin Activity Log") + end + + it "shows admin activity entries" do + AdminActivityLog.create!(admin: admin, action_type: "upload", details: "Uploaded test file", ip_address: "127.0.0.1") + + get admin_admin_log_path + expect(response.body).to include(admin.first_name) + expect(response.body).to include("Uploaded test file") + end + end + end +end diff --git a/spec/requests/admin/local_files_spec.rb b/spec/requests/admin/local_files_spec.rb new file mode 100644 index 0000000..e266699 --- /dev/null +++ b/spec/requests/admin/local_files_spec.rb @@ -0,0 +1,172 @@ +require "rails_helper" + +RSpec.describe "Admin::LocalFiles", type: :request do + let!(:admin) { create(:admin) } + + def sign_in_admin + post admin_login_path, params: { login_id: admin.login_id, password: "password123" } + end + + describe "GET /admin/local_files" do + context "when not authenticated" do + it "redirects to admin login" do + get admin_local_files_path + expect(response).to redirect_to(admin_login_path) + end + end + + context "when authenticated" do + before { sign_in_admin } + + it "returns http success" do + get admin_local_files_path + expect(response).to have_http_status(:success) + end + + it "displays empty state when no files" do + get admin_local_files_path + expect(response.body).to include("No files uploaded yet") + end + + it "lists uploaded files" do + local_file = create(:local_file, admin: admin) + local_file.file.attach( + io: StringIO.new("test content"), + filename: "test.txt", + content_type: "text/plain" + ) + + get admin_local_files_path + expect(response.body).to include("test.txt") + end + end + end + + describe "GET /admin/local_files/new" do + context "when not authenticated" do + it "redirects to admin login" do + get new_admin_local_file_path + expect(response).to redirect_to(admin_login_path) + end + end + + context "when authenticated" do + before { sign_in_admin } + + it "returns http success" do + get new_admin_local_file_path + expect(response).to have_http_status(:success) + end + + it "displays upload form" do + get new_admin_local_file_path + expect(response.body).to include("Upload Files") + end + end + end + + describe "POST /admin/local_files" do + before { sign_in_admin } + + context "with valid file" do + let(:file) do + Rack::Test::UploadedFile.new( + StringIO.new("test content"), + "text/plain", + original_filename: "test.txt" + ) + end + + it "creates a new local file" do + expect { + post admin_local_files_path, params: { files: [ file ], folder_path: "/uploads" } + }.to change(LocalFile, :count).by(1) + end + + it "redirects to index with success message" do + post admin_local_files_path, params: { files: [ file ], folder_path: "/uploads" } + expect(response).to redirect_to(admin_local_files_path) + follow_redirect! + expect(response.body).to include("Successfully uploaded") + end + + it "logs admin activity" do + expect { + post admin_local_files_path, params: { files: [ file ], folder_path: "/uploads" } + }.to change(AdminActivityLog, :count).by(1) + end + end + + context "without files" do + it "redirects with error message" do + post admin_local_files_path, params: { folder_path: "/uploads" } + expect(response).to redirect_to(new_admin_local_file_path) + end + end + end + + describe "DELETE /admin/local_files/:id" do + before { sign_in_admin } + + let!(:local_file) do + file = create(:local_file, admin: admin) + file.file.attach( + io: StringIO.new("test content"), + filename: "test.txt", + content_type: "text/plain" + ) + file + end + + it "deletes the local file" do + expect { + delete admin_local_file_path(local_file) + }.to change(LocalFile, :count).by(-1) + end + + it "redirects to index with success message" do + delete admin_local_file_path(local_file) + expect(response).to redirect_to(admin_local_files_path) + end + + it "logs admin activity" do + expect { + delete admin_local_file_path(local_file) + }.to change(AdminActivityLog, :count).by(1) + end + + it "responds to turbo_stream" do + delete admin_local_file_path(local_file), headers: { "Accept" => "text/vnd.turbo-stream.html" } + expect(response.content_type).to include("text/vnd.turbo-stream.html") + end + end + + describe "DELETE /admin/local_files/destroy_folder" do + before { sign_in_admin } + + let!(:local_file1) do + file = create(:local_file, admin: admin, folder_path: "/test/folder") + file.file.attach(io: StringIO.new("content1"), filename: "file1.txt", content_type: "text/plain") + file + end + + let!(:local_file2) do + file = create(:local_file, admin: admin, folder_path: "/test/folder") + file.file.attach(io: StringIO.new("content2"), filename: "file2.txt", content_type: "text/plain") + file + end + + it "deletes all files in the folder" do + expect { + delete destroy_folder_admin_local_files_path(folder_path: "/test/folder") + }.to change(LocalFile, :count).by(-2) + end + + it "redirects with success message" do + delete destroy_folder_admin_local_files_path(folder_path: "/test/folder") + expect(response).to redirect_to(admin_local_files_path) + follow_redirect! + expect(response.body).to include("Deleted folder") + end + end +end diff --git a/spec/requests/admin/sessions_spec.rb b/spec/requests/admin/sessions_spec.rb new file mode 100644 index 0000000..569f55c --- /dev/null +++ b/spec/requests/admin/sessions_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +RSpec.describe "Admin::Sessions", type: :request do + describe "GET /admin/login" do + it "returns http success" do + get admin_login_path + expect(response).to have_http_status(:success) + end + end + + describe "POST /admin/login" do + let!(:admin) { create(:admin, login_id: "sysadmin", password: "password123") } + + context "with valid credentials" do + it "signs in the admin and redirects to dashboard" do + post admin_login_path, params: { login_id: "sysadmin", password: "password123" } + expect(response).to redirect_to(admin_root_path) + expect(session[:admin_id]).to eq(admin.id) + end + end + + context "with invalid credentials" do + it "renders login form with error" do + post admin_login_path, params: { login_id: "sysadmin", password: "wrong" } + expect(response).to have_http_status(:unprocessable_entity) + expect(session[:admin_id]).to be_nil + end + end + end + + describe "DELETE /admin/logout" do + let!(:admin) { create(:admin, login_id: "sysadmin", password: "password123") } + + it "signs out the admin" do + post admin_login_path, params: { login_id: "sysadmin", password: "password123" } + delete admin_logout_path + expect(response).to redirect_to(admin_login_path) + expect(session[:admin_id]).to be_nil + end + end +end diff --git a/spec/requests/audio_player_spec.rb b/spec/requests/audio_player_spec.rb new file mode 100644 index 0000000..43e2712 --- /dev/null +++ b/spec/requests/audio_player_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe "AudioPlayer", type: :request do + let!(:content_provider) { create(:content_provider) } + let!(:topic) { create(:topic, content_provider: content_provider) } + let!(:mp3_file) { create(:topic_file, topic: topic, file_type: "mp3") } + let!(:pdf_file) { create(:topic_file, topic: topic, file_type: "pdf") } + + describe "GET /topic_files/:id/audio" do + context "with mp3 file" do + it "redirects to audio_not_found when file not attached" do + get audio_topic_file_path(mp3_file) + expect(response).to redirect_to(audio_not_found_path) + end + end + + context "with non-mp3 file" do + it "redirects to not_found" do + get audio_topic_file_path(pdf_file) + expect(response).to redirect_to(not_found_path) + end + end + end +end diff --git a/spec/requests/errors_spec.rb b/spec/requests/errors_spec.rb new file mode 100644 index 0000000..c51ef60 --- /dev/null +++ b/spec/requests/errors_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe "Errors", type: :request do + describe "GET /errors/not_found" do + it "returns 404 status" do + get not_found_path + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /errors/audio_not_found" do + it "returns 404 status" do + get audio_not_found_path + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /errors/pdf_not_found" do + it "returns 404 status" do + get pdf_not_found_path + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /errors/unsupported_browser" do + it "returns http success" do + get unsupported_browser_path + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/requests/local_files_spec.rb b/spec/requests/local_files_spec.rb new file mode 100644 index 0000000..3726231 --- /dev/null +++ b/spec/requests/local_files_spec.rb @@ -0,0 +1,88 @@ +require "rails_helper" + +RSpec.describe "LocalFiles", type: :request do + let!(:user) { create(:user) } + let!(:admin) { create(:admin) } + + describe "GET /local_files" do + context "when not authenticated" do + it "redirects to login" do + get local_files_path + expect(response).to redirect_to(login_path) + end + end + + context "when authenticated" do + before { post login_path, params: { login_id: user.login_id } } + + it "returns http success" do + get local_files_path + expect(response).to have_http_status(:success) + end + + it "displays empty state when no files" do + get local_files_path + expect(response.body).to include("No local files available") + end + + it "lists uploaded files" do + local_file = create(:local_file, admin: admin) + local_file.file.attach( + io: StringIO.new("test content"), + filename: "test.txt", + content_type: "text/plain" + ) + + get local_files_path + expect(response.body).to include("test.txt") + end + + it "groups files by folder" do + file1 = create(:local_file, admin: admin, folder_path: "/folder1") + file1.file.attach(io: StringIO.new("content"), filename: "file1.txt", content_type: "text/plain") + + file2 = create(:local_file, admin: admin, folder_path: "/folder2") + file2.file.attach(io: StringIO.new("content"), filename: "file2.txt", content_type: "text/plain") + + get local_files_path + expect(response.body).to include("/folder1") + expect(response.body).to include("/folder2") + end + end + end + + describe "GET /local_files/:id" do + let!(:local_file) do + file = create(:local_file, admin: admin) + file.file.attach( + io: StringIO.new("test content"), + filename: "test.txt", + content_type: "text/plain" + ) + file + end + + context "when not authenticated" do + it "redirects to login" do + get local_file_path(local_file) + expect(response).to redirect_to(login_path) + end + end + + context "when authenticated" do + before { post login_path, params: { login_id: user.login_id } } + + it "redirects to the file" do + get local_file_path(local_file) + expect(response).to have_http_status(:redirect) + end + + it "logs user activity" do + expect { + get local_file_path(local_file) + }.to change(UserActivityLog, :count).by(1) + expect(UserActivityLog.last.action_type).to eq("view_local_file") + end + end + end +end diff --git a/spec/requests/pdf_viewer_spec.rb b/spec/requests/pdf_viewer_spec.rb new file mode 100644 index 0000000..370a588 --- /dev/null +++ b/spec/requests/pdf_viewer_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe "PdfViewer", type: :request do + let!(:content_provider) { create(:content_provider) } + let!(:topic) { create(:topic, content_provider: content_provider) } + let!(:pdf_file) { create(:topic_file, topic: topic, file_type: "pdf") } + let!(:mp3_file) { create(:topic_file, topic: topic, file_type: "mp3") } + + describe "GET /topic_files/:id/pdf" do + context "with pdf file" do + it "redirects to pdf_not_found when file not attached" do + get pdf_topic_file_path(pdf_file) + expect(response).to redirect_to(pdf_not_found_path) + end + end + + context "with non-pdf file" do + it "redirects to not_found" do + get pdf_topic_file_path(mp3_file) + expect(response).to redirect_to(not_found_path) + end + end + end +end diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb new file mode 100644 index 0000000..c7f5c9b --- /dev/null +++ b/spec/requests/registrations_spec.rb @@ -0,0 +1,40 @@ +require "rails_helper" + +RSpec.describe "Registrations", type: :request do + describe "GET /signup" do + it "returns http success" do + get signup_path + expect(response).to have_http_status(:success) + end + end + + describe "POST /signup" do + context "with valid params" do + let(:valid_params) { { user: { first_name: "John", last_name: "Doe" } } } + + it "creates a new user" do + expect { post signup_path, params: valid_params } + .to change(User, :count).by(1) + end + + it "generates login_id from name" do + post signup_path, params: valid_params + expect(User.last.login_id).to eq("john.doe") + end + + it "signs in the user and redirects" do + post signup_path, params: valid_params + expect(response).to redirect_to(root_path) + expect(session[:user_id]).to eq(User.last.id) + end + end + + context "with invalid params" do + it "does not create user without first_name" do + expect { post signup_path, params: { user: { last_name: "Doe" } } } + .not_to change(User, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end diff --git a/spec/requests/search_spec.rb b/spec/requests/search_spec.rb new file mode 100644 index 0000000..744ed4a --- /dev/null +++ b/spec/requests/search_spec.rb @@ -0,0 +1,130 @@ +require "rails_helper" + +RSpec.describe "Search", type: :request do + let!(:content_provider) { create(:content_provider) } + let!(:topic) { create(:topic, title: "Diabetes Management", content_provider: content_provider) } + let!(:tag) { create(:tag, name: "diabetes") } + let!(:author) { create(:author, name: "Dr. Smith") } + + before do + topic.tags << tag + topic.authors << author + end + + describe "GET /search" do + it "returns http success" do + get search_path + expect(response).to have_http_status(:success) + end + + it "displays search form" do + get search_path + expect(response.body).to include("Search") + end + + context "with query parameter" do + it "returns matching topics by title" do + get search_path, params: { q: "Diabetes" } + expect(response).to have_http_status(:success) + expect(response.body).to include("Diabetes Management") + end + + it "returns matching topics by tag" do + get search_path, params: { q: "diabetes" } + expect(response).to have_http_status(:success) + expect(response.body).to include("Diabetes Management") + end + + it "returns matching topics by author" do + get search_path, params: { q: "Smith" } + expect(response).to have_http_status(:success) + expect(response.body).to include("Diabetes Management") + end + + it "shows no results message when no matches" do + get search_path, params: { q: "nonexistent" } + expect(response).to have_http_status(:success) + expect(response.body).to include("No results found") + end + end + + context "without query parameter" do + it "shows popular tags" do + get search_path + expect(response.body).to include("Popular Tags") + end + end + end + + describe "GET /search/autocomplete" do + it "returns json response" do + get search_autocomplete_path, params: { q: "dia" } + expect(response).to have_http_status(:success) + expect(response.content_type).to include("application/json") + end + + it "returns tag suggestions" do + get search_autocomplete_path, params: { q: "dia" } + json = JSON.parse(response.body) + expect(json).to include(hash_including("type" => "tag", "value" => "diabetes")) + end + + it "returns topic suggestions" do + get search_autocomplete_path, params: { q: "Diabetes" } + json = JSON.parse(response.body) + expect(json).to include(hash_including("type" => "topic", "value" => "Diabetes Management")) + end + + it "returns author suggestions" do + get search_autocomplete_path, params: { q: "Smith" } + json = JSON.parse(response.body) + expect(json).to include(hash_including("type" => "author", "value" => "Dr. Smith")) + end + + it "returns empty array for short queries" do + get search_autocomplete_path, params: { q: "d" } + json = JSON.parse(response.body) + expect(json).to eq([]) + end + + it "returns empty array for blank query" do + get search_autocomplete_path, params: { q: "" } + json = JSON.parse(response.body) + expect(json).to eq([]) + end + end + + describe "GET /search/results" do + it "returns http success" do + get search_results_path, params: { q: "Diabetes" } + expect(response).to have_http_status(:success) + end + + it "shows search results" do + get search_results_path, params: { q: "Diabetes" } + expect(response.body).to include("Diabetes Management") + end + + it "shows results count" do + get search_results_path, params: { q: "Diabetes" } + expect(response.body).to include("1 results") + end + end + + describe "search logging" do + let(:user) { create(:user) } + + it "logs search when user is signed in" do + post login_path, params: { login_id: user.login_id } + expect { + get search_path, params: { q: "Diabetes" } + }.to change(UserActivityLog, :count).by(1) + end + + it "does not log search when user is not signed in" do + expect { + get search_path, params: { q: "Diabetes" } + }.not_to change(UserActivityLog, :count) + end + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 0000000..2191aea --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,46 @@ +require "rails_helper" + +RSpec.describe "Sessions", type: :request do + describe "GET /login" do + it "returns http success" do + get login_path + expect(response).to have_http_status(:success) + end + end + + describe "POST /login" do + let!(:user) { create(:user, first_name: "John", last_name: "Doe") } + + context "with valid login_id" do + it "signs in the user and redirects" do + post login_path, params: { login_id: user.login_id } + expect(response).to redirect_to(root_path) + follow_redirect! + expect(session[:user_id]).to eq(user.id) + end + + it "increments login count" do + expect { post login_path, params: { login_id: user.login_id } } + .to change { user.reload.login_count }.by(1) + end + end + + context "with invalid login_id" do + it "renders login form with error" do + post login_path, params: { login_id: "invalid.user" } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe "DELETE /logout" do + let!(:user) { create(:user) } + + it "signs out the user" do + post login_path, params: { login_id: user.login_id } + delete logout_path + expect(response).to redirect_to(root_path) + expect(session[:user_id]).to be_nil + end + end +end diff --git a/spec/requests/topics_spec.rb b/spec/requests/topics_spec.rb new file mode 100644 index 0000000..da359c6 --- /dev/null +++ b/spec/requests/topics_spec.rb @@ -0,0 +1,133 @@ +require "rails_helper" + +RSpec.describe "Topics", type: :request do + let!(:content_provider) { create(:content_provider) } + let!(:topic) { create(:topic, content_provider: content_provider) } + + describe "GET /topics" do + it "returns http success" do + get topics_path + expect(response).to have_http_status(:success) + end + end + + describe "GET /topics/:id" do + it "returns http success" do + get topic_path(topic) + expect(response).to have_http_status(:success) + end + + it "increments view count" do + expect { get topic_path(topic) }.to change { topic.reload.view_count }.by(1) + end + end + + describe "GET /topics/by_year" do + it "returns http success" do + get by_year_topics_path(year: topic.year) + expect(response).to have_http_status(:success) + end + end + + describe "GET /topics/new_uploads" do + it "returns http success" do + get new_uploads_topics_path + expect(response).to have_http_status(:success) + end + end + + describe "GET /topics/top_topics" do + it "returns http success" do + get top_topics_topics_path + expect(response).to have_http_status(:success) + end + end + + describe "GET /topics/favorites" do + context "when not authenticated" do + it "redirects to login" do + get favorites_topics_path + expect(response).to redirect_to(login_path) + end + end + + context "when authenticated" do + let!(:user) { create(:user) } + + before { post login_path, params: { login_id: user.login_id } } + + it "returns http success" do + get favorites_topics_path + expect(response).to have_http_status(:success) + end + + it "displays user's favorited topics" do + user.favorites.create(topic: topic) + get favorites_topics_path + expect(response.body).to include(topic.title) + end + + it "shows empty state when no favorites" do + get favorites_topics_path + expect(response.body).to include("You haven't added any favorites yet") + end + + it "does not show other users' favorites" do + other_user = create(:user) + other_user.favorites.create(topic: topic) + get favorites_topics_path + expect(response.body).not_to include(topic.title) + end + end + end + + describe "POST /topics/:id/toggle_favorite" do + let!(:user) { create(:user) } + + context "when not authenticated" do + it "redirects to login" do + post toggle_favorite_topic_path(topic) + expect(response).to redirect_to(login_path) + end + end + + context "when authenticated" do + before { post login_path, params: { login_id: user.login_id } } + + it "creates a favorite" do + expect { post toggle_favorite_topic_path(topic) } + .to change { user.favorites.count }.by(1) + end + + it "removes a favorite when already favorited" do + user.favorites.create(topic: topic) + expect { post toggle_favorite_topic_path(topic) } + .to change { user.favorites.count }.by(-1) + end + + it "logs activity when adding favorite" do + expect { post toggle_favorite_topic_path(topic) } + .to change(UserActivityLog, :count).by(1) + expect(UserActivityLog.last.action_type).to eq("favorite") + end + + it "logs activity when removing favorite" do + user.favorites.create(topic: topic) + expect { post toggle_favorite_topic_path(topic) } + .to change(UserActivityLog, :count).by(1) + expect(UserActivityLog.last.action_type).to eq("unfavorite") + end + + it "responds with turbo_stream when requested" do + post toggle_favorite_topic_path(topic), headers: { "Accept" => "text/vnd.turbo-stream.html" } + expect(response.content_type).to include("text/vnd.turbo-stream.html") + expect(response.body).to include("turbo-stream") + end + + it "redirects to topic for html requests" do + post toggle_favorite_topic_path(topic) + expect(response).to redirect_to(topic) + end + end + end +end diff --git a/spec/services/stats_service_spec.rb b/spec/services/stats_service_spec.rb new file mode 100644 index 0000000..5f0a0a7 --- /dev/null +++ b/spec/services/stats_service_spec.rb @@ -0,0 +1,121 @@ +require "rails_helper" + +RSpec.describe StatsService do + let(:service) { described_class.new(days: 30) } + let!(:admin) { create(:admin) } + let!(:content_provider) { create(:content_provider) } + let!(:users) { create_list(:user, 3) } + let!(:topics) { create_list(:topic, 5, content_provider: content_provider) } + + describe "#overview" do + it "returns total counts" do + result = service.overview + + expect(result[:total_users]).to eq(3) + expect(result[:total_topics]).to eq(5) + end + end + + describe "#activity_summary" do + before do + users.each do |user| + UserActivityLog.create!(user: user, action_type: "login", ip_address: "127.0.0.1") + UserActivityLog.create!(user: user, action_type: "view", topic: topics.first, ip_address: "127.0.0.1") + end + UserActivityLog.create!(user: users.first, action_type: "search", search_term: "test", ip_address: "127.0.0.1") + end + + it "returns activity counts" do + result = service.activity_summary + + expect(result[:total_activities]).to eq(7) + expect(result[:logins]).to eq(3) + expect(result[:views]).to eq(3) + expect(result[:searches]).to eq(1) + expect(result[:unique_users]).to eq(3) + end + end + + describe "#top_users" do + before do + UserActivityLog.create!(user: users.first, action_type: "login", ip_address: "127.0.0.1") + UserActivityLog.create!(user: users.first, action_type: "view", topic: topics.first, ip_address: "127.0.0.1") + UserActivityLog.create!(user: users.first, action_type: "view", topic: topics.second, ip_address: "127.0.0.1") + UserActivityLog.create!(user: users.second, action_type: "login", ip_address: "127.0.0.1") + end + + it "returns users ordered by activity count" do + result = service.top_users(limit: 10) + + expect(result.first).to eq(users.first) + expect(result.first.activity_count).to eq(3) + end + end + + describe "#hot_topics" do + before do + topics.first.update!(view_count: 100) + topics.second.update!(view_count: 50) + end + + it "returns topics ordered by view count" do + result = service.hot_topics(limit: 2) + + expect(result.first).to eq(topics.first) + expect(result.second).to eq(topics.second) + end + end + + describe "#popular_searches" do + before do + 3.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "diabetes", search_found: true, ip_address: "127.0.0.1") } + 2.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "heart", search_found: true, ip_address: "127.0.0.1") } + 1.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "cancer", search_found: false, ip_address: "127.0.0.1") } + end + + it "returns search terms ordered by frequency" do + result = service.popular_searches(limit: 10) + + expect(result.keys.first).to eq("diabetes") + expect(result["diabetes"]).to eq(3) + end + end + + describe "#failed_searches" do + before do + 2.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "nonexistent", search_found: false, ip_address: "127.0.0.1") } + 1.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "missing", search_found: false, ip_address: "127.0.0.1") } + 1.times { UserActivityLog.create!(user: users.first, action_type: "search", search_term: "found", search_found: true, ip_address: "127.0.0.1") } + end + + it "returns only failed searches" do + result = service.failed_searches(limit: 10) + + expect(result).to include("nonexistent" => 2) + expect(result).to include("missing" => 1) + expect(result).not_to include("found") + end + end + + describe "#logins_per_day" do + before do + UserActivityLog.create!(user: users.first, action_type: "login", ip_address: "127.0.0.1", created_at: 1.day.ago) + UserActivityLog.create!(user: users.second, action_type: "login", ip_address: "127.0.0.1", created_at: 1.day.ago) + UserActivityLog.create!(user: users.first, action_type: "login", ip_address: "127.0.0.1", created_at: Date.current) + end + + it "groups logins by day" do + result = service.logins_per_day + + expect(result.values.sum).to eq(3) + end + end + + describe "#content_by_provider" do + it "returns topics grouped by provider" do + result = service.content_by_provider + + expect(result[content_provider.name]).to eq(5) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..327b58e --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,94 @@ +# This file was generated by the `rails generate rspec:install` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + config.disable_monkey_patching! + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/views/admin/dashboard/index.html.tailwindcss_spec.rb b/spec/views/admin/dashboard/index.html.tailwindcss_spec.rb new file mode 100644 index 0000000..0541255 --- /dev/null +++ b/spec/views/admin/dashboard/index.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "dashboard/index.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/admin/sessions/create.html.tailwindcss_spec.rb b/spec/views/admin/sessions/create.html.tailwindcss_spec.rb new file mode 100644 index 0000000..3462da9 --- /dev/null +++ b/spec/views/admin/sessions/create.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/create.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/admin/sessions/destroy.html.tailwindcss_spec.rb b/spec/views/admin/sessions/destroy.html.tailwindcss_spec.rb new file mode 100644 index 0000000..ef9c42e --- /dev/null +++ b/spec/views/admin/sessions/destroy.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/destroy.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/admin/sessions/new.html.tailwindcss_spec.rb b/spec/views/admin/sessions/new.html.tailwindcss_spec.rb new file mode 100644 index 0000000..afa8f36 --- /dev/null +++ b/spec/views/admin/sessions/new.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/new.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/audio_player/show.html.tailwindcss_spec.rb b/spec/views/audio_player/show.html.tailwindcss_spec.rb new file mode 100644 index 0000000..d60138c --- /dev/null +++ b/spec/views/audio_player/show.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "audio_player/show.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/errors/audio_not_found.html.tailwindcss_spec.rb b/spec/views/errors/audio_not_found.html.tailwindcss_spec.rb new file mode 100644 index 0000000..510969b --- /dev/null +++ b/spec/views/errors/audio_not_found.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "errors/audio_not_found.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/errors/not_found.html.tailwindcss_spec.rb b/spec/views/errors/not_found.html.tailwindcss_spec.rb new file mode 100644 index 0000000..7715150 --- /dev/null +++ b/spec/views/errors/not_found.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "errors/not_found.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/errors/pdf_not_found.html.tailwindcss_spec.rb b/spec/views/errors/pdf_not_found.html.tailwindcss_spec.rb new file mode 100644 index 0000000..363ed07 --- /dev/null +++ b/spec/views/errors/pdf_not_found.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "errors/pdf_not_found.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/errors/unsupported_browser.html.tailwindcss_spec.rb b/spec/views/errors/unsupported_browser.html.tailwindcss_spec.rb new file mode 100644 index 0000000..23a5152 --- /dev/null +++ b/spec/views/errors/unsupported_browser.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "errors/unsupported_browser.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/pdf_viewer/show.html.tailwindcss_spec.rb b/spec/views/pdf_viewer/show.html.tailwindcss_spec.rb new file mode 100644 index 0000000..6558247 --- /dev/null +++ b/spec/views/pdf_viewer/show.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "pdf_viewer/show.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/registrations/create.html.tailwindcss_spec.rb b/spec/views/registrations/create.html.tailwindcss_spec.rb new file mode 100644 index 0000000..b7aa6ca --- /dev/null +++ b/spec/views/registrations/create.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "registrations/create.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/registrations/new.html.tailwindcss_spec.rb b/spec/views/registrations/new.html.tailwindcss_spec.rb new file mode 100644 index 0000000..3202d25 --- /dev/null +++ b/spec/views/registrations/new.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "registrations/new.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/search/autocomplete.html.tailwindcss_spec.rb b/spec/views/search/autocomplete.html.tailwindcss_spec.rb new file mode 100644 index 0000000..e5fe0da --- /dev/null +++ b/spec/views/search/autocomplete.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "search/autocomplete.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/search/index.html.tailwindcss_spec.rb b/spec/views/search/index.html.tailwindcss_spec.rb new file mode 100644 index 0000000..3a4a542 --- /dev/null +++ b/spec/views/search/index.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "search/index.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/search/results.html.tailwindcss_spec.rb b/spec/views/search/results.html.tailwindcss_spec.rb new file mode 100644 index 0000000..eca3cac --- /dev/null +++ b/spec/views/search/results.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "search/results.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/sessions/create.html.tailwindcss_spec.rb b/spec/views/sessions/create.html.tailwindcss_spec.rb new file mode 100644 index 0000000..3462da9 --- /dev/null +++ b/spec/views/sessions/create.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/create.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/sessions/destroy.html.tailwindcss_spec.rb b/spec/views/sessions/destroy.html.tailwindcss_spec.rb new file mode 100644 index 0000000..ef9c42e --- /dev/null +++ b/spec/views/sessions/destroy.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/destroy.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/sessions/new.html.tailwindcss_spec.rb b/spec/views/sessions/new.html.tailwindcss_spec.rb new file mode 100644 index 0000000..afa8f36 --- /dev/null +++ b/spec/views/sessions/new.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "sessions/new.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/topics/index.html.tailwindcss_spec.rb b/spec/views/topics/index.html.tailwindcss_spec.rb new file mode 100644 index 0000000..079c947 --- /dev/null +++ b/spec/views/topics/index.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "topics/index.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/views/topics/show.html.tailwindcss_spec.rb b/spec/views/topics/show.html.tailwindcss_spec.rb new file mode 100644 index 0000000..d6a8ea7 --- /dev/null +++ b/spec/views/topics/show.html.tailwindcss_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe "topics/show.html.tailwindcss", type: :view do + pending "add some examples to (or delete) #{__FILE__}" +end