completed SMS gateway project

This commit is contained in:
Min Zeya Phyo
2025-10-22 17:22:17 +08:00
commit c883fa7128
190 changed files with 16294 additions and 0 deletions

51
.dockerignore Normal file
View File

@@ -0,0 +1,51 @@
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
# Ignore git directory.
/.git/
/.gitignore
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all default key files.
/config/master.key
/config/credentials/*.key
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore assets.
/node_modules/
/app/assets/builds/*
!/app/assets/builds/.keep
/public/assets
# Ignore CI service files.
/.github
# Ignore Kamal files.
/config/deploy*.yml
/.kamal
# Ignore development files
/.devcontainer
# Ignore Docker-related files
/.dockerignore
/Dockerfile*

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

101
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: CI
on:
pull_request:
push:
branches: [ main ]
jobs:
scan_ruby:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
scan_js:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Scan for security vulnerabilities in JavaScript dependencies
run: bin/importmap audit
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Lint code for consistent style
run: bin/rubocop -f github
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
# redis:
# image: redis
# ports:
# - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config google-chrome-stable
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run tests
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test test:system
- 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

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep
/public/assets
# Ignore master key for decrypting credentials and more.
/config/master.key
/app/assets/builds/*
!/app/assets/builds/.keep

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."

14
.kamal/hooks/post-deploy.sample Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."

51
.kamal/hooks/pre-build.sample Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

47
.kamal/hooks/pre-connect.sample Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

122
.kamal/hooks/pre-deploy.sample Executable file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = github_repo_from_remote_url
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
private
def github_repo_from_remote_url
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
if url.start_with?("https://github.com/")
url.delete_prefix("https://github.com/")
elsif url.start_with?("git@github.com:")
url.delete_prefix("git@github.com:")
else
url
end
end
end
$stdout.sync = true
begin
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."

17
.kamal/secrets Normal file
View File

@@ -0,0 +1,17 @@
# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,
# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
# Example of extracting secrets from 1password (or another compatible pw manager)
# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
# Use a GITHUB_TOKEN if private repositories are needed for the image
# GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
# Grab the registry password from ENV
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# Improve security by using a password manager. Never check config/master.key into git!
RAILS_MASTER_KEY=$(cat config/master.key)

8
.rubocop.yml Normal file
View File

@@ -0,0 +1,8 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
ruby-3.4.7

757
ADMIN_COMPLETE.md Normal file
View File

@@ -0,0 +1,757 @@
# Complete Admin Interface Implementation
## Summary
This document summarizes all the work completed to build a full-featured admin interface for MySMSAPio, including API key management, gateway management, SMS logs, and QR code-based configuration.
---
## Features Implemented
### 1. ✅ Admin Authentication System
**What was built**:
- Secure login/logout system
- Session-based authentication
- Bcrypt password hashing
- Flash message support
- Helper methods for authentication checks
**Files created/modified**:
- `app/models/admin_user.rb` (renamed from `admin.rb` to avoid namespace conflict)
- `app/controllers/admin/base_controller.rb`
- `app/controllers/admin/sessions_controller.rb`
- `app/views/admin/sessions/new.html.erb`
- `app/helpers/application_helper.rb`
- Migration: `db/migrate/*_rename_admins_to_admin_users.rb`
**Access**:
- URL: `/admin/login`
- Default credentials: `admin@example.com` / `password123`
---
### 2. ✅ Professional UI with Tailwind CSS
**What was implemented**:
- Modern Tailwind CSS v4 design system
- Dark gradient sidebar navigation
- Responsive layouts (mobile/tablet/desktop)
- Font Awesome 6.4.0 icons
- Animated status indicators (pulse effects)
- Smooth transitions and hover effects
- Professional color scheme
**Visual Features**:
- Card-based layouts
- Badge components for status
- Table designs with hover effects
- Form styling with icons
- Button states and loading indicators
**Files**:
- `app/assets/tailwind/application.css`
- `app/views/layouts/admin.html.erb`
- All admin view files
---
### 3. ✅ Dashboard
**Features**:
- Real-time statistics:
- Total gateways (with online count)
- Active API keys count
- Messages sent today
- Messages received today
- Recent messages table (last 10)
- Gateway status overview
- Color-coded status badges
**URL**: `/admin/dashboard` or `/admin`
**Files**:
- `app/controllers/admin/dashboard_controller.rb`
- `app/views/admin/dashboard/index.html.erb`
---
### 4. ✅ API Keys Management
**Features**:
- List all API keys with:
- Name and key prefix
- Permissions (as badges)
- Active/Revoked/Expired status
- Last used timestamp
- Creation date
- Create new API keys:
- Name input
- Permission checkboxes (Send SMS, Receive SMS, Manage Gateways, Manage OTP)
- Optional expiration date
- One-time API key display:
- Full key shown only once after creation
- Copy to clipboard button with feedback
- Session-based security (not in URL)
- Revoke keys with confirmation
**URLs**:
- List: `/admin/api_keys`
- Create: `/admin/api_keys/new`
- Show (after creation): `/admin/api_keys/:id`
**Files**:
- `app/controllers/admin/api_keys_controller.rb`
- `app/views/admin/api_keys/index.html.erb`
- `app/views/admin/api_keys/new.html.erb`
- `app/views/admin/api_keys/show.html.erb`
**Security**:
- API keys are SHA256 hashed
- Raw keys shown only once
- Stored in session temporarily
- CSRF protection enabled
---
### 5. ✅ Gateway Management
**Features**:
**List Gateways** (`/admin/gateways`):
- Table showing all gateways
- Online/Offline status with animated pulse
- Active/Inactive toggles
- Priority levels
- Message statistics (today and total)
- Last heartbeat timestamps
- Device IDs
- "Register New Gateway" button
**Create Gateway** (`/admin/gateways/new`):
- Device ID input
- Gateway name input
- Priority selector (1-10)
- Automatic API key generation
- Info box explaining the process
**Gateway Success Page** (after creation):
- **QR Code Display**:
- High-quality SVG QR code
- Contains: API key, API base URL, WebSocket URL
- Scannable with Android app
- Auto-configuration support
- **Manual Configuration**:
- API Base URL (with copy button)
- WebSocket URL (with copy button)
- API Key (with copy button)
- "Copy All" button
- **Gateway Details**:
- Device ID, Name, Priority
- Status (offline until connected)
- Creation timestamp
- **Setup Instructions**:
- Option 1: QR code scanning (recommended)
- Option 2: Manual entry
- Step-by-step guide
**View Gateway Details** (`/admin/gateways/:id`):
- Connection status dashboard
- Statistics cards:
- Online/Offline status
- Active/Inactive status
- Priority level
- Messages sent/received today
- Total messages
- Gateway information:
- Device ID
- Name, Status, Priority
- Last heartbeat (with time ago)
- Message counters
- Creation/update timestamps
- Device metadata (JSON display)
- Recent messages table (last 20)
- Activate/Deactivate button
**Files**:
- `app/controllers/admin/gateways_controller.rb`
- `app/views/admin/gateways/index.html.erb`
- `app/views/admin/gateways/new.html.erb`
- `app/views/admin/gateways/show.html.erb`
**Dependencies**:
- `rqrcode` gem v2.0+ for QR code generation
---
### 6. ✅ SMS Logs
**Features**:
- Paginated message list (50 per page, using Pagy)
- Advanced filtering:
- Direction (Inbound/Outbound/All)
- Status (Pending/Sent/Delivered/Failed/All)
- Phone number search
- Gateway filter
- Date range (from/to)
- Search button
- Message display:
- Message ID (truncated with code style)
- Phone number
- Direction badge (Inbound/Outbound with icons)
- Status badge (color-coded)
- Gateway name (if assigned)
- Message preview (truncated)
- Retry count (if failed)
- Timestamp
- Expandable error messages (click to view full error)
- Empty state with helpful message
**URL**: `/admin/logs`
**Files**:
- `app/controllers/admin/logs_controller.rb`
- `app/views/admin/logs/index.html.erb`
---
### 7. ✅ QR Code Configuration System
**What was implemented**:
- Automatic QR code generation on gateway creation
- JSON payload with all configuration data:
```json
{
"api_key": "gw_live_...",
"api_base_url": "http://localhost:3000",
"websocket_url": "ws://localhost:3000/cable",
"version": "1.0"
}
```
- High error correction level (L=H)
- SVG format for quality scaling
- Automatic URL detection (HTTP→WS, HTTPS→WSS)
- Copy to clipboard for individual fields
- "Copy All" button for complete configuration
**Benefits**:
- No manual typing of long API keys
- Reduces configuration errors
- Faster gateway setup (scan QR code → done)
- Works offline (QR code doesn't need network)
**Gem Added**:
```ruby
gem "rqrcode", "~> 2.0"
```
---
## Bug Fixes Applied
### Issue 1: Namespace Conflict - "Admin is not a module"
**Problem**: Model class `Admin` conflicted with `Admin` module namespace
**Solution**:
- Renamed model: `Admin` → `AdminUser`
- Renamed table: `admins` → `admin_users`
- Updated all references in controllers, views, seeds, tests
**Files**:
- Migration: `20251020031401_rename_admins_to_admin_users.rb`
- Model: `app/models/admin_user.rb`
- All admin controllers
- `db/seeds.rb`
### Issue 2: undefined method 'flash'
**Problem**: Application in API-only mode disabled sessions and flash
**Solution**:
- Disabled `config.api_only = true` in `config/application.rb`
- Created `config/initializers/session_store.rb`
- API controllers use `ActionController::API` (fast, stateless)
- Admin controllers use `ActionController::Base` (full Rails features)
**Middleware Added**:
- ActionDispatch::Cookies
- ActionDispatch::Session::CookieStore
- ActionDispatch::Flash
### Issue 3: undefined method 'logged_in?'
**Problem**: Helper methods not available in layout before controller runs
**Solution**:
- Added `current_admin` and `logged_in?` to `ApplicationHelper`
- Methods now globally available in all views
### Issue 4: undefined method 'stringify_keys' for String
**Problem**: JSONB fields sometimes returned String instead of Hash, causing serialization errors
**Solution**:
- Added explicit attribute declarations to all models with JSONB fields:
- `ApiKey` - `permissions` field
- `Gateway` - `metadata` field
- `OtpCode` - `metadata` field
- `SmsMessage` - `metadata` field
- Added `before_validation :ensure_*_is_hash` callbacks
- Added defensive coding in views
**Pattern Applied**:
```ruby
class Model < ApplicationRecord
attribute :jsonb_field, :jsonb, default: {}
before_validation :ensure_jsonb_field_is_hash
private
def ensure_jsonb_field_is_hash
self.jsonb_field = {} if jsonb_field.nil?
self.jsonb_field = {} unless jsonb_field.is_a?(Hash)
end
end
```
### Issue 5: API Key Creation Stuck on /new Page
**Problem**: Form submission created key but didn't redirect properly
**Solution**:
- Changed from `render :show` to `redirect_to` pattern
- Added session storage for raw API key
- Added `show` action to controller
- Updated routes to include `:show`
**Same fix applied to Gateway creation for consistency**
---
## Architecture
### Hybrid Rails Application
```
MySMSAPio
├── API Endpoints (ActionController::API)
│ ├── Fast, stateless, token-based auth
│ ├── /api/v1/sms/*
│ ├── /api/v1/otp/*
│ └── /api/v1/gateway/*
└── Admin Interface (ActionController::Base)
├── Full Rails features, session-based auth
├── /admin/login
├── /admin/dashboard
├── /admin/api_keys
├── /admin/logs
└── /admin/gateways
```
### Database Schema Updates
**AdminUsers Table**:
```ruby
create_table "admin_users" do |t|
t.string :email, null: false, index: {unique: true}
t.string :password_digest, null: false
t.string :name, null: false
t.datetime :last_login_at
t.timestamps
end
```
**JSONB Fields** (all with `default: {}`):
- `api_keys.permissions` - API key permissions
- `gateways.metadata` - Gateway device metadata
- `otp_codes.metadata` - OTP metadata
- `sms_messages.metadata` - Message metadata
---
## Routes Summary
```ruby
namespace :admin do
# Authentication
get "login", to: "sessions#new"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
# Dashboard
get "dashboard", to: "dashboard#index"
root to: "dashboard#index"
# API Keys
resources :api_keys, only: [:index, :new, :create, :show, :destroy] do
member { post :toggle }
end
# SMS Logs
resources :logs, only: [:index]
# Gateways
resources :gateways, only: [:index, :new, :create, :show] do
member { post :toggle }
end
end
```
---
## Dependencies Added
### Gems
```ruby
gem "tailwindcss-rails", "~> 4.3" # Already present
gem "rqrcode", "~> 2.0" # NEW - QR code generation
```
### External Libraries (CDN)
- Font Awesome 6.4.0 (icons)
---
## Configuration Files
### Key Files Created/Modified
**Config**:
- `config/application.rb` - Disabled API-only mode
- `config/initializers/session_store.rb` - Session configuration
- `config/routes.rb` - Admin routes
**Controllers**:
- `app/controllers/admin/base_controller.rb` - Base for all admin controllers
- `app/controllers/admin/sessions_controller.rb` - Login/logout
- `app/controllers/admin/dashboard_controller.rb` - Dashboard
- `app/controllers/admin/api_keys_controller.rb` - API key management
- `app/controllers/admin/logs_controller.rb` - SMS logs
- `app/controllers/admin/gateways_controller.rb` - Gateway management
**Models**:
- `app/models/admin_user.rb` - Admin authentication
- `app/models/api_key.rb` - Updated with JSONB fix
- `app/models/gateway.rb` - Updated with JSONB fix
- `app/models/otp_code.rb` - Updated with JSONB fix
- `app/models/sms_message.rb` - Updated with JSONB fix
**Views**:
- `app/views/layouts/admin.html.erb` - Admin layout with sidebar
- `app/views/admin/sessions/new.html.erb` - Login page
- `app/views/admin/dashboard/index.html.erb` - Dashboard
- `app/views/admin/api_keys/*.html.erb` - API key views (3 files)
- `app/views/admin/logs/index.html.erb` - SMS logs
- `app/views/admin/gateways/*.html.erb` - Gateway views (3 files)
**Helpers**:
- `app/helpers/application_helper.rb` - Auth helper methods
**Assets**:
- `app/assets/tailwind/application.css` - Custom Tailwind theme
**Seeds**:
- `db/seeds.rb` - Default admin user creation
---
## Security Features
### Authentication
✅ Bcrypt password hashing (cost: 12)
✅ Session-based login (cookie store)
✅ CSRF protection on all forms
✅ `before_action :require_admin` on all admin controllers
### API Keys
✅ SHA256 hashing before storage
✅ Raw keys shown only once
✅ Session-based temporary storage
✅ No keys in URLs or browser history
✅ HTTPS enforcement recommended for production
### Gateways
✅ Unique device ID enforcement
✅ API key generation with secure random
✅ QR code displayed only once
✅ WebSocket authentication required
### General
✅ SQL injection protection (ActiveRecord)
✅ XSS protection (ERB escaping)
✅ Mass assignment protection (strong parameters)
✅ Encrypted session cookies
---
## Documentation Created
1. `ADMIN_INTERFACE.md` - Complete admin documentation
2. `ADMIN_QUICKSTART.md` - Quick reference guide
3. `STARTUP_GUIDE.md` - Detailed startup instructions
4. `NAMESPACE_FIX.md` - Admin namespace conflict explanation
5. `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration details
6. `PERMISSIONS_FIX.md` - JSONB permissions fix explanation
7. `JSONB_FIXES.md` - Complete JSONB field fixes documentation
8. `FIXES_APPLIED.md` - All fixes summary
9. `GATEWAY_MANAGEMENT.md` - Gateway management documentation
10. `QR_CODE_SETUP.md` - QR code implementation details
11. `ADMIN_COMPLETE.md` - This file (complete summary)
---
## Testing
### Manual Testing Checklist
- [x] Admin login works
- [x] Dashboard displays statistics
- [x] Can create API keys
- [x] API key displayed once after creation
- [x] Copy to clipboard works
- [x] Can revoke API keys
- [x] Can view SMS logs
- [x] Can filter SMS logs
- [x] Pagination works
- [x] Can register new gateway
- [x] QR code generated and displayed
- [x] Manual configuration copy buttons work
- [x] Can view gateway details
- [x] Can activate/deactivate gateways
- [x] Can toggle gateway status
- [x] Flash messages display correctly
- [x] Responsive design works on mobile
- [x] All icons display correctly
- [x] No stringify_keys errors
- [x] No JSONB serialization errors
### Console Testing
```bash
# Test admin login
bin/rails runner "puts AdminUser.first&.authenticate('password123') ? 'OK' : 'FAIL'"
# Test API key creation
bin/rails runner "
result = ApiKey.generate!(name: 'Test', permissions: {send_sms: true})
puts result[:raw_key]
"
# Test gateway creation
bin/rails runner "
gateway = Gateway.new(device_id: 'test-001', name: 'Test', priority: 1, status: 'offline')
key = gateway.generate_api_key!
puts key
"
# Test JSONB fields
bin/rails runner "
puts ApiKey.first.permissions.class # Should be Hash
puts Gateway.first.metadata.class # Should be Hash
"
```
---
## Production Deployment Checklist
### Before Deploying
- [ ] Change default admin password
- [ ] Set `config.force_ssl = true` in production.rb
- [ ] Set secure `SECRET_KEY_BASE`
- [ ] Configure proper `ALLOWED_ORIGINS` for CORS
- [ ] Set up proper database backups
- [ ] Configure Redis for production
- [ ] Set up SSL certificates (Let's Encrypt)
- [ ] Configure proper logging
- [ ] Set up monitoring (e.g., New Relic, Datadog)
- [ ] Test all features in staging first
### Environment Variables
```bash
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
SECRET_KEY_BASE=...
RAILS_ENV=production
RAILS_LOG_TO_STDOUT=enabled
RAILS_SERVE_STATIC_FILES=enabled
```
### Security Settings
```ruby
# config/environments/production.rb
config.force_ssl = true
config.action_controller.default_url_options = { host: 'api.example.com', protocol: 'https' }
```
---
## Performance Considerations
### Database Indexes
All critical queries have indexes:
- `admin_users.email` (unique)
- `api_keys.key_digest` (unique)
- `api_keys.key_prefix`
- `gateways.device_id` (unique)
- `sms_messages.message_id` (unique)
- `sms_messages.status`
- `sms_messages.phone_number`
### Pagination
- SMS logs: 50 per page (using Pagy)
- Recent messages: Limited to 20
- Can be adjusted in controllers
### Caching Opportunities
Not yet implemented, but recommended:
- Dashboard statistics (cache for 5 minutes)
- Gateway list (cache for 1 minute)
- API key count (cache for 5 minutes)
---
## Future Enhancements
### Potential Improvements
1. **API Key Features**:
- Edit API key permissions
- API key usage statistics
- Rate limiting configuration per key
- Key rotation/regeneration
2. **Gateway Features**:
- Edit gateway details (name, priority)
- Gateway health alerts
- Multiple device support per gateway
- Gateway groups/tags
3. **Logs Features**:
- Export logs (CSV, JSON)
- Advanced search (regex, wildcards)
- Log retention policies
- Real-time log streaming
4. **Dashboard**:
- Charts and graphs (Chart.js)
- Customizable widgets
- Date range selection
- Export reports
5. **User Management**:
- Multiple admin users
- Role-based permissions (super admin, viewer, etc.)
- Audit logs for admin actions
- Two-factor authentication
6. **Notifications**:
- Email alerts for gateway offline
- SMS delivery failure alerts
- Daily/weekly reports
- Webhook integrations
---
## Summary
### What Was Achieved
✅ **Complete Admin Interface**: Fully functional web-based admin panel
✅ **Authentication**: Secure session-based login with bcrypt
✅ **Professional Design**: Modern Tailwind CSS UI with responsive layout
✅ **API Key Management**: Create, view, revoke with one-time display
✅ **Gateway Management**: Register, configure, monitor SMS gateways
✅ **QR Code Setup**: Instant configuration via QR code scanning
✅ **SMS Logs**: Advanced filtering and pagination
✅ **Dashboard**: Real-time statistics and overview
✅ **Bug Fixes**: All namespace, session, and JSONB issues resolved
✅ **Documentation**: Comprehensive guides and references
✅ **Security**: CSRF protection, password hashing, key encryption
✅ **Production Ready**: Deployable with Kamal/Docker
### Total Files Created/Modified
- **Controllers**: 6 files
- **Models**: 5 files (4 updated, 1 created)
- **Views**: 15+ files
- **Migrations**: 2 files
- **Initializers**: 1 file
- **Routes**: 1 file (updated)
- **Assets**: 1 file
- **Documentation**: 11 markdown files
- **Gemfile**: 1 gem added
- **Seeds**: 1 file (updated)
### Lines of Code
Approximately **3,500+ lines** of Ruby, ERB, CSS, and JavaScript code written.
---
## Quick Start
### First Time Setup
```bash
# Install dependencies
bundle install
# Setup database
bin/rails db:migrate
bin/rails db:seed
# Start server
bin/dev
```
### Access Admin Interface
```
URL: http://localhost:3000/admin/login
Email: admin@example.com
Password: password123
```
### Create First API Key
1. Login to admin
2. Click "API Keys" in sidebar
3. Click "Create New API Key"
4. Fill form and submit
5. Copy the API key (shown only once!)
### Register First Gateway
1. Click "Gateways" in sidebar
2. Click "Register New Gateway"
3. Fill form and submit
4. Scan QR code with Android app OR copy configuration manually
5. Start gateway service in app
6. Gateway will show "Online" when connected
---
## Support
For issues or questions:
- Check documentation in project root
- Review code comments
- Check Rails logs: `tail -f log/development.log`
- Use Rails console for debugging: `bin/rails console`
---
**Status**: ✅ COMPLETE - All features implemented and tested
**Last Updated**: October 20, 2025

376
ADMIN_INTERFACE.md Normal file
View File

@@ -0,0 +1,376 @@
# Admin Interface Documentation
## Overview
A web-based admin interface has been added to MySMSAPio for managing API keys and monitoring SMS logs through a user-friendly dashboard.
## Features
### 1. Dashboard
- Real-time statistics overview
- Total and online gateways
- Active API keys count
- Messages sent/received today
- Failed messages tracking
- Recent messages display
- Gateway status overview
### 2. API Keys Management
- **List API Keys**: View all API keys with their status, permissions, and usage
- **Create API Keys**: Generate new API keys with customizable permissions
- Send SMS permission
- Receive SMS permission
- Manage Gateways permission
- Optional expiration date
- **Revoke API Keys**: Deactivate API keys when no longer needed
- **View Details**: See API key prefix, last used date, and creation date
### 3. SMS Logs Monitoring
- **View All Messages**: Paginated list of all SMS messages (50 per page)
- **Advanced Filtering**:
- Direction (Inbound/Outbound)
- Status (Pending/Queued/Sent/Delivered/Failed)
- Phone number search
- Gateway filter
- Date range (Start/End date)
- **Message Details**: View message ID, content, timestamps, retry count, and error messages
- **Real-time Updates**: See the latest message activity
### 4. Gateways Management
- **List Gateways**: View all registered gateway devices
- **Gateway Details**:
- Connection status (Online/Offline)
- Message statistics (today and total)
- Last heartbeat timestamp
- Device metadata
- **Activate/Deactivate**: Toggle gateway active status
- **Recent Messages**: View messages processed by each gateway
## Access
### Default Login Credentials
After running `bin/rails db:seed`, you can access the admin interface with:
- **URL**: http://localhost:3000/admin/login
- **Email**: admin@example.com
- **Password**: password123
⚠️ **IMPORTANT**: Change the default password immediately in production!
### Creating Additional Admin Users
```ruby
# In Rails console (bin/rails console)
Admin.create!(
email: "your-email@example.com",
password: "your-secure-password",
name: "Your Name"
)
```
## Routes
All admin routes are namespaced under `/admin`:
- `GET /admin/login` - Login page
- `POST /admin/login` - Login action
- `DELETE /admin/logout` - Logout action
- `GET /admin/dashboard` - Dashboard home
- `GET /admin/api_keys` - List API keys
- `GET /admin/api_keys/new` - Create new API key
- `POST /admin/api_keys` - Save new API key
- `DELETE /admin/api_keys/:id` - Revoke API key
- `POST /admin/api_keys/:id/toggle` - Toggle API key active status
- `GET /admin/logs` - View SMS logs with filtering
- `GET /admin/gateways` - List all gateways
- `GET /admin/gateways/:id` - View gateway details
- `POST /admin/gateways/:id/toggle` - Toggle gateway active status
## Technical Details
### Authentication
- Uses Rails' `has_secure_password` with bcrypt for secure password hashing
- Session-based authentication
- Password must be at least 8 characters
- Email validation with format checking
### Authorization
- All admin controllers inherit from `Admin::BaseController`
- `require_admin` before_action ensures user is logged in
- Redirects to login page if not authenticated
### Models
- **Admin** (`app/models/admin.rb`): Admin user accounts
- Fields: email, password_digest, name, last_login_at
- Validations: email uniqueness and format, password minimum length
- Methods: `update_last_login!` for tracking login activity
### Controllers
- **Admin::SessionsController**: Handles login/logout
- **Admin::DashboardController**: Dashboard with statistics
- **Admin::ApiKeysController**: API key CRUD operations
- **Admin::LogsController**: SMS message logs with filtering
- **Admin::GatewaysController**: Gateway management
### Views
- Custom admin layout with built-in CSS (no external dependencies)
- Responsive design with clean, modern UI
- Turbo-powered for fast navigation
- Integrated flash messages for user feedback
### Styling
- Custom CSS included in admin layout
- No external CSS frameworks required
- Clean, modern design with:
- Card-based layouts
- Color-coded badges for status indicators
- Responsive grid system
- Form styling with validation states
- Alert notifications
### Pagination
- Uses Pagy gem for efficient pagination
- Configured in `Admin::BaseController` with `include Pagy::Backend`
- Frontend helpers in `ApplicationHelper` with `include Pagy::Frontend`
- 50 items per page for logs
## Security Best Practices
1. **Change Default Password**: Immediately change admin@example.com password in production
2. **Use Strong Passwords**: Enforce minimum 8 characters (configured in model)
3. **HTTPS Only**: Always use HTTPS in production
4. **Session Security**: Configure secure session cookies in production
5. **Rate Limiting**: Consider adding rate limiting to login endpoint
6. **Regular Audits**: Monitor admin access through `last_login_at` field
## Database Schema
### Admins Table
```ruby
create_table "admins" do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.string :name, null: false
t.datetime :last_login_at
t.timestamps
t.index :email, unique: true
end
```
## Customization
### Adding New Admin Features
1. Create controller in `app/controllers/admin/`
2. Inherit from `Admin::BaseController`
3. Add routes in `config/routes.rb` under `namespace :admin`
4. Create views in `app/views/admin/`
### Styling Customization
- Edit inline styles in `app/views/layouts/admin.html.erb`
- Modify CSS variables for colors, spacing, fonts
- Add custom JavaScript if needed
### Adding Permissions
- Extend `Admin` model with role/permission system
- Add authorization checks in controllers
- Update views to show/hide features based on permissions
## Troubleshooting
### "Please log in to continue" message
- Session may have expired
- Navigate to `/admin/login` to log in again
### API key not showing after creation
- API keys are only shown once for security
- If lost, revoke and create a new one
### Pagination not working
- Ensure Pagy gem is installed: `bundle exec gem list pagy`
- Check that `Pagy::Backend` is included in `Admin::BaseController`
- Verify `Pagy::Frontend` is in `ApplicationHelper`
### Filters not applying
- Check that form is submitting with GET method
- Verify filter parameters are being passed in URL
- Check `apply_filters` method in `Admin::LogsController`
## Development
### Running Tests
```bash
bin/rails test
```
### Checking Routes
```bash
bin/rails routes | grep admin
```
### Console Access
```bash
bin/rails console
# Check admin users
Admin.all
# Create new admin
Admin.create!(email: "test@example.com", password: "password", name: "Test")
```
## Future Enhancements
Potential features to add:
- [ ] Role-based permissions (Admin, Viewer, Operator)
- [ ] Activity logs (audit trail)
- [ ] Two-factor authentication (2FA)
- [ ] Email notifications for critical events
- [ ] Export functionality (CSV, JSON)
- [ ] Advanced analytics and charts
- [ ] Webhook management interface
- [ ] OTP code management
- [ ] Bulk operations for messages
- [ ] API usage analytics per key
---
## Tailwind CSS Theme
The admin interface has been completely redesigned with **Tailwind CSS v4** for a modern, professional appearance.
### Design Features
#### Color Scheme
- **Primary**: Blue (#3b82f6 - blue-600)
- **Success**: Green (#10b981 - green-500)
- **Warning**: Yellow (#f59e0b - yellow-500)
- **Danger**: Red (#ef4444 - red-500)
- **Neutral**: Gray scale for text and backgrounds
#### Components
**Sidebar Navigation:**
- Dark gradient background (gray-900 to gray-800)
- Fixed 288px width (w-72)
- Active state highlighting
- User profile section at bottom
- Smooth transitions on hover
**Cards:**
- Rounded corners (rounded-xl)
- Subtle shadows (ring-1 ring-gray-900/5)
- White background
- Proper spacing and padding
**Stats Cards:**
- Colored icon backgrounds
- Large numbers for metrics
- Secondary information in smaller text
- Animated pulse indicators for online status
**Tables:**
- Alternating row backgrounds on hover
- Sticky headers
- Responsive overflow scrolling
- Color-coded status badges
**Badges:**
- Rounded-full design
- Color-coded by status
- Ring borders for depth
- Icons integrated
**Forms:**
- Rounded inputs (rounded-lg)
- Icon prefixes for visual clarity
- Focus states with blue ring
- Proper validation styling
- Checkbox descriptions
**Buttons:**
- Primary: Blue gradient with hover effect
- Secondary: Gray with hover effect
- Danger: Red for destructive actions
- Success: Green for positive actions
- All include smooth transitions
#### Typography
- Headlines: Bold, large sizes (text-3xl)
- Body: Gray-900 for primary text, gray-600 for secondary
- Font weights: Semibold for emphasis, medium for labels
- Proper line heights and spacing
#### Icons
- **Font Awesome 6.4.0** icons throughout
- Contextual icon usage
- Consistent sizing
- Proper spacing with text
#### Responsive Design
- Mobile-first approach
- Grid layouts that adapt (1/2/3/4 columns)
- Overflow scrolling on mobile
- Touch-friendly tap targets
#### Animations
- Pulse animation for online indicators
- Smooth transitions (duration-200)
- Hover state changes
- Loading states
### Development
**Build Tailwind CSS:**
```bash
bin/rails tailwindcss:build
```
**Watch for changes (development):**
```bash
bin/rails tailwindcss:watch
```
**Using foreman (recommended for development):**
```bash
bin/dev
```
This automatically runs both Rails server and Tailwind watcher.
### Customization
#### Custom Colors
Edit `app/assets/tailwind/application.css`:
```css
@theme {
--color-primary-500: #your-color;
--color-primary-600: #your-darker-color;
}
```
#### Extending Tailwind
The project uses Tailwind CSS v4 with the new `@import "tailwindcss"` syntax. Custom utilities can be added directly to the CSS file.
#### Custom Scrollbar
Custom scrollbar styling is included for webkit browsers with gray colors matching the theme.
### Browser Support
- Modern browsers (Chrome, Firefox, Safari, Edge)
- CSS Grid and Flexbox required
- Custom scrollbar styles for webkit browsers
- Responsive design works on all screen sizes
### Performance
- Tailwind CSS is compiled and cached
- Production builds are automatically optimized
- Uses Rails asset pipeline for caching
- Minimal JavaScript (only for interactive features like copy-to-clipboard)
### Accessibility
- Semantic HTML elements
- Proper ARIA labels
- Keyboard navigation support
- Focus states visible
- Color contrast meets WCAG guidelines
- Screen reader friendly

229
ADMIN_QUICKSTART.md Normal file
View File

@@ -0,0 +1,229 @@
# Admin Interface - Quick Start Guide
## Getting Started
### 1. Start the Development Server
```bash
# Option 1: Run server with Tailwind watch (recommended)
bin/dev
# Option 2: Run server only
bin/rails server
```
### 2. Access the Admin Interface
Open your browser and navigate to:
```
http://localhost:3000/admin/login
```
### 3. Login with Default Credentials
```
Email: admin@example.com
Password: password123
```
⚠️ **Important**: Change this password immediately in production!
## Interface Overview
### Dashboard (/)
- **Real-time Statistics**: View gateway counts, API keys, and message metrics
- **Recent Messages**: Monitor the latest 10 SMS messages
- **Gateway Status**: Check active gateways and their heartbeat status
### API Keys (/admin/api_keys)
- **List Keys**: View all API keys with status and permissions
- **Create New**: Generate API keys with custom permissions (Send SMS, Receive SMS, Manage Gateways)
- **Revoke Keys**: Deactivate keys that are no longer needed
- **One-time Display**: API keys are shown only once after creation for security
### SMS Logs (/admin/logs)
- **View All Messages**: Paginated list (50 per page) of all SMS traffic
- **Advanced Filters**:
- Direction (Inbound/Outbound)
- Status (Pending/Sent/Delivered/Failed)
- Phone number search
- Gateway selection
- Date range
- **Error Details**: Click on failed messages to view error information
### Gateways (/admin/gateways)
- **List Devices**: View all registered gateway devices
- **Device Details**: Click on gateway name for detailed stats
- **Toggle Status**: Activate or deactivate gateways
- **Monitor Health**: Check connection status, heartbeat, and message counts
## Key Features
### Status Indicators
- 🟢 **Green Pulse**: Gateway is online and active
- 🔴 **Red Dot**: Gateway is offline
- 🟡 **Yellow**: Pending or warning status
- 🔵 **Blue**: Information or in-progress status
### Color-Coded Badges
- **Green**: Success, Active, Delivered, Online
- **Blue**: Information, Sent, Outbound
- **Red**: Error, Failed, Revoked, Offline
- **Yellow**: Warning, Pending
- **Purple**: Queued, Priority
- **Gray**: Inactive, Neutral
### Quick Actions
- **Copy API Key**: One-click copy to clipboard
- **Filter & Search**: Quick filtering on all list views
- **Toggle States**: One-click activate/deactivate
- **View Details**: Click on links for detailed information
## Common Tasks
### Creating an API Key
1. Navigate to **API Keys****Create New API Key**
2. Enter a descriptive name
3. Select permissions (checkboxes)
4. Optionally set an expiration date
5. Click **Create API Key**
6. **Copy the key immediately** (shown only once!)
7. Store securely
### Monitoring Messages
1. Navigate to **SMS Logs**
2. Use filters to narrow down results:
- Select direction and status
- Enter phone number to search
- Choose specific gateway
- Set date range
3. Click **Apply Filters**
4. Click on any row with errors to view details
### Managing Gateways
1. Navigate to **Gateways**
2. View online/offline status
3. Click gateway name for detailed stats
4. Use **Activate/Deactivate** buttons to control gateway
### Checking Statistics
1. Dashboard shows current metrics:
- Total vs. online gateways
- Active API keys
- Messages today (sent/received)
- Failed messages count
2. All stats update on page refresh
## Navigation
### Sidebar Menu
- **Dashboard**: Home page with overview
- **API Keys**: Manage client API keys
- **SMS Logs**: View and filter messages
- **Gateways**: Manage gateway devices
### User Menu (Bottom of Sidebar)
- Shows your name and email
- **Logout** button
## Tips & Tricks
### Keyboard Navigation
- All interactive elements are keyboard accessible
- Use Tab to navigate between fields
- Enter to submit forms
### Search & Filter
- Phone number search accepts partial numbers
- Date filters are inclusive (includes start and end dates)
- Clear filters to reset to default view
### Visual Cues
- Hover over rows to highlight
- Online gateways show animated pulse
- Active states have distinct colors
- Error messages expand on click
### Mobile Support
- Sidebar collapses on mobile
- Tables scroll horizontally
- Touch-friendly button sizes
- Responsive grid layouts
## Development
### Running Tests
```bash
bin/rails test
```
### Code Quality
```bash
bin/rubocop
bin/brakeman
```
### Database Management
```bash
# Reset and seed
bin/rails db:reset
# Just seed
bin/rails db:seed
```
### Building Tailwind CSS
```bash
# One-time build
bin/rails tailwindcss:build
# Watch mode (auto-rebuild)
bin/rails tailwindcss:watch
# Or use foreman to run both
bin/dev
```
## Troubleshooting
### Can't Login
- Verify you ran `bin/rails db:seed`
- Check that Admin user exists: `bin/rails console``Admin.count`
- Verify email/password are correct
### Styling Looks Broken
- Run `bin/rails tailwindcss:build`
- Clear browser cache
- Check `app/assets/builds/tailwind.css` exists
### API Key Not Working
- Verify key is active (not revoked)
- Check key hasn't expired
- Ensure permissions are set correctly
### No Data Showing
- Run `bin/rails db:seed` to create sample data
- Check that gateway devices are registered
- Verify messages exist in database
## Security Checklist
- [ ] Change default admin password
- [ ] Use HTTPS in production
- [ ] Set secure session cookies
- [ ] Enable rate limiting
- [ ] Monitor admin access logs
- [ ] Regularly audit API keys
- [ ] Revoke unused API keys
- [ ] Set expiration dates on keys
## Getting Help
- Read full documentation: `ADMIN_INTERFACE.md`
- Check project docs: `CLAUDE.md`
- View Rails logs: `log/development.log`
- Rails console: `bin/rails console`
---
**Happy administering! 🎉**

1020
API_DOCUMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

984
CABLE_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,984 @@
# Action Cable / WebSocket Documentation for Android Integration
## Overview
This document provides comprehensive documentation for integrating with MySMSAPio's Action Cable WebSocket implementation. Action Cable provides real-time bidirectional communication between the Android gateway devices and the server.
**Key Benefits**:
- ⚡ Real-time SMS delivery (no polling required)
- 🔄 Bidirectional communication (send and receive)
- 💓 Heartbeat support over WebSocket
- 📊 Instant delivery reports
- 🔌 Automatic reconnection handling
## WebSocket Connection
### Base URL
Convert your HTTP base URL to WebSocket URL:
```kotlin
val httpUrl = "http://192.168.1.100:3000"
val wsUrl = httpUrl.replace("http://", "ws://")
.replace("https://", "wss://")
// Result: "ws://192.168.1.100:3000"
```
**Production**: Always use `wss://` (WebSocket Secure) for production environments.
### Connection URL Format
```
ws://[host]/cable?api_key=[gateway_api_key]
```
**Example**:
```
ws://192.168.1.100:3000/cable?api_key=gw_live_abc123...
```
**Important**: The API key must be passed as a query parameter for WebSocket authentication.
### Android WebSocket Library
Use OkHttp3 WebSocket client:
```gradle
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}
```
## Action Cable Protocol
Action Cable uses a specific message protocol based on JSON.
### Message Format
All messages are JSON objects with these fields:
```json
{
"command": "subscribe|message|unsubscribe",
"identifier": "{\"channel\":\"ChannelName\"}",
"data": "{\"action\":\"action_name\",\"param\":\"value\"}"
}
```
### Channel Identifier
For gateway communication, use the `GatewayChannel`:
```json
{
"channel": "GatewayChannel"
}
```
**In Kotlin**:
```kotlin
val identifier = """{"channel":"GatewayChannel"}"""
```
## Connection Lifecycle
### 1. Connect to WebSocket
```kotlin
class GatewayWebSocketClient(
private val baseUrl: String,
private val apiKey: String,
private val listener: WebSocketEventListener
) {
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for long-lived connections
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS) // Keep connection alive
.build()
private var webSocket: WebSocket? = null
private val identifier = """{"channel":"GatewayChannel"}"""
fun connect() {
val wsUrl = baseUrl.replace("http://", "ws://")
.replace("https://", "wss://")
val request = Request.Builder()
.url("$wsUrl/cable?api_key=$apiKey")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected")
listener.onConnected()
subscribe()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "Received: $text")
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closing: $code $reason")
listener.onClosing(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: $code $reason")
listener.onDisconnected()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket error", t)
listener.onError(t)
}
})
}
fun disconnect() {
webSocket?.close(1000, "Client disconnect")
webSocket = null
}
}
```
### 2. Subscribe to GatewayChannel
After connection is established, subscribe to the channel:
```kotlin
private fun subscribe() {
val subscribeMessage = JSONObject().apply {
put("command", "subscribe")
put("identifier", identifier)
}
webSocket?.send(subscribeMessage.toString())
Log.d(TAG, "Subscribing to GatewayChannel")
}
```
**Server Response** (confirmation):
```json
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"type": "confirm_subscription"
}
```
### 3. Handle Incoming Messages
```kotlin
private fun handleMessage(text: String) {
try {
val json = JSONObject(text)
when {
// Subscription confirmed
json.has("type") && json.getString("type") == "confirm_subscription" -> {
Log.d(TAG, "Subscription confirmed")
listener.onSubscribed()
}
// Welcome message (connection established)
json.has("type") && json.getString("type") == "welcome" -> {
Log.d(TAG, "Welcome received")
}
// Ping from server (keep-alive)
json.has("type") && json.getString("type") == "ping" -> {
// Server sent ping, no response needed
Log.d(TAG, "Ping received")
}
// Data message from channel
json.has("message") -> {
val message = json.getJSONObject("message")
handleChannelMessage(message)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing message", e)
}
}
```
### 4. Handle Channel Messages
```kotlin
private fun handleChannelMessage(message: JSONObject) {
val action = message.optString("action")
when (action) {
"send_sms" -> handleSendSmsRequest(message)
"heartbeat_ack" -> handleHeartbeatAck(message)
"delivery_report_ack" -> handleDeliveryReportAck(message)
else -> Log.w(TAG, "Unknown action: $action")
}
}
```
## Sending Messages to Server
### Message Structure
```kotlin
private fun sendChannelMessage(action: String, data: Map<String, Any>) {
val dataJson = JSONObject(data).toString()
val message = JSONObject().apply {
put("command", "message")
put("identifier", identifier)
put("data", dataJson)
}
val sent = webSocket?.send(message.toString()) ?: false
if (!sent) {
Log.e(TAG, "Failed to send message: $action")
}
}
```
## Heartbeat Over WebSocket
### Sending Heartbeat
Send heartbeat every 60 seconds:
```kotlin
class HeartbeatManager(
private val webSocketClient: GatewayWebSocketClient
) {
private val handler = Handler(Looper.getMainLooper())
private val heartbeatInterval = 60_000L // 60 seconds
private val heartbeatRunnable = object : Runnable {
override fun run() {
sendHeartbeat()
handler.postDelayed(this, heartbeatInterval)
}
}
fun start() {
handler.post(heartbeatRunnable)
}
fun stop() {
handler.removeCallbacks(heartbeatRunnable)
}
private fun sendHeartbeat() {
val data = mapOf(
"action" to "heartbeat",
"device_info" to mapOf(
"battery_level" to getBatteryLevel(),
"signal_strength" to getSignalStrength(),
"pending_messages" to getPendingMessageCount()
)
)
webSocketClient.sendMessage("heartbeat", data)
Log.d(TAG, "Heartbeat sent via WebSocket")
}
}
```
### WebSocket Client Send Method
```kotlin
fun sendMessage(action: String, data: Map<String, Any>) {
sendChannelMessage(action, data)
}
private fun sendChannelMessage(action: String, data: Map<String, Any>) {
val dataMap = data.toMutableMap()
dataMap["action"] = action
val dataJson = JSONObject(dataMap).toString()
val message = JSONObject().apply {
put("command", "message")
put("identifier", identifier)
put("data", dataJson)
}
webSocket?.send(message.toString())
}
```
### Server Response
The server acknowledges heartbeat:
```json
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"message": {
"action": "heartbeat_ack",
"status": "success",
"server_time": "2025-10-20T14:30:00Z"
}
}
```
## Receiving SMS Request
The server sends SMS via WebSocket when there's an outbound message:
### Incoming Message Format
```json
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"message": {
"action": "send_sms",
"message_id": "msg_abc123...",
"phone_number": "+959123456789",
"message_body": "Your OTP is 123456"
}
}
```
### Handle Send SMS Request
```kotlin
private fun handleSendSmsRequest(message: JSONObject) {
val messageId = message.getString("message_id")
val phoneNumber = message.getString("phone_number")
val messageBody = message.getString("message_body")
Log.d(TAG, "SMS send request: $messageId to $phoneNumber")
// Send SMS via Android SMS Manager
sendSmsMessage(phoneNumber, messageBody, messageId)
}
private fun sendSmsMessage(
phoneNumber: String,
message: String,
messageId: String
) {
val smsManager = SmsManager.getDefault()
// Create pending intent for delivery report
val deliveryIntent = Intent("SMS_DELIVERED").apply {
putExtra("message_id", messageId)
putExtra("phone_number", phoneNumber)
}
val deliveryPendingIntent = PendingIntent.getBroadcast(
context,
messageId.hashCode(),
deliveryIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
// Split message if longer than 160 characters
if (message.length > 160) {
val parts = smsManager.divideMessage(message)
val deliveryIntents = ArrayList<PendingIntent>()
for (i in parts.indices) {
deliveryIntents.add(deliveryPendingIntent)
}
smsManager.sendMultipartTextMessage(
phoneNumber,
null,
parts,
null,
deliveryIntents
)
} else {
smsManager.sendTextMessage(
phoneNumber,
null,
message,
null,
deliveryPendingIntent
)
}
Log.d(TAG, "SMS sent successfully: $messageId")
} catch (e: Exception) {
Log.e(TAG, "Failed to send SMS: $messageId", e)
reportDeliveryFailure(messageId, e.message ?: "Unknown error")
}
}
```
## Sending Delivery Reports
After SMS is delivered (or fails), report back to server:
### Delivery Report Format
```kotlin
fun sendDeliveryReport(
messageId: String,
status: String, // "delivered", "failed", "sent"
errorMessage: String? = null
) {
val data = mutableMapOf<String, Any>(
"action" to "delivery_report",
"message_id" to messageId,
"status" to status
)
if (errorMessage != null) {
data["error_message"] = errorMessage
}
sendChannelMessage("delivery_report", data)
Log.d(TAG, "Delivery report sent: $messageId -> $status")
}
```
### Broadcast Receiver for Delivery Status
```kotlin
class SmsDeliveryReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageId = intent.getStringExtra("message_id") ?: return
val phoneNumber = intent.getStringExtra("phone_number") ?: return
when (resultCode) {
Activity.RESULT_OK -> {
Log.d(TAG, "SMS delivered: $messageId")
webSocketClient.sendDeliveryReport(
messageId = messageId,
status = "delivered"
)
}
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
Log.e(TAG, "SMS failed (generic): $messageId")
webSocketClient.sendDeliveryReport(
messageId = messageId,
status = "failed",
errorMessage = "Generic failure"
)
}
SmsManager.RESULT_ERROR_NO_SERVICE -> {
Log.e(TAG, "SMS failed (no service): $messageId")
webSocketClient.sendDeliveryReport(
messageId = messageId,
status = "failed",
errorMessage = "No service"
)
}
SmsManager.RESULT_ERROR_RADIO_OFF -> {
Log.e(TAG, "SMS failed (radio off): $messageId")
webSocketClient.sendDeliveryReport(
messageId = messageId,
status = "failed",
errorMessage = "Radio off"
)
}
SmsManager.RESULT_ERROR_NULL_PDU -> {
Log.e(TAG, "SMS failed (null PDU): $messageId")
webSocketClient.sendDeliveryReport(
messageId = messageId,
status = "failed",
errorMessage = "Null PDU"
)
}
}
}
}
```
### Register Receiver in Manifest
```xml
<receiver android:name=".SmsDeliveryReceiver"
android:exported="false">
<intent-filter>
<action android:name="SMS_DELIVERED" />
</intent-filter>
</receiver>
```
## Reporting Received SMS
When Android receives an SMS, forward it to the server:
### Received SMS Format
```kotlin
fun reportReceivedSms(
phoneNumber: String,
messageBody: String,
receivedAt: Long = System.currentTimeMillis()
) {
val data = mapOf(
"action" to "received_sms",
"phone_number" to phoneNumber,
"message_body" to messageBody,
"received_at" to receivedAt
)
sendChannelMessage("received_sms", data)
Log.d(TAG, "Received SMS reported: from $phoneNumber")
}
```
### SMS Receiver Implementation
```kotlin
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (message in messages) {
val sender = message.originatingAddress ?: continue
val body = message.messageBody ?: continue
val timestamp = message.timestampMillis
Log.d(TAG, "SMS received from $sender")
// Report to server via WebSocket
webSocketClient.reportReceivedSms(
phoneNumber = sender,
messageBody = body,
receivedAt = timestamp
)
}
}
}
}
```
## Reconnection Strategy
Implement automatic reconnection with exponential backoff:
```kotlin
class ReconnectionManager(
private val webSocketClient: GatewayWebSocketClient
) {
private val handler = Handler(Looper.getMainLooper())
private var reconnectAttempts = 0
private val maxReconnectAttempts = 10
private val baseDelay = 1000L // 1 second
fun scheduleReconnect() {
if (reconnectAttempts >= maxReconnectAttempts) {
Log.e(TAG, "Max reconnection attempts reached")
return
}
val delay = calculateBackoffDelay(reconnectAttempts)
reconnectAttempts++
Log.d(TAG, "Scheduling reconnection attempt $reconnectAttempts in ${delay}ms")
handler.postDelayed({
Log.d(TAG, "Attempting reconnection...")
webSocketClient.connect()
}, delay)
}
fun reset() {
reconnectAttempts = 0
handler.removeCallbacksAndMessages(null)
}
private fun calculateBackoffDelay(attempt: Int): Long {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s...
val exponentialDelay = baseDelay * (1 shl attempt)
return minOf(exponentialDelay, 60_000L) // Cap at 60 seconds
}
}
```
### Integrate with WebSocket Client
```kotlin
class GatewayWebSocketClient(
private val baseUrl: String,
private val apiKey: String,
private val listener: WebSocketEventListener
) {
private val reconnectionManager = ReconnectionManager(this)
// ... existing code ...
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket connected")
reconnectionManager.reset() // Reset reconnection attempts
listener.onConnected()
subscribe()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket error", t)
listener.onError(t)
reconnectionManager.scheduleReconnect() // Automatically reconnect
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closed: $code $reason")
listener.onDisconnected()
// Only reconnect if not intentionally closed
if (code != 1000) {
reconnectionManager.scheduleReconnect()
}
}
}
```
## Complete Android Service
Combine everything into a foreground service:
```kotlin
class GatewayService : Service() {
private lateinit var webSocketClient: GatewayWebSocketClient
private lateinit var heartbeatManager: HeartbeatManager
private val notificationId = 1001
private val channelId = "gateway_service"
override fun onCreate() {
super.onCreate()
// Load configuration
val prefs = getSharedPreferences("gateway_config", MODE_PRIVATE)
val apiKey = prefs.getString("api_key", null) ?: return
val baseUrl = prefs.getString("base_url", null) ?: return
// Initialize WebSocket client
webSocketClient = GatewayWebSocketClient(
baseUrl = baseUrl,
apiKey = apiKey,
listener = object : WebSocketEventListener {
override fun onConnected() {
Log.d(TAG, "Connected to server")
updateNotification("Connected")
}
override fun onDisconnected() {
Log.d(TAG, "Disconnected from server")
updateNotification("Disconnected")
}
override fun onSubscribed() {
Log.d(TAG, "Subscribed to GatewayChannel")
heartbeatManager.start()
}
override fun onError(error: Throwable) {
Log.e(TAG, "WebSocket error", error)
updateNotification("Error: ${error.message}")
}
}
)
// Initialize heartbeat manager
heartbeatManager = HeartbeatManager(webSocketClient)
// Create notification channel
createNotificationChannel()
// Start as foreground service
startForeground(notificationId, createNotification("Starting..."))
// Connect to server
webSocketClient.connect()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // Restart if killed
}
override fun onDestroy() {
super.onDestroy()
heartbeatManager.stop()
webSocketClient.disconnect()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Gateway Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "SMS Gateway background service"
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(status: String): Notification {
return NotificationCompat.Builder(this, channelId)
.setContentTitle("SMS Gateway Active")
.setContentText(status)
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
}
private fun updateNotification(status: String) {
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(notificationId, createNotification(status))
}
companion object {
private const val TAG = "GatewayService"
}
}
```
### Start Service from Activity
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Start gateway service
val serviceIntent = Intent(this, GatewayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
}
```
### Service in Manifest
```xml
<service
android:name=".GatewayService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
```
## Error Handling
### Connection Errors
```kotlin
interface WebSocketEventListener {
fun onConnected()
fun onDisconnected()
fun onSubscribed()
fun onError(error: Throwable)
fun onClosing(code: Int, reason: String)
}
```
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| **401 Unauthorized** | Invalid API key | Verify API key is correct and starts with `gw_live_` |
| **403 Forbidden** | Origin not allowed | Check Action Cable allowed origins in server config |
| **Connection timeout** | Server unreachable | Verify server URL and network connectivity |
| **Connection closed (1006)** | Abnormal closure | Check server logs, implement reconnection |
| **Subscription rejected** | Channel not found | Ensure using `GatewayChannel` identifier |
## Testing WebSocket Connection
### Test Tool: wscat
Install wscat (Node.js tool):
```bash
npm install -g wscat
```
Connect to server:
```bash
wscat -c "ws://192.168.1.100:3000/cable?api_key=gw_live_your_key_here"
```
Subscribe to channel:
```json
{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
```
Send heartbeat:
```json
{"command":"message","identifier":"{\"channel\":\"GatewayChannel\"}","data":"{\"action\":\"heartbeat\",\"device_info\":{\"battery_level\":85}}"}
```
## Production Configuration
### Use WSS (WebSocket Secure)
Always use encrypted WebSocket in production:
```kotlin
val baseUrl = "https://api.yourdomain.com"
val wsUrl = baseUrl.replace("https://", "wss://")
// Result: "wss://api.yourdomain.com"
```
### SSL Certificate Pinning
For additional security, implement certificate pinning:
```kotlin
val certificatePinner = CertificatePinner.Builder()
.add("api.yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
```
### Server Configuration
In production, configure Action Cable in `config/cable.yml`:
```yaml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") %>
channel_prefix: my_smsa_pio_production
```
And in `config/environments/production.rb`:
```ruby
config.action_cable.url = "wss://api.yourdomain.com/cable"
config.action_cable.allowed_request_origins = [
"https://yourdomain.com",
/https:\/\/.*\.yourdomain\.com/
]
```
## Message Flow Summary
### Outbound SMS Flow
1. **Server → Android**: Server sends `send_sms` action via WebSocket
2. **Android**: Receives message, sends SMS via SmsManager
3. **Android → Server**: Reports delivery status via `delivery_report` action
### Inbound SMS Flow
1. **Android**: Receives SMS via BroadcastReceiver
2. **Android → Server**: Reports received SMS via `received_sms` action
3. **Server**: Processes and triggers webhooks if configured
### Heartbeat Flow
1. **Android → Server**: Sends `heartbeat` action every 60 seconds
2. **Server**: Updates `last_heartbeat_at` timestamp
3. **Server → Android**: Sends `heartbeat_ack` confirmation
## Debugging Tips
### Enable Logging
```kotlin
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
```
### Monitor Messages
```kotlin
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d(TAG, "← Received: $text")
handleMessage(text)
}
private fun sendChannelMessage(action: String, data: Map<String, Any>) {
val message = buildMessage(action, data)
Log.d(TAG, "→ Sending: ${message.toString()}")
webSocket?.send(message.toString())
}
```
### Check Server Logs
On the server, monitor Action Cable logs:
```bash
# Development
tail -f log/development.log | grep "GatewayChannel"
# Production (via Kamal)
bin/kamal logs | grep "GatewayChannel"
```
## Performance Considerations
### Message Buffering
If sending multiple messages rapidly, consider buffering:
```kotlin
class MessageBuffer(
private val webSocketClient: GatewayWebSocketClient
) {
private val buffer = mutableListOf<Pair<String, Map<String, Any>>>()
private val handler = Handler(Looper.getMainLooper())
private val flushInterval = 1000L // 1 second
fun enqueue(action: String, data: Map<String, Any>) {
buffer.add(action to data)
scheduleFlush()
}
private fun scheduleFlush() {
handler.removeCallbacks(flushRunnable)
handler.postDelayed(flushRunnable, flushInterval)
}
private val flushRunnable = Runnable {
if (buffer.isNotEmpty()) {
buffer.forEach { (action, data) ->
webSocketClient.sendMessage(action, data)
}
buffer.clear()
}
}
}
```
### Connection Pooling
OkHttp handles connection pooling automatically, but you can configure it:
```kotlin
val client = OkHttpClient.Builder()
.connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
.build()
```
## Summary
**WebSocket Connection**: Connect to `/cable` with API key query parameter
**Channel Subscription**: Subscribe to `GatewayChannel` after connection
**Send SMS**: Receive `send_sms` actions from server
**Delivery Reports**: Report SMS delivery status back to server
**Receive SMS**: Forward received SMS to server via `received_sms` action
**Heartbeat**: Send heartbeat every 60 seconds to maintain online status
**Reconnection**: Implement exponential backoff for automatic reconnection
**Foreground Service**: Run as foreground service for reliability
**Error Handling**: Handle all connection and message errors gracefully
**Production Ready**: Use WSS, certificate pinning, and proper logging
**Complete Android integration with MySMSAPio WebSocket server!** 🚀

430
CLAUDE.md Normal file
View File

@@ -0,0 +1,430 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MySMSAPio is an SMS Gateway Backend API - a Rails 8.0 REST API and WebSocket server for managing SMS messaging through Android gateway devices. It provides programmatic SMS capabilities with OTP management, webhooks, and real-time WebSocket communication.
The project includes a web-based admin interface for managing API keys, monitoring SMS logs, and viewing gateway status. See [ADMIN_INTERFACE.md](ADMIN_INTERFACE.md) for detailed documentation.
## Tech Stack
- **Framework**: Rails 8.0.3 (API-only mode)
- **Ruby**: 3.4.7
- **Database**: PostgreSQL 14+
- **Cache/Queue**: Redis 7+
- **Background Jobs**: Sidekiq 7 with sidekiq-cron
- **WebSocket**: Action Cable (Redis adapter)
- **Authentication**: API Keys with SHA256 hashing
- **Server**: Puma with Thruster for production
- **Deployment**: Kamal (Docker-based deployment)
- **Code Quality**: RuboCop with rails-omakase configuration, Brakeman for security
## SMS Gateway Dependencies
- **redis**: WebSocket, caching, background jobs
- **sidekiq**: Background job processing
- **sidekiq-cron**: Scheduled jobs (health checks, cleanup)
- **jwt**: API authentication tokens
- **rack-cors**: Cross-origin resource sharing
- **phonelib**: Phone number validation
- **rotp**: OTP generation
- **httparty**: Webhook HTTP requests
- **pagy**: Pagination
## Development Commands
### Initial Setup
```bash
bin/setup
```
This will:
- Install dependencies
- Prepare the database
- Clear logs and temp files
- Start the development server
To skip auto-starting the server:
```bash
bin/setup --skip-server
```
### Running the Application
```bash
bin/dev
# or
bin/rails server
```
### Database
**Create and migrate databases:**
```bash
bin/rails db:create
bin/rails db:migrate
```
**Prepare database (create + migrate + seed):**
```bash
bin/rails db:prepare
```
**Reset database:**
```bash
bin/rails db:reset
```
**Database console:**
```bash
bin/rails dbconsole
# or via Kamal:
bin/kamal dbc
```
### Testing
**Run all tests:**
```bash
bin/rails test
```
**Run specific test file:**
```bash
bin/rails test test/models/your_model_test.rb
```
**Run specific test by line number:**
```bash
bin/rails test test/models/your_model_test.rb:42
```
**System tests:**
```bash
bin/rails test:system
```
Tests run in parallel using all processor cores by default.
### Code Quality
**Run RuboCop:**
```bash
bin/rubocop
```
**Auto-correct RuboCop violations:**
```bash
bin/rubocop -a
```
**Run Brakeman security scanner:**
```bash
bin/brakeman
```
### Rails Console
```bash
bin/rails console
# or via Kamal:
bin/kamal console
```
### Background Jobs (Sidekiq)
**Start Sidekiq:**
```bash
bundle exec sidekiq
```
**View scheduled jobs:**
```bash
bundle exec sidekiq-cron
```
**Scheduled jobs run automatically**:
- `CheckGatewayHealthJob`: Every minute
- `CleanupExpiredOtpsJob`: Every 15 minutes
- `ResetDailyCountersJob`: Daily at midnight
### Asset Management
**Precompile assets:**
```bash
bin/rails assets:precompile
```
**Clear compiled assets:**
```bash
bin/rails assets:clobber
```
### Deployment (Kamal)
**Deploy application:**
```bash
bin/kamal deploy
```
**View logs:**
```bash
bin/kamal logs
```
**Access remote shell:**
```bash
bin/kamal shell
```
**View configuration:**
```bash
bin/kamal config
```
The deployment configuration is in `config/deploy.yml`. Production uses Docker containers with SSL enabled via Let's Encrypt.
## Database Architecture
### Development/Test
Single PostgreSQL database per environment:
- Development: `my_smsa_pio_development`
- Test: `my_smsa_pio_test`
### Production
Multi-database setup for performance isolation:
- **Primary**: Main application data
- **Cache**: Database-backed cache via Solid Cache (migrations in `db/cache_migrate/`)
- **Queue**: Job queue via Solid Queue (migrations in `db/queue_migrate/`)
- **Cable**: WebSocket connections via Solid Cable (migrations in `db/cable_migrate/`)
Each database shares connection pooling configuration but maintains separate migration paths.
## Application Structure
The application follows standard Rails 8 conventions with the modern "Omakase" stack:
- **app/**: Standard Rails application code (models, controllers, views, jobs, mailers, helpers)
- **config/**: Configuration files including multi-database setup
- **db/**: Database schemas with separate migration directories for each database role
- **lib/**: Custom library code (autoloaded via `config.autoload_lib`)
- **test/**: Minitest-based test suite with system tests using Capybara + Selenium
## Key Configuration Details
### Module Name
The Rails application module is `MySmsaPio` (defined in `config/application.rb`).
### Environment Variables
**Required**:
- `DATABASE_URL`: PostgreSQL connection string
- `REDIS_URL`: Redis connection string (used for Action Cable, Sidekiq, caching)
- `SECRET_KEY_BASE`: Rails secret key
- `RAILS_ENV`: Environment (development, test, production)
**Optional**:
- `ALLOWED_ORIGINS`: CORS allowed origins (default: `*`)
- `DEFAULT_COUNTRY_CODE`: Default country for phone validation (default: `US`)
- `RAILS_LOG_LEVEL`: Logging level
### Docker & Production
The application is containerized using a multi-stage Dockerfile optimized for production:
- Base Ruby 3.4.7 slim image
- Uses Thruster as the web server (listens on port 80)
- Non-root user (rails:rails, uid/gid 1000)
- Entrypoint handles database preparation via `bin/docker-entrypoint`
- Assets precompiled during build
### Code Style
Follow RuboCop Rails Omakase conventions. Configuration is minimal, inheriting from `rubocop-rails-omakase` gem.
## Health Checks
The application includes a health check endpoint:
```
GET /up
```
Returns 200 if app boots successfully, 500 otherwise. Used by load balancers and monitoring.
---
## SMS Gateway Specific Information
### Database Models
**Gateway** (`app/models/gateway.rb`):
- Represents Android SMS gateway devices
- Tracks connection status, heartbeat, message counts
- Generates and stores API keys (hashed with SHA256)
**SmsMessage** (`app/models/sms_message.rb`):
- Stores all SMS messages (inbound and outbound)
- Auto-generates unique message IDs
- Validates phone numbers using Phonelib
- Triggers SendSmsJob on creation for outbound messages
**OtpCode** (`app/models/otp_code.rb`):
- Generates 6-digit OTP codes
- Enforces rate limiting (3 per phone per hour)
- Auto-expires after 5 minutes
- Tracks verification attempts (max 3)
**WebhookConfig** (`app/models/webhook_config.rb`):
- Configures webhooks for SMS events
- Signs payloads with HMAC-SHA256
- Supports retry logic
**ApiKey** (`app/models/api_key.rb`):
- Client API keys for application access
- Permissions-based access control
- Tracks usage and expiration
### API Controllers
**Gateway APIs** (`app/controllers/api/v1/gateway/`):
- `RegistrationsController`: Register new gateway devices
- `HeartbeatsController`: Keep-alive from gateways
- `SmsController`: Report received SMS and delivery status
**Client APIs** (`app/controllers/api/v1/`):
- `SmsController`: Send/receive SMS, check status
- `OtpController`: Generate and verify OTP codes
- `Admin::GatewaysController`: Manage gateway devices
- `Admin::StatsController`: System statistics
### WebSocket Communication
**GatewayChannel** (`app/channels/gateway_channel.rb`):
- Real-time bidirectional communication with gateway devices
- Authenticated via API key digest
- Handles:
- Gateway connection/disconnection
- Heartbeat messages
- Delivery reports
- Inbound SMS notifications
- Outbound SMS commands
**Connection** (`app/channels/application_cable/connection.rb`):
- Authenticates WebSocket connections
- Verifies gateway API keys
### Background Jobs
**Processing Jobs**:
- `SendSmsJob`: Routes outbound SMS to available gateways via WebSocket
- `ProcessInboundSmsJob`: Triggers webhooks for received SMS
- `RetryFailedSmsJob`: Retries failed messages with exponential backoff
- `TriggerWebhookJob`: Executes webhook HTTP requests
**Scheduled Jobs** (config/sidekiq_cron.yml):
- `CheckGatewayHealthJob`: Marks offline gateways (every minute)
- `CleanupExpiredOtpsJob`: Deletes expired OTP codes (every 15 minutes)
- `ResetDailyCountersJob`: Resets daily message counters (daily at midnight)
### Admin Web Interface
**Admin Authentication** (`app/models/admin.rb`, `app/controllers/admin/`):
- Session-based authentication with bcrypt password hashing
- Access URL: `/admin/login`
- Default credentials (development): admin@example.com / password123
**Admin Features**:
1. **Dashboard** (`/admin/dashboard`): Real-time statistics and recent activity
2. **API Keys Management** (`/admin/api_keys`): Create, view, and revoke API keys
3. **SMS Logs** (`/admin/logs`): Monitor messages with advanced filtering
4. **Gateway Management** (`/admin/gateways`): View and manage gateway devices
**Admin Controllers**:
- `Admin::BaseController`: Base controller with authentication
- `Admin::SessionsController`: Login/logout
- `Admin::DashboardController`: Dashboard with stats
- `Admin::ApiKeysController`: API key CRUD operations
- `Admin::LogsController`: SMS logs with filtering
- `Admin::GatewaysController`: Gateway management
See [ADMIN_INTERFACE.md](ADMIN_INTERFACE.md) for complete documentation.
### Authentication & Security
**API Key Types**:
1. **Gateway Keys** (`gw_live_...`): For Android gateway devices
2. **Client Keys** (`api_live_...`): For application APIs
3. **Admin Access**: Session-based web authentication
**Authentication Flow**:
- API keys passed in `Authorization: Bearer <key>` header
- Keys are hashed with SHA256 before storage
- Admin passwords hashed with bcrypt
- Concerns: `ApiAuthenticatable`, `RateLimitable`
**Rate Limiting**:
- Implemented via Redis caching
- OTP: Max 3 per phone per hour
- SMS Send: 100 per minute per API key
- Customizable per endpoint
### Key Files
**Models**: `app/models/{gateway,sms_message,otp_code,webhook_config,api_key}.rb`
**Controllers**: `app/controllers/api/v1/**/*_controller.rb`
**Jobs**: `app/jobs/{send_sms_job,process_inbound_sms_job,retry_failed_sms_job,trigger_webhook_job,check_gateway_health_job,cleanup_expired_otps_job,reset_daily_counters_job}.rb`
**Channels**: `app/channels/{gateway_channel,application_cable/connection}.rb`
**Concerns**: `app/controllers/concerns/{api_authenticatable,rate_limitable}.rb`, `app/models/concerns/metrics.rb`
**Config**:
- `config/routes.rb`: API routes
- `config/cable.yml`: Action Cable (Redis)
- `config/sidekiq_cron.yml`: Scheduled jobs
- `config/initializers/{cors,sidekiq,phonelib,pagy}.rb`
### Common Development Tasks
**Generate new API key**:
```ruby
# In Rails console
result = ApiKey.generate!(name: "My App", permissions: { send_sms: true, receive_sms: true })
puts result[:raw_key] # Save this immediately!
```
**Register gateway manually**:
```ruby
# In Rails console
gateway = Gateway.new(device_id: "my-device-001", name: "My Gateway")
api_key = gateway.generate_api_key!
puts api_key # Save this immediately!
```
**Check gateway status**:
```ruby
# In Rails console
Gateway.online.each { |g| puts "#{g.name}: #{g.status}" }
```
**View pending messages**:
```ruby
# In Rails console
SmsMessage.pending.count
SmsMessage.failed.each { |msg| puts "#{msg.message_id}: #{msg.error_message}" }
```
**Test WebSocket connection**:
```bash
# Use wscat or similar WebSocket client
wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here"
```
### Important Notes
- **Redis is required** for Action Cable, Sidekiq, and caching to work
- All phone numbers are validated and normalized using Phonelib
- Gateway devices must send heartbeats every 2 minutes or they'll be marked offline
- Outbound SMS messages are queued and sent asynchronously via Sidekiq
- Failed messages retry automatically up to 3 times with exponential backoff
- OTP codes expire after 5 minutes and allow max 3 verification attempts
- All API keys are SHA256 hashed - raw keys are only shown once during creation

72
Dockerfile Normal file
View File

@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t my_smsa_pio .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name my_smsa_pio my_smsa_pio
# 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=3.4.7
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
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 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

355
FIXES_APPLIED.md Normal file
View File

@@ -0,0 +1,355 @@
# All Fixes Applied - MySMSAPio Admin Interface
## Issues Resolved ✅
### 1. ✅ Namespace Conflict: "Admin is not a module"
**Problem:** Model class `Admin` conflicted with `Admin` module namespace
**Solution Applied:**
- Renamed model: `Admin``AdminUser`
- Updated table: `admins``admin_users`
- Updated all controllers and seeds
- Migration: `20251020031401_rename_admins_to_admin_users.rb`
**Files Changed:**
- `app/models/admin_user.rb` (renamed from admin.rb)
- `app/controllers/admin/base_controller.rb`
- `app/controllers/admin/sessions_controller.rb`
- `db/seeds.rb`
- `test/models/admin_user_test.rb`
- `test/fixtures/admin_users.yml`
---
### 2. ✅ Session & Flash Error: "undefined method 'flash'"
**Problem:** Application in API-only mode disabled sessions and flash
**Solution Applied:**
- Disabled `config.api_only` mode in `config/application.rb`
- Added `config/initializers/session_store.rb`
- API controllers still use `ActionController::API` (fast)
- Admin controllers use `ActionController::Base` (full features)
**Files Changed:**
- `config/application.rb` - Commented out `api_only = true`
- `config/initializers/session_store.rb` - New file
- `app/controllers/admin/base_controller.rb` - Added CSRF protection
- `app/controllers/admin/sessions_controller.rb` - Added CSRF protection
**Middleware Added:**
```
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
```
---
### 3. ✅ Helper Method Error: "undefined method 'logged_in?'"
**Problem:** Helper methods not accessible in layout before controller runs
**Solution Applied:**
- Added helper methods to `ApplicationHelper`
- Methods: `current_admin`, `logged_in?`
- Also created `AdminHelper` for admin-specific helpers
**Files Changed:**
- `app/helpers/application_helper.rb` - Added auth helper methods
- `app/helpers/admin_helper.rb` - New file
**Helper Methods Added:**
```ruby
def current_admin
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
end
def logged_in?
current_admin.present?
end
```
---
## Current Application State
### Architecture
```
MySMSAPio (Hybrid Rails App)
├── API Endpoints (ActionController::API)
│ ├── Fast, stateless, token-based auth
│ ├── /api/v1/sms/*
│ ├── /api/v1/otp/*
│ └── /api/v1/gateway/*
└── Admin Interface (ActionController::Base)
├── Full Rails features, session-based auth
├── /admin/login
├── /admin/dashboard
├── /admin/api_keys
├── /admin/logs
└── /admin/gateways
```
### Database Schema
```ruby
create_table "admin_users" do |t|
t.string :email, null: false, index: {unique: true}
t.string :password_digest, null: false
t.string :name, null: false
t.datetime :last_login_at
t.timestamps
end
```
### Authentication Flow
**Admin Interface:**
1. User visits `/admin/login`
2. Enters email/password
3. `AdminUser.authenticate` verifies credentials
4. Session stored with `session[:admin_id]`
5. Flash messages show success/error
6. CSRF token validates all forms
**API Endpoints:**
1. Client sends request with `Authorization: Bearer api_key`
2. `ApiAuthenticatable` concern validates token
3. No session created
4. Fast, stateless response
### Configuration Files
**Key Configuration:**
- `config/application.rb` - API-only mode disabled
- `config/initializers/session_store.rb` - Session configuration
- `config/routes.rb` - Admin routes under `/admin` namespace
**Controllers:**
- `app/controllers/application_controller.rb` - Base for API (ActionController::API)
- `app/controllers/admin/base_controller.rb` - Base for Admin (ActionController::Base)
- All admin controllers inherit from `Admin::BaseController`
**Helpers:**
- `app/helpers/application_helper.rb` - Global helpers including auth
- `app/helpers/admin_helper.rb` - Admin-specific helpers
---
## How to Start
### 1. Ensure Database is Migrated
```bash
bin/rails db:migrate
bin/rails db:seed
```
### 2. Start the Server
```bash
# Option A: With Tailwind CSS watch (Recommended)
bin/dev
# Option B: Rails server only
bin/rails server
```
### 3. Access Admin Interface
```
URL: http://localhost:3000/admin/login
Email: admin@example.com
Password: password123
```
---
## Verification Steps
### Check Database
```bash
bin/rails runner "puts 'AdminUsers: ' + AdminUser.count.to_s"
# Should output: AdminUsers: 1
```
### Check Routes
```bash
bin/rails routes | grep admin | head -5
# Should show admin routes
```
### Check Middleware
```bash
bin/rails middleware | grep -E "Session|Flash|Cookies"
# Should show:
# use ActionDispatch::Cookies
# use ActionDispatch::Session::CookieStore
# use ActionDispatch::Flash
```
### Check Models
```bash
bin/rails runner "puts AdminUser.first.email"
# Should output: admin@example.com
```
---
## Features Working
### ✅ Admin Dashboard
- Real-time statistics (gateways, API keys, messages)
- Recent messages table with status badges
- Gateway status with pulse animations
- Responsive grid layout
### ✅ API Keys Management
- List all API keys with permissions
- Create new keys with checkboxes
- One-time key display with copy button
- Revoke keys with confirmation
- Status indicators (active/revoked/expired)
### ✅ SMS Logs
- Paginated message list (50 per page)
- Advanced filters (direction, status, phone, gateway, dates)
- Click to expand error messages
- Color-coded status badges
- Retry count indicators
### ✅ Gateway Management
- List all gateway devices
- Animated online/offline indicators
- Message statistics (today and total)
- Activate/deactivate controls
- Detailed gateway view with stats cards
### ✅ Authentication & Security
- Session-based login
- Bcrypt password hashing
- CSRF protection on all forms
- Flash messages for user feedback
- Automatic session expiration
- "Remember me" capability
### ✅ Professional UI
- Tailwind CSS v4
- Dark sidebar with gradient
- Responsive design (mobile/tablet/desktop)
- Font Awesome icons
- Smooth transitions
- Hover effects
- Status pulse animations
---
## API Endpoints (Unaffected)
All API endpoints work exactly as before:
```bash
# Send SMS
POST /api/v1/sms/send
Authorization: Bearer api_live_xxx
# Get SMS status
GET /api/v1/sms/status/:message_id
Authorization: Bearer api_live_xxx
# Gateway registration
POST /api/v1/gateway/register
# And more...
```
---
## Security Considerations
### Production Checklist
- [ ] Change default admin password
- [ ] Enable HTTPS (`config.force_ssl = true`)
- [ ] Set secure session cookies
- [ ] Configure CORS properly
- [ ] Set strong SECRET_KEY_BASE
- [ ] Enable rate limiting
- [ ] Monitor admin access logs
- [ ] Regular security audits
### Current Security Features
✅ Bcrypt password hashing (cost: 12)
✅ CSRF protection enabled
✅ SQL injection protection (ActiveRecord)
✅ XSS protection (ERB escaping)
✅ Session hijacking protection (encrypted cookies)
✅ Mass assignment protection (strong parameters)
---
## Documentation
- 📖 `README.md` - Project overview
- 📖 `CLAUDE.md` - Development guidelines
- 📖 `ADMIN_INTERFACE.md` - Complete admin documentation
- 📖 `ADMIN_QUICKSTART.md` - Quick reference
- 📖 `STARTUP_GUIDE.md` - Detailed startup instructions
- 📖 `NAMESPACE_FIX.md` - Namespace conflict explanation
- 📖 `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration
- 📖 `FIXES_APPLIED.md` - This file
---
## Troubleshooting
### Server Won't Start
```bash
# Check for syntax errors
bin/rails runner "puts 'OK'"
# Check logs
tail -f log/development.log
```
### Login Not Working
```bash
# Verify admin exists
bin/rails runner "puts AdminUser.first.inspect"
# Check session middleware
bin/rails middleware | grep Session
```
### Layout Not Loading
```bash
# Rebuild assets
bin/rails assets:precompile
bin/rails tailwindcss:build
```
### API Endpoints Broken
**They shouldn't be!** API endpoints use different controllers. If you see issues:
```bash
# Check API routes
bin/rails routes | grep api/v1
# Test API endpoint
curl -v http://localhost:3000/api/v1/admin/gateways \
-H "Authorization: Bearer api_live_xxx"
```
---
## Summary
🎉 **All issues resolved!**
The MySMSAPio application now has a fully functional admin interface with:
- ✅ Professional Tailwind CSS design
- ✅ Session-based authentication
- ✅ Flash message support
- ✅ No namespace conflicts
- ✅ Proper helper method availability
- ✅ API endpoints unaffected and working
- ✅ Production-ready security features
**Ready to use! Start the server with `bin/dev` and visit http://localhost:3000/admin/login**

378
GATEWAY_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,378 @@
# Gateway Management - Admin Interface
## Overview
The admin interface now includes complete gateway management functionality, allowing administrators to register new Android SMS gateway devices and manage existing ones.
## Features Added
### 1. Register New Gateway
**URL**: `/admin/gateways/new`
**Form Fields**:
- **Device ID** (required): Unique identifier for the gateway device
- **Gateway Name** (required): Friendly name for identification
- **Priority** (1-10): Priority level for message routing (higher priority = used first)
**Process**:
1. Admin fills out the registration form
2. System creates gateway record with status "offline"
3. System generates unique API key (format: `gw_live_...`)
4. Redirects to gateway details page showing the API key
### 2. Gateway Creation Success Page
After creating a gateway, the admin sees:
**Key Information Displayed**:
- ✅ Warning banner: "Save this API key now - you won't see it again!"
- ✅ Gateway API key in copy-to-clipboard format
- ✅ Gateway details (Device ID, Name, Priority, Status)
- ✅ Next steps instructions for configuring the Android app
**Copy to Clipboard**:
- Click button to copy API key
- Visual feedback (button turns green, shows "Copied!")
- API key displayed in terminal-style format (dark background, green text)
### 3. Gateway List Page
**URL**: `/admin/gateways`
**Features**:
- "Register New Gateway" button (top right)
- Table showing all gateways with:
- Name (clickable link to details)
- Device ID
- Status (Online/Offline with animated pulse for online)
- Active/Inactive status
- Priority level
- Today's message counts
- Total message counts
- Last heartbeat timestamp
- Created date
- Actions (View Details, Toggle Active)
### 4. Gateway Details Page (Existing Gateway)
**URL**: `/admin/gateways/:id`
**For existing gateways** (not newly created):
- Complete statistics dashboard
- Connection status indicators
- Message statistics (sent/received today and total)
- Recent messages table
- Device metadata (if available)
- Activate/Deactivate button
---
## API Key Security
### Key Generation
```ruby
# Format: gw_live_ + 64 character hex string
raw_key = "gw_live_#{SecureRandom.hex(32)}"
api_key_digest = Digest::SHA256.hexdigest(raw_key)
```
**Security Features**:
- Raw key shown only once during creation
- Stored as SHA256 hash in database
- Keys cannot be retrieved after creation
- Unique per gateway
### Session-Based Key Display
Similar to API key creation, gateway keys use session storage:
```ruby
# On create:
session[:new_gateway_id] = gateway.id
session[:new_gateway_raw_key] = raw_key
# On show:
if session[:new_gateway_id] == @gateway.id && session[:new_gateway_raw_key].present?
@raw_key = session[:new_gateway_raw_key]
@is_new = true
# Clear session immediately
session.delete(:new_gateway_id)
session.delete(:new_gateway_raw_key)
end
```
**Benefits**:
- Prevents accidental key exposure via URL
- One-time display only
- Secure redirect pattern
- No key in browser history
---
## Routes Added
```ruby
resources :gateways, only: [:index, :new, :create, :show] do
member do
post :toggle
end
end
```
**Available Routes**:
- `GET /admin/gateways` - List all gateways
- `GET /admin/gateways/new` - New gateway form
- `POST /admin/gateways` - Create gateway
- `GET /admin/gateways/:id` - View gateway details (or show new key)
- `POST /admin/gateways/:id/toggle` - Activate/deactivate gateway
---
## Controller Actions
### `new`
Renders the registration form with empty gateway object.
### `create`
```ruby
def create
@gateway = Gateway.new(
device_id: params[:gateway][:device_id],
name: params[:gateway][:name],
priority: params[:gateway][:priority] || 1,
status: "offline"
)
raw_key = @gateway.generate_api_key!
# Store in session
session[:new_gateway_id] = @gateway.id
session[:new_gateway_raw_key] = raw_key
redirect_to admin_gateway_path(@gateway)
end
```
**Error Handling**:
- Validates device_id uniqueness
- Validates required fields
- Shows error messages on form
- Logs errors for debugging
### `show`
Conditional rendering based on `@is_new`:
**If newly created**:
- Shows API key with copy button
- Shows basic gateway info
- Shows next steps instructions
- No statistics (gateway not connected yet)
**If existing gateway**:
- Shows full statistics dashboard
- Shows recent messages
- Shows device metadata
- Shows activate/deactivate controls
---
## Gateway Model
### Required Fields
```ruby
validates :device_id, presence: true, uniqueness: true
validates :api_key_digest, presence: true
validates :status, inclusion: { in: %w[online offline error] }
```
### Default Values
- `status`: "offline"
- `priority`: 1
- `active`: true
- `metadata`: {} (empty JSONB hash)
- Message counters: 0
### Methods
**`generate_api_key!`**:
```ruby
def generate_api_key!
raw_key = "gw_live_#{SecureRandom.hex(32)}"
self.api_key_digest = Digest::SHA256.hexdigest(raw_key)
save!
raw_key
end
```
**`online?`**:
Checks if gateway is truly online based on heartbeat:
```ruby
def online?
status == "online" && last_heartbeat_at.present? && last_heartbeat_at > 2.minutes.ago
end
```
---
## View Files Created/Modified
### New Files
- `app/views/admin/gateways/new.html.erb` - Registration form
### Modified Files
- `app/views/admin/gateways/index.html.erb` - Added "Register New Gateway" button
- `app/views/admin/gateways/show.html.erb` - Added conditional for new gateway display
---
## Usage Flow
### Registering a New Gateway
1. **Navigate to Gateways**
- Click "Gateways" in sidebar
- See list of existing gateways (if any)
2. **Start Registration**
- Click "Register New Gateway" button (top right)
- Form appears with 3 fields
3. **Fill Form**
- Device ID: `phone-samsung-001`
- Gateway Name: `Office Android Phone`
- Priority: `5` (1-10 scale)
4. **Submit**
- Click "Register Gateway" button
- System creates gateway and generates API key
5. **Save API Key**
- Warning shown: "Save this key now!"
- API key displayed: `gw_live_a6e2b250dade...`
- Click "Copy to Clipboard" button
- Key copied to clipboard
6. **Configure Android App**
- Follow "Next Steps" instructions
- Install Android SMS Gateway app
- Paste API key in app settings
- Start gateway service
7. **Verify Connection**
- Gateway appears "Offline" initially
- Once Android app connects, status changes to "Online"
- View gateway details to see statistics
---
## Next Steps Instructions (Shown on Success Page)
1. Copy the API key above using the "Copy to Clipboard" button
2. Install the Android SMS Gateway app on your device
3. Open the app and navigate to Settings
4. Paste the API key in the app's configuration
5. Save the configuration and start the gateway service
6. The gateway will appear as "Online" once it connects successfully
---
## Testing
### Console Test
```bash
bin/rails console
```
```ruby
# Create gateway
gateway = Gateway.new(
device_id: "test-001",
name: "Test Gateway",
priority: 3,
status: "offline"
)
# Generate API key
raw_key = gateway.generate_api_key!
puts "API Key: #{raw_key}"
# Verify
gateway.reload
puts "Gateway ID: #{gateway.id}"
puts "Device ID: #{gateway.device_id}"
puts "Key Digest: #{gateway.api_key_digest}"
```
### Browser Test
1. Start server: `bin/dev`
2. Visit: `http://localhost:3000/admin/login`
3. Login with admin credentials
4. Click "Gateways" in sidebar
5. Click "Register New Gateway"
6. Fill form and submit
7. Verify API key is displayed
8. Verify copy to clipboard works
9. Click "Back to Gateways"
10. Verify new gateway appears in list
---
## Integration with Android App
The gateway API key is used by the Android app to:
1. **Authenticate WebSocket Connection**
- Connect to: `ws://your-server.com/cable?api_key=gw_live_...`
- Connection class authenticates via `api_key_digest`
2. **Send Heartbeats**
- POST `/api/v1/gateway/heartbeat`
- Header: `Authorization: Bearer gw_live_...`
- Keeps gateway status "online"
3. **Report Received SMS**
- POST `/api/v1/gateway/sms/received`
- Header: `Authorization: Bearer gw_live_...`
- Sends inbound SMS to system
4. **Report Delivery Status**
- POST `/api/v1/gateway/sms/status`
- Header: `Authorization: Bearer gw_live_...`
- Updates message delivery status
---
## Security Considerations
### Production Checklist
- ✅ API keys are SHA256 hashed before storage
- ✅ Raw keys shown only once during creation
- ✅ Keys transmitted via session (not URL)
- ✅ Device ID uniqueness enforced
- ✅ CSRF protection enabled on all forms
- ⚠️ Ensure HTTPS in production (`config.force_ssl = true`)
- ⚠️ Secure WebSocket connections (wss://)
- ⚠️ Implement rate limiting on registration endpoint
### Recommended Practices
- Regularly audit gateway list
- Deactivate unused gateways
- Monitor heartbeat timestamps
- Alert on prolonged offline status
- Rotate keys if device is compromised
- Keep device firmware updated
---
## Summary
**Complete Gateway Management**: Register, view, and manage SMS gateway devices
**Secure API Key Generation**: One-time display with copy-to-clipboard
**Professional UI**: Tailwind CSS styling with clear instructions
**Session-Based Security**: Keys not exposed in URLs or browser history
**Integration Ready**: Works with Android SMS Gateway app
**Admin Controls**: Activate/deactivate, view statistics, monitor status
The admin interface now provides everything needed to manage SMS gateway devices from registration to monitoring!

614
GATEWAY_TESTING.md Normal file
View File

@@ -0,0 +1,614 @@
# Gateway Testing via Admin Interface
## Overview
The admin interface now includes a comprehensive gateway testing module that allows you to:
- Check gateway connection status in real-time
- Send test SMS messages through specific gateways
- Verify gateway functionality without external tools
- Debug connection issues
## Features
### 1. Connection Status Check
**Real-time Gateway Status**:
- ✅ Online/Offline detection
- ⏰ Last heartbeat timestamp
- 🕐 Time since last connection
- 🔄 One-click refresh
**How It Works**:
- Checks if gateway sent heartbeat within last 2 minutes
- Displays exact last heartbeat time
- Shows human-readable "time ago" format
- Updates with AJAX (no page reload)
### 2. Send Test SMS
**Test Message Features**:
- 📱 Phone number validation
- ✉️ Custom message composition
- 📊 Character counter (160 char SMS limit)
- 📝 Multi-part SMS detection
- ✅ Success/failure feedback
- 🔍 Message ID tracking
**Message Tracking**:
- Test messages marked with `metadata: { test: true }`
- Identifies sender as "admin_interface"
- Full message history in logs
- Same queue as regular messages
## Access Points
### From Gateway List
**URL**: `/admin/gateways`
Each gateway has a **Test** button in the Actions column:
- Blue button with flask icon
- Located next to Activate/Deactivate button
- Available for all gateways (online or offline)
### From Gateway Details
**URL**: `/admin/gateways/:id`
**Test Gateway** button at the bottom:
- Blue button with flask icon
- Located above Activate/Deactivate button
- Opens dedicated testing page
### Direct Testing Page
**URL**: `/admin/gateways/:id/test`
Full testing interface with:
- Connection status card
- Gateway information
- Test SMS form
- Real-time updates
## Testing Interface
### Page Layout
```
┌─────────────────────────────────────────┐
│ Test Gateway: [Gateway Name] │
│ [Back to Gateway Details] │
├─────────────────────────────────────────┤
│ Gateway Status │
│ ┌───────────────────────┐ │
│ │ ✅ Gateway is Online │ [Refresh] │
│ │ Last heartbeat: 30s │ │
│ └───────────────────────┘ │
├─────────────────────────────────────────┤
│ Connection Information │
│ Device ID: android-001 │
│ Name: Office Phone │
│ Priority: 5 │
│ Active: Yes │
├─────────────────────────────────────────┤
│ Send Test SMS │
│ Phone Number: [+959123456789____] │
│ Message: [This is a test___________] │
│ [________________________] │
│ 160 characters remaining │
│ │
│ [Send Test SMS] [Reset Form] │
└─────────────────────────────────────────┘
```
### Status Indicators
**Online (Green)**:
```
┌──────────────────────────────┐
│ ✅ Gateway is Online │
│ Last heartbeat: 1 minute ago │
│ 2025-10-20 13:45:30 │
└──────────────────────────────┘
```
**Offline (Red)**:
```
┌──────────────────────────────┐
│ ❌ Gateway is Offline │
│ Gateway is offline │
│ Last seen: 5 hours ago │
└──────────────────────────────┘
```
**Never Connected (Red)**:
```
┌──────────────────────────────┐
│ ❌ Gateway is Offline │
│ Gateway is offline │
│ Never connected │
└──────────────────────────────┘
```
## Using the Test Feature
### Step 1: Access Testing Page
**Option A**: From Gateway List
1. Navigate to `/admin/gateways`
2. Find the gateway you want to test
3. Click the blue **Test** button
**Option B**: From Gateway Details
1. Navigate to `/admin/gateways/:id`
2. Scroll to bottom
3. Click **Test Gateway** button
### Step 2: Check Connection Status
The page loads with automatic status check:
1. **Wait for status**: Shows loading spinner
2. **View result**: Green (online) or Red (offline)
3. **Refresh if needed**: Click **Refresh Status** button
**Connection Check Details**:
- Verifies `last_heartbeat_at` timestamp
- Must be within 2 minutes to be "online"
- Shows exact time of last heartbeat
- Displays human-readable time ago
### Step 3: Send Test SMS
1. **Enter phone number**:
- Include country code (e.g., `+959123456789`)
- Required field
- Validated on submission
2. **Enter message**:
- Default test message provided
- Customizable content
- Character counter updates live
- Warns if over 160 chars
3. **Click "Send Test SMS"**:
- Button shows spinner: "Sending..."
- Waits for response
- Displays result
4. **View result**:
**Success (Green)**:
```
┌──────────────────────────────────────┐
│ ✅ Test SMS Sent Successfully! │
│ Test SMS queued for sending │
│ Message ID: msg_abc123... │
│ Status: pending │
└──────────────────────────────────────┘
```
**Error (Red)**:
```
┌──────────────────────────────────────┐
│ ❌ Failed to Send Test SMS │
│ Phone number is not valid │
└──────────────────────────────────────┘
```
### Step 4: Verify in Logs
1. Navigate to `/admin/logs`
2. Look for test message:
- Message ID from success response
- Phone number you entered
- Status: pending → sent → delivered
3. Filter by gateway to see only this gateway's messages
## API Endpoints
### Check Connection
**Endpoint**: `POST /admin/gateways/:id/check_connection`
**Response (Online)**:
```json
{
"status": "success",
"message": "Gateway is online",
"last_heartbeat": "2025-10-20T13:45:30.000Z",
"time_ago": "1 minute"
}
```
**Response (Offline)**:
```json
{
"status": "error",
"message": "Gateway is offline",
"last_heartbeat": "2025-10-20T08:30:15.000Z",
"time_ago": "5 hours"
}
```
### Send Test SMS
**Endpoint**: `POST /admin/gateways/:id/send_test_sms`
**Request**:
```json
{
"phone_number": "+959123456789",
"message_body": "This is a test message"
}
```
**Response (Success)**:
```json
{
"status": "success",
"message": "Test SMS queued for sending",
"message_id": "msg_abc123def456...",
"sms_status": "pending"
}
```
**Response (Error)**:
```json
{
"status": "error",
"message": "Phone number and message are required"
}
```
## Routes Added
```ruby
resources :gateways do
member do
get :test # Testing page
post :check_connection # AJAX status check
post :send_test_sms # AJAX send test
post :toggle # Activate/deactivate (existing)
end
end
```
**New Routes**:
- `GET /admin/gateways/:id/test` - Testing page
- `POST /admin/gateways/:id/check_connection` - Check status
- `POST /admin/gateways/:id/send_test_sms` - Send test SMS
## Controller Actions
### `test`
```ruby
def test
@gateway = Gateway.find(params[:id])
end
```
Renders the testing page.
### `check_connection`
```ruby
def check_connection
@gateway = Gateway.find(params[:id])
if @gateway.online?
render json: {
status: "success",
message: "Gateway is online",
last_heartbeat: @gateway.last_heartbeat_at,
time_ago: helpers.time_ago_in_words(@gateway.last_heartbeat_at)
}
else
render json: {
status: "error",
message: "Gateway is offline",
last_heartbeat: @gateway.last_heartbeat_at,
time_ago: @gateway.last_heartbeat_at ? helpers.time_ago_in_words(@gateway.last_heartbeat_at) : "never"
}
end
end
```
### `send_test_sms`
```ruby
def send_test_sms
@gateway = Gateway.find(params[:id])
phone_number = params[:phone_number]
message_body = params[:message_body]
sms = SmsMessage.create!(
direction: "outbound",
phone_number: phone_number,
message_body: message_body,
gateway: @gateway,
metadata: { test: true, sent_from: "admin_interface" }
)
render json: {
status: "success",
message: "Test SMS queued for sending",
message_id: sms.message_id,
sms_status: sms.status
}
end
```
## JavaScript Features
### Auto-load Status
```javascript
document.addEventListener('DOMContentLoaded', function() {
checkConnection(); // Check on page load
});
```
### Refresh Button
```javascript
async function checkConnection() {
// Show loading
container.innerHTML = '<div class="spinner">...</div>';
// Fetch status
const response = await fetch('/admin/gateways/:id/check_connection', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
}
});
const data = await response.json();
// Display result
}
```
### Character Counter
```javascript
messageBody.addEventListener('input', updateCharCount);
function updateCharCount() {
const length = messageBody.value.length;
const remaining = 160 - length;
if (remaining < 0) {
const parts = Math.ceil(length / 160);
charCount.textContent = `${Math.abs(remaining)} characters over (${parts} parts)`;
charCount.classList.add('text-red-600');
} else {
charCount.textContent = `${remaining} characters remaining`;
}
}
```
### Form Submission
```javascript
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Disable button
submitButton.disabled = true;
submitText.innerHTML = 'Sending...';
// Send request
const response = await fetch('/admin/gateways/:id/send_test_sms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
phone_number: phoneNumber.value,
message_body: messageBody.value
})
});
const data = await response.json();
// Display result and re-enable button
});
```
## Test Message Metadata
All test messages include metadata for identification:
```ruby
{
test: true,
sent_from: "admin_interface"
}
```
**Benefits**:
- Easy to filter test messages in logs
- Distinguish from production messages
- Audit trail of admin testing
- Can be excluded from analytics
## Troubleshooting
### Connection Check Fails
**Symptom**: Can't check gateway status
**Causes**:
1. Database connection issue
2. Gateway record not found
3. JavaScript error
**Solutions**:
```bash
# Check Rails logs
tail -f log/development.log
# Verify gateway exists
bin/rails runner "puts Gateway.find(1).inspect"
# Check browser console for JavaScript errors
```
### Test SMS Not Sending
**Symptom**: SMS queued but never sent
**Causes**:
1. Gateway offline
2. Sidekiq not running
3. Redis not running
4. Queue backed up
**Solutions**:
```bash
# Check gateway status
bin/rails console
> Gateway.find(1).online?
# Check Sidekiq
ps aux | grep sidekiq
# Start Sidekiq if needed
bundle exec sidekiq
# Check Redis
redis-cli ping
```
### Invalid Phone Number
**Symptom**: "Phone number is not valid" error
**Causes**:
1. Missing country code
2. Invalid format
3. Phonelib validation failed
**Solutions**:
- Always include country code: `+959123456789`
- Check number format for your country
- Test number in console:
```ruby
Phonelib.parse("+959123456789").valid?
```
## Security Considerations
### Admin Authentication Required
- All testing endpoints require admin login
- CSRF protection enabled
- Session validation
- No public access
### Rate Limiting (Recommended)
Consider adding rate limiting:
```ruby
# config/initializers/rack_attack.rb
Rack::Attack.throttle('test_sms_per_admin', limit: 10, period: 1.hour) do |req|
if req.path == '/admin/gateways/*/send_test_sms' && req.post?
req.session[:admin_id]
end
end
```
### Test Message Limits
**Best Practices**:
- Limit test messages to prevent abuse
- Log all test SMS sends
- Monitor for unusual patterns
- Alert on excessive testing
### Phone Number Privacy
**Considerations**:
- Test messages go to real phone numbers
- Recipients will receive actual SMS
- Use dedicated test numbers
- Don't test with customer numbers
## Best Practices
### When to Use Testing
**Good Use Cases**:
- After gateway registration (verify it works)
- After configuration changes
- Diagnosing offline issues
- Verifying app updates
- Training new staff
**Avoid**:
- Testing with production phone numbers
- Excessive testing (generates costs)
- Testing offline gateways repeatedly
- Using for regular message sending
### Test Message Guidelines
**Recommended Content**:
```
This is a test message from MySMSAPio admin interface.
Gateway: [Gateway Name]
Date: [Date/Time]
Ignore this message.
```
**Avoid**:
- Long messages (keep under 160 chars)
- Multiple consecutive tests
- Testing during peak hours
- Sensitive information in tests
## Monitoring Test Messages
### View in Logs
1. Navigate to `/admin/logs`
2. Filter by:
- Gateway name
- Phone number
- Date range
3. Look for status progression:
- `pending``sent``delivered`
4. Check error messages if failed
### Identify Test Messages
Test messages have:
- `metadata.test = true`
- `metadata.sent_from = "admin_interface"`
**Query in console**:
```ruby
# Find all test messages
SmsMessage.where("metadata->>'test' = 'true'").count
# Find recent test messages
SmsMessage.where("metadata->>'test' = 'true'")
.where("created_at > ?", 1.day.ago)
.order(created_at: :desc)
```
## Summary
**Implemented**: Gateway testing via admin interface
**Features**: Connection check + Test SMS sending
**Access**: From gateway list or details page
**Real-time**: AJAX status updates
**Tracking**: Full metadata and logging
**Security**: Admin authentication required
**Test any gateway easily**:
1. Click "Test" button
2. Check status (auto-loads)
3. Send test SMS
4. View in logs
Perfect for debugging, verification, and training! 🚀

77
Gemfile Normal file
View File

@@ -0,0 +1,77 @@
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.0.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# 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"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
# SMS Gateway API Dependencies
gem "redis", "~> 5.0"
gem "sidekiq", "~> 7.0"
gem "sidekiq-cron", "~> 1.12"
gem "jwt", "~> 2.7"
gem "rack-cors"
gem "phonelib"
gem "rotp"
gem "httparty"
gem "pagy", "~> 6.0"
gem "rqrcode", "~> 2.0"
# 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
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# 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 "tailwindcss-rails", "~> 4.3"

447
Gemfile.lock Normal file
View File

@@ -0,0 +1,447 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
mail (>= 2.8.0)
actionmailer (8.0.3)
actionpack (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activesupport (= 8.0.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.3)
actionview (= 8.0.3)
activesupport (= 8.0.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.3)
actionpack (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.3)
activesupport (= 8.0.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.3)
activesupport (= 8.0.3)
globalid (>= 0.3.6)
activemodel (8.0.3)
activesupport (= 8.0.3)
activerecord (8.0.3)
activemodel (= 8.0.3)
activesupport (= 8.0.3)
timeout (>= 0.4.0)
activestorage (8.0.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activesupport (= 8.0.3)
marcel (~> 1.0)
activesupport (8.0.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.1)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.0)
racc
builder (3.3.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)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crass (1.0.6)
csv (3.3.5)
date (3.4.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
drb (2.2.3)
ed25519 (1.4.0)
erb (5.1.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
httparty (0.23.2)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.1)
irb (1.15.2)
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.15.1)
jwt (2.10.2)
base64
kamal (2.7.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 3.1)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (5.26.0)
msgpack (1.8.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
net-imap (0.5.12)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
pagy (6.5.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
racc
pg (1.6.2)
pg (1.6.2-aarch64-linux)
pg (1.6.2-aarch64-linux-musl)
pg (1.6.2-arm64-darwin)
pg (1.6.2-x86_64-linux)
pg (1.6.2-x86_64-linux-musl)
phonelib (0.10.12)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.3)
actioncable (= 8.0.3)
actionmailbox (= 8.0.3)
actionmailer (= 8.0.3)
actionpack (= 8.0.3)
actiontext (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activemodel (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
bundler (>= 1.15.0)
railties (= 8.0.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.15.0)
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.26.1)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
io-console (~> 0.5)
rexml (3.4.4)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (3.2.0)
securerandom (0.4.1)
selenium-webdriver (4.37.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.8)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.1)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
railties (>= 7.1)
thor (>= 1.3.1)
sshkit (1.24.0)
base64
logger
net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
tailwindcss-rails (4.3.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.13)
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
tailwindcss-ruby (4.1.13-arm64-darwin)
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
thor (1.4.0)
thruster (0.1.15)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-linux)
timeout (0.4.3)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
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.3)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin-24
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
brakeman
capybara
debug
httparty
importmap-rails
jbuilder
jwt (~> 2.7)
kamal
pagy (~> 6.0)
pg (~> 1.1)
phonelib
propshaft
puma (>= 5.0)
rack-cors
rails (~> 8.0.3)
redis (~> 5.0)
rotp
rqrcode (~> 2.0)
rubocop-rails-omakase
selenium-webdriver
sidekiq (~> 7.0)
sidekiq-cron (~> 1.12)
solid_cable
solid_cache
solid_queue
stimulus-rails
tailwindcss-rails (~> 4.3)
thruster
turbo-rails
tzinfo-data
web-console
BUNDLED WITH
2.6.9

326
JSONB_FIXES.md Normal file
View File

@@ -0,0 +1,326 @@
# JSONB Field Fixes - Complete Solution
## Issue
```
undefined method 'stringify_keys' for an instance of String
```
This error occurred when rendering views that use Rails helpers like `button_to`, `link_to`, or `form_with` that serialize model objects containing JSONB fields.
## Root Cause
PostgreSQL JSONB columns can sometimes return inconsistent data types:
- Sometimes returns a Hash (correct)
- Sometimes returns a String (incorrect - causes stringify_keys error)
- Sometimes returns nil (causes nil errors)
This happens when Rails tries to serialize model objects for URL generation or form helpers.
## Solution Applied
Added explicit JSONB attribute handling to ALL models with JSONB fields to ensure they always return Hash objects.
---
## Models Fixed
### 1. ApiKey Model - `permissions` field
**File**: `app/models/api_key.rb`
**JSONB Field**: `permissions` (stores API key permissions like send_sms, receive_sms, etc.)
**Fix Applied**:
```ruby
class ApiKey < ApplicationRecord
# Normalize permissions to always be a Hash
attribute :permissions, :jsonb, default: {}
before_validation :ensure_permissions_is_hash
# ... rest of the model ...
private
def ensure_permissions_is_hash
self.permissions = {} if permissions.nil?
self.permissions = {} unless permissions.is_a?(Hash)
end
end
```
---
### 2. Gateway Model - `metadata` field
**File**: `app/models/gateway.rb`
**JSONB Field**: `metadata` (stores additional gateway configuration and metadata)
**Fix Applied**:
```ruby
class Gateway < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
```
---
### 3. OtpCode Model - `metadata` field
**File**: `app/models/otp_code.rb`
**JSONB Field**: `metadata` (stores OTP-related metadata like device info, IP address context, etc.)
**Fix Applied**:
```ruby
class OtpCode < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
```
---
### 4. SmsMessage Model - `metadata` field
**File**: `app/models/sms_message.rb`
**JSONB Field**: `metadata` (stores message metadata like delivery reports, carrier info, etc.)
**Fix Applied**:
```ruby
class SmsMessage < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
before_validation :ensure_metadata_is_hash
# ... rest of the model ...
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end
```
---
## Database Schema Reference
All JSONB columns are properly defined in the schema with default values:
```ruby
# db/schema.rb
create_table "api_keys" do |t|
# ...
t.jsonb "permissions", default: {}
# ...
end
create_table "gateways" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
create_table "otp_codes" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
create_table "sms_messages" do |t|
# ...
t.jsonb "metadata", default: {}
# ...
end
```
---
## How The Fix Works
### 1. Explicit Attribute Declaration
```ruby
attribute :permissions, :jsonb, default: {}
```
This tells ActiveRecord to explicitly treat the column as JSONB and always default to an empty Hash.
### 2. Before Validation Callback
```ruby
before_validation :ensure_permissions_is_hash
```
Runs before every validation, ensuring the field is always a Hash before Rails processes it.
### 3. Hash Normalization Method
```ruby
def ensure_permissions_is_hash
self.permissions = {} if permissions.nil?
self.permissions = {} unless permissions.is_a?(Hash)
end
```
- Converts nil to {}
- Converts any non-Hash value to {}
- Leaves Hash values unchanged
---
## Why This Prevents stringify_keys Error
When Rails helpers like `button_to` serialize model objects:
**Before Fix**:
```ruby
api_key.permissions # Sometimes returns String "{"send_sms": true}"
# Rails tries to call .stringify_keys on String
# ERROR: undefined method 'stringify_keys' for String
```
**After Fix**:
```ruby
api_key.permissions # Always returns Hash {"send_sms" => true}
# Rails successfully calls .stringify_keys on Hash
# SUCCESS: No error
```
---
## View Safety Measures
In addition to model fixes, views also have defensive coding:
**app/views/admin/api_keys/index.html.erb**:
```erb
<% perms = api_key.permissions || {} %>
<% perms = {} unless perms.is_a?(Hash) %>
<% if perms.any? %>
<% perms.select { |_, v| v }.keys.each do |perm| %>
<span><%= perm.to_s.humanize %></span>
<% end %>
<% else %>
<span>None</span>
<% end %>
```
This provides double protection:
1. Model ensures JSONB field is always Hash
2. View verifies and provides fallback
---
## Testing The Fix
### Console Test
```bash
bin/rails console
```
```ruby
# Test ApiKey permissions
api_key = ApiKey.first
api_key.permissions.class # => Hash
api_key.can?("send_sms") # => true or false
# Test Gateway metadata
gateway = Gateway.first
gateway.metadata.class # => Hash
gateway.metadata["foo"] = "bar"
gateway.save! # Should work without errors
# Test OtpCode metadata
otp = OtpCode.first
otp.metadata.class # => Hash
# Test SmsMessage metadata
msg = SmsMessage.first
msg.metadata.class # => Hash
```
### Server Test
```bash
# Start server
bin/dev
# Visit admin interface
# http://localhost:3000/admin/login
# Login and navigate to API Keys
# Should load without stringify_keys error
```
---
## Benefits of This Approach
**Consistent**: All JSONB fields behave the same way across all models
**Safe**: Handles nil, String, and other edge cases gracefully
**Performance**: Minimal overhead (callback only runs on save/update)
**Rails-native**: Uses Rails attribute API, not monkey-patching
**Future-proof**: Works with all Rails helpers and serializers
**Maintainable**: Clear, documented pattern that's easy to understand
---
## Adding JSONB Fields in the Future
When adding new JSONB fields to any model, follow this pattern:
```ruby
class MyModel < ApplicationRecord
# 1. Declare the attribute
attribute :my_jsonb_field, :jsonb, default: {}
# 2. Add before_validation callback
before_validation :ensure_my_jsonb_field_is_hash
# ... rest of model code ...
private
# 3. Add normalization method
def ensure_my_jsonb_field_is_hash
self.my_jsonb_field = {} if my_jsonb_field.nil?
self.my_jsonb_field = {} unless my_jsonb_field.is_a?(Hash)
end
end
```
---
## Summary
**Fixed Models**: ApiKey, Gateway, OtpCode, SmsMessage
**Fixed Fields**: permissions (ApiKey), metadata (Gateway, OtpCode, SmsMessage)
**Error Resolved**: stringify_keys error when using button_to, form_with, etc.
**Approach**: Explicit attribute declaration + before_validation normalization
**Safety**: Double protection (model + view defensive coding)
All JSONB fields now consistently return Hash objects, preventing serialization errors throughout the Rails application!

87
NAMESPACE_FIX.md Normal file
View File

@@ -0,0 +1,87 @@
# Namespace Conflict Fix
## Issue
There was a naming conflict between the `Admin` model class and the `Admin` module namespace used by the admin controllers.
```
TypeError (Admin is not a module
/app/models/admin.rb:1: previous definition of Admin was here)
```
## Solution
Renamed the model from `Admin` to `AdminUser` to avoid the namespace conflict.
## Changes Made
### 1. Database Migration
- Created migration: `20251020031401_rename_admins_to_admin_users.rb`
- Renamed table: `admins``admin_users`
### 2. Model Renamed
- File: `app/models/admin.rb``app/models/admin_user.rb`
- Class: `Admin``AdminUser`
### 3. Controllers Updated
- `app/controllers/admin/base_controller.rb`: Updated `current_admin` method
- `app/controllers/admin/sessions_controller.rb`: Updated authentication logic
### 4. Seeds Updated
- `db/seeds.rb`: Changed `Admin` to `AdminUser`
### 5. Tests Updated
- File: `test/models/admin_test.rb``test/models/admin_user_test.rb`
- Class: `AdminTest``AdminUserTest`
- Fixtures: `test/fixtures/admins.yml``test/fixtures/admin_users.yml`
## No View Changes Required
The views remain unchanged because they use helper methods (`current_admin`, `logged_in?`) that abstract away the model name.
## Migration Command
```bash
bin/rails db:migrate
```
## Database Structure
```ruby
# Before
create_table "admins" do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.string :name, null: false
t.datetime :last_login_at
t.timestamps
end
# After
create_table "admin_users" do |t|
t.string :email, null: false
t.string :password_digest, null: false
t.string :name, null: false
t.datetime :last_login_at
t.timestamps
end
```
## Login Credentials
Unchanged - still:
```
Email: admin@example.com
Password: password123
```
## API Usage
No changes to the admin interface or API endpoints. Everything works the same from a user perspective.
## Technical Notes
- Rails namespaces (modules) take precedence over class names
- Having `module Admin` and `class Admin` in the same project causes conflicts
- The solution is to rename either the module or the class
- We chose to rename the model since it's less invasive than renaming all controllers
## Verification
Run this to verify everything works:
```bash
bin/rails runner "puts AdminUser.first.email"
```
Should output: `admin@example.com`

183
PERMISSIONS_FIX.md Normal file
View File

@@ -0,0 +1,183 @@
# Permissions Field Fix
## Issue
```
undefined method 'stringify_keys' for an instance of String
```
This error occurred in the API Keys index view when trying to display permissions.
## Root Cause
The `permissions` field in the `api_keys` table is a `jsonb` column (PostgreSQL native JSON type). Rails handles this automatically, but the code was:
1. Not properly handling the `permissions` attribute
2. Not providing safe fallbacks for nil or invalid data
## Solution Applied
### 1. Updated ApiKey Model
**File:** `app/models/api_key.rb`
Added a safe `permissions` method that:
- Returns empty hash if permissions is nil
- Returns the value if it's already a Hash
- Parses JSON if it's a String
- Returns empty hash on any error
```ruby
# Ensure permissions is always a hash
def permissions
value = read_attribute(:permissions)
return {} if value.nil?
return value if value.is_a?(Hash)
return JSON.parse(value) if value.is_a?(String)
{}
rescue JSON::ParserError
{}
end
```
### 2. Updated Views
**Files:**
- `app/views/admin/api_keys/index.html.erb`
- `app/views/admin/api_keys/show.html.erb`
Added defensive code to handle edge cases:
```erb
<% perms = api_key.permissions || {} %>
<% perms = {} unless perms.is_a?(Hash) %>
<% if perms.any? %>
<% perms.select { |_, v| v }.keys.each do |perm| %>
<span><%= perm.to_s.humanize %></span>
<% end %>
<% else %>
<span>None</span>
<% end %>
```
## Why This Works
### PostgreSQL JSONB Support
- Rails 5+ has native support for PostgreSQL JSONB columns
- No need for `serialize` declaration
- Data is stored and retrieved as Hash automatically
- But we need to handle edge cases
### Safe Accessor Method
- The custom `permissions` method ensures we always get a Hash
- Handles legacy data or corrupted entries
- Provides sensible defaults
### View Defensive Coding
- Checks for nil before using
- Verifies it's a Hash
- Gracefully degrades to "None" if empty
## Database Schema
The permissions column is properly defined:
```ruby
create_table "api_keys" do |t|
# ...
t.jsonb "permissions", default: {}
# ...
end
```
**Key points:**
- Type: `jsonb` (not `json` or `text`)
- Default: `{}` (empty hash)
- No serialization needed
## Verification
Test that permissions work correctly:
```bash
bin/rails runner "
api_key = ApiKey.first
puts 'Permissions class: ' + api_key.permissions.class.to_s
puts 'Permissions value: ' + api_key.permissions.inspect
puts 'Can check permission: ' + api_key.can?('send_sms').to_s
"
```
Should output:
```
Permissions class: Hash
Permissions value: {"send_sms"=>true, "receive_sms"=>true, ...}
Can check permission: true
```
## Related Code
### Creating API Keys
The create action already passes a hash:
```ruby
permissions = {}
permissions["send_sms"] = params[:api_key][:send_sms] == "1"
permissions["receive_sms"] = params[:api_key][:receive_sms] == "1"
permissions["manage_gateways"] = params[:api_key][:manage_gateways] == "1"
ApiKey.generate!(
name: params[:api_key][:name],
permissions: permissions,
expires_at: expires_at
)
```
### Checking Permissions
The `can?` method works with our safe accessor:
```ruby
def can?(permission)
permissions.fetch(permission.to_s, false)
end
```
Usage:
```ruby
api_key.can?("send_sms") # => true or false
```
## Testing
### Manual Test
1. Start server: `bin/dev`
2. Visit: http://localhost:3000/admin/api_keys
3. Should see permissions displayed as badges
4. Create new API key
5. Check permissions are saved and displayed
### Console Test
```ruby
# In rails console
api_key = ApiKey.first
# Should return Hash
api_key.permissions
# => {"send_sms"=>true, "receive_sms"=>true}
# Should work
api_key.can?("send_sms")
# => true
# Should handle missing permissions
api_key.can?("nonexistent")
# => false
```
## Summary
**Fixed**: Permissions now always return a Hash
**Fixed**: Views handle nil/invalid permissions gracefully
**Improved**: Added defensive coding throughout
**Maintained**: PostgreSQL native JSONB support
**Tested**: All API keys work correctly
The admin interface can now safely display API keys and their permissions without errors!

2
Procfile.dev Normal file
View File

@@ -0,0 +1,2 @@
web: bin/rails server
css: bin/rails tailwindcss:watch

449
QR_CODE_SETUP.md Normal file
View File

@@ -0,0 +1,449 @@
# QR Code Gateway Setup
## Overview
The gateway registration now includes QR code generation for quick and easy Android app configuration. When a new gateway is created, the system automatically generates a QR code containing all necessary configuration data.
## What's Included in the QR Code
The QR code contains a JSON payload with:
```json
{
"api_key": "gw_live_a6e2b250dade8f6501256a8717723fc3f8ab7d4e7cb26aad470d65ee8478a82c",
"api_base_url": "http://localhost:3000",
"websocket_url": "ws://localhost:3000/cable",
"version": "1.0"
}
```
### Fields Explained
| Field | Description | Example |
|-------|-------------|---------|
| `api_key` | Gateway authentication key | `gw_live_...` (64 chars) |
| `api_base_url` | Base URL for API endpoints | `http://localhost:3000` or `https://api.example.com` |
| `websocket_url` | WebSocket connection URL | `ws://localhost:3000/cable` or `wss://api.example.com/cable` |
| `version` | Configuration format version | `1.0` |
## Features
### 1. QR Code Display
**Location**: Gateway creation success page (`/admin/gateways/:id` after creation)
**Visual Features**:
- High-quality SVG QR code
- Error correction level: High (L=H)
- White background with border
- Centered display
- Info badge explaining contents
### 2. Manual Configuration Fallback
If QR scanning is unavailable, the page also displays:
- API Base URL (with individual copy button)
- WebSocket URL (with individual copy button)
- API Key (with individual copy button)
- "Copy All" button to copy all three fields at once
### 3. Copy to Clipboard Functions
**Individual Field Copy**:
```javascript
copyField('api-base-url') // Copies just the API base URL
copyField('ws-url') // Copies just the WebSocket URL
copyField('api-key') // Copies just the API key
```
**Copy All Configuration**:
```javascript
copyAllConfig() // Copies all fields as formatted text
```
Output format:
```
API Base URL: http://localhost:3000
WebSocket URL: ws://localhost:3000/cable
API Key: gw_live_a6e2b250dade8f6501256a8717723fc3f8ab7d4e7cb26aad470d65ee8478a82c
```
## Implementation Details
### Gem Used
**rqrcode** v2.0+
- Pure Ruby QR code generator
- No external dependencies
- SVG output support
- High error correction
Added to `Gemfile`:
```ruby
gem "rqrcode", "~> 2.0"
```
### Controller Method
**File**: `app/controllers/admin/gateways_controller.rb`
```ruby
def generate_qr_code_data(api_key)
require "rqrcode"
# Determine URLs based on request
base_url = request.base_url
ws_url = request.base_url.sub(/^http/, "ws") + "/cable"
# Create JSON configuration
config_data = {
api_key: api_key,
api_base_url: base_url,
websocket_url: ws_url,
version: "1.0"
}.to_json
# Generate QR code with high error correction
qr = RQRCode::QRCode.new(config_data, level: :h)
# Return as SVG string
qr.as_svg(
offset: 0,
color: "000",
shape_rendering: "crispEdges",
module_size: 4,
standalone: true,
use_path: true
)
end
```
### View Integration
**File**: `app/views/admin/gateways/show.html.erb`
The QR code is displayed using:
```erb
<div class="bg-white p-4 rounded-lg shadow-inner border-2 border-gray-200">
<%= @qr_code_data.html_safe %>
</div>
```
## URL Detection
The system automatically detects the correct URLs based on the request:
### Development
- Base URL: `http://localhost:3000`
- WebSocket URL: `ws://localhost:3000/cable`
### Production (HTTP)
- Base URL: `http://api.example.com`
- WebSocket URL: `ws://api.example.com/cable`
### Production (HTTPS) - Recommended
- Base URL: `https://api.example.com`
- WebSocket URL: `wss://api.example.com/cable`
**Note**: WebSocket URL automatically changes from `http` to `ws` and `https` to `wss`.
## Android App Integration
### QR Code Scanning Flow
1. **User opens Android SMS Gateway app**
2. **Taps "Scan QR Code" or similar option**
3. **Camera opens with QR scanner**
4. **Scans the QR code from admin interface**
5. **App parses JSON configuration**
6. **All fields auto-populated**:
- API Base URL field
- WebSocket URL field
- API Key field
7. **User taps "Save" or "Connect"**
8. **App connects to server**
9. **Gateway appears as "Online" in admin interface**
### Expected Android App Code
The Android app should:
1. **Scan QR Code**:
```kotlin
// Using ML Kit or ZXing library
val result = qrCodeScanner.scan()
val jsonString = result.text
```
2. **Parse JSON**:
```kotlin
val config = JSONObject(jsonString)
val apiKey = config.getString("api_key")
val apiBaseUrl = config.getString("api_base_url")
val websocketUrl = config.getString("websocket_url")
val version = config.getString("version")
```
3. **Validate Version**:
```kotlin
if (version != "1.0") {
showError("Unsupported configuration version")
return
}
```
4. **Save Configuration**:
```kotlin
sharedPreferences.edit().apply {
putString("api_key", apiKey)
putString("api_base_url", apiBaseUrl)
putString("websocket_url", websocketUrl)
apply()
}
```
5. **Connect to Server**:
```kotlin
// Connect to WebSocket
webSocketClient.connect(websocketUrl, apiKey)
// Test API connection
apiClient.setBaseUrl(apiBaseUrl)
apiClient.setAuthToken(apiKey)
apiClient.sendHeartbeat()
```
## Security Considerations
### QR Code Security
**Secure**:
- QR code displayed only once after creation
- Requires admin authentication to view
- Session-based display (not in URL)
- Page cannot be refreshed to see QR code again
⚠️ **Warning**:
- Anyone with camera access to the screen can scan the QR code
- QR code contains full API key in plaintext JSON
- Suitable for secure environments only
### Best Practices
1. **Display Environment**:
- Only display QR code in secure locations
- Ensure no cameras/recording devices nearby
- Clear screen after scanning
2. **Network Security**:
- Use HTTPS/WSS in production (`config.force_ssl = true`)
- Never use HTTP/WS in production
- Implement rate limiting on WebSocket connections
3. **Key Management**:
- QR code shown only once during gateway creation
- If compromised, deactivate gateway and create new one
- Regularly audit active gateways
4. **Mobile App Security**:
- Store configuration in encrypted SharedPreferences
- Use Android Keystore for API key storage
- Implement certificate pinning for API calls
- Validate SSL certificates for WebSocket connections
## Troubleshooting
### QR Code Not Displaying
**Check**:
1. `rqrcode` gem installed: `bundle show rqrcode`
2. Controller generates QR code: Check `@qr_code_data` in view
3. Browser console for JavaScript errors
4. View source - SVG should be present
**Fix**:
```bash
bundle install
bin/rails restart
```
### QR Code Too Complex to Scan
**Symptom**: QR scanner can't read the code
**Cause**: JSON payload too long (rare, but possible with very long URLs)
**Solution**:
- Use shorter domain names
- Reduce module_size in controller (current: 4)
- Lower error correction level (current: :h, try :m or :l)
### Wrong URLs in QR Code
**Symptom**: QR code contains `localhost` in production
**Cause**: `request.base_url` not detecting correctly
**Fix**: Set environment variables in production
```bash
# .env or config
RAILS_FORCE_SSL=true
RAILS_RELATIVE_URL_ROOT=https://api.example.com
```
Or override in controller:
```ruby
base_url = ENV['API_BASE_URL'] || request.base_url
```
### Android App Can't Parse QR Code
**Symptom**: App shows "Invalid QR code" error
**Causes**:
1. QR code scanner library issue
2. JSON parsing error
3. Wrong configuration version
**Debug**:
```kotlin
try {
val json = JSONObject(qrCodeText)
Log.d("QR", "API Key: ${json.getString("api_key")}")
Log.d("QR", "Base URL: ${json.getString("api_base_url")}")
Log.d("QR", "WS URL: ${json.getString("websocket_url")}")
} catch (e: Exception) {
Log.e("QR", "Parse error: ${e.message}")
}
```
## Testing
### Manual QR Code Test
1. **Create Test Gateway**:
```bash
bin/rails console
```
```ruby
gateway = Gateway.new(
device_id: "test-qr-001",
name: "QR Test Gateway",
priority: 1,
status: "offline"
)
raw_key = gateway.generate_api_key!
puts "Gateway ID: #{gateway.id}"
puts "Raw Key: #{raw_key}"
```
2. **Navigate to Success Page**:
- Visit: `http://localhost:3000/admin/gateways/new`
- Fill form and submit
- Should redirect to gateway show page with QR code
3. **Test QR Code**:
- Open QR code scanner app on phone
- Scan the displayed QR code
- Verify JSON payload contains all fields
4. **Test Copy Buttons**:
- Click individual copy buttons (API URL, WS URL, API Key)
- Verify green checkmark feedback
- Click "Copy All" button
- Paste in text editor - verify format
### Automated Test
```ruby
# test/controllers/admin/gateways_controller_test.rb
test "should generate QR code on gateway creation" do
post admin_gateways_url, params: {
gateway: {
device_id: "test-001",
name: "Test Gateway",
priority: 5
}
}
assert_response :redirect
gateway = Gateway.last
get admin_gateway_url(gateway)
assert_response :success
assert_select 'svg' # QR code should be present as SVG
end
```
## Configuration Examples
### Local Development
```json
{
"api_key": "gw_live_abc123...",
"api_base_url": "http://localhost:3000",
"websocket_url": "ws://localhost:3000/cable",
"version": "1.0"
}
```
### Staging Environment
```json
{
"api_key": "gw_live_def456...",
"api_base_url": "https://staging-api.example.com",
"websocket_url": "wss://staging-api.example.com/cable",
"version": "1.0"
}
```
### Production Environment
```json
{
"api_key": "gw_live_ghi789...",
"api_base_url": "https://api.example.com",
"websocket_url": "wss://api.example.com/cable",
"version": "1.0"
}
```
## Future Enhancements
### Possible Improvements
1. **Download QR Code**:
- Add "Download QR Code" button
- Generate PNG image
- Allow saving for later
2. **Email QR Code**:
- Send QR code via email
- Secure time-limited link
- Auto-expires after 1 hour
3. **Multiple QR Code Formats**:
- Different sizes (small, medium, large)
- Different error correction levels
- PNG, SVG, PDF options
4. **Configuration Presets**:
- Save common configurations
- Apply preset to multiple gateways
- Template system
5. **Advanced Security**:
- Encrypted QR code payload
- Time-limited configuration URLs
- Two-factor gateway activation
## Summary
**Implemented**: QR code generation with all gateway configuration
**Features**: Scan QR code OR manual copy/paste
**Security**: One-time display, session-based, admin-only
**UX**: Copy buttons with visual feedback, clear instructions
**Production Ready**: Automatic URL detection (HTTP/HTTPS/WS/WSS)
The QR code feature makes gateway setup much faster and reduces configuration errors by eliminating manual typing of long API keys!

137
QUICKSTART.md Normal file
View File

@@ -0,0 +1,137 @@
# Quick Start Guide - SMS Gateway API
This guide will help you get the SMS Gateway API up and running quickly.
## Prerequisites
Ensure you have the following installed:
- Ruby 3.4.7
- PostgreSQL 14+
- Redis 7+
- Bundler 2.x
## Step-by-Step Setup
### 1. Install Dependencies
```bash
bundle install
```
### 2. Set Up Database
```bash
# Create databases
bin/rails db:create
# Run migrations
bin/rails db:migrate
# Seed sample data (includes API keys and test gateways)
bin/rails db:seed
```
**⚠️ IMPORTANT**: After seeding, you'll see API keys printed. **Save these immediately** as they won't be shown again!
### 3. Start Redis
If Redis isn't running:
```bash
redis-server
```
### 4. Start Sidekiq (Background Jobs)
In a separate terminal:
```bash
bundle exec sidekiq
```
### 5. Start Rails Server
```bash
bin/rails server
```
The API will be available at `http://localhost:3000`
## Quick Test
### Test the Health Check
```bash
curl http://localhost:3000/up
```
Should return 200 OK.
### Send a Test SMS
Replace `YOUR_API_KEY` with the API key from the seed output:
```bash
curl -X POST http://localhost:3000/api/v1/sms/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+1234567890",
"message": "Test message from SMS Gateway API"
}'
```
### Check System Stats
```bash
curl http://localhost:3000/api/v1/admin/stats \
-H "Authorization: Bearer YOUR_API_KEY"
```
## Next Steps
1. **Read the full API documentation** in `README.md`
2. **Set up an Android gateway device** using the gateway API key from seeds
3. **Configure webhooks** for real-time SMS notifications
4. **Integrate OTP functionality** into your application
## Common Issues
### Redis Connection Error
Make sure Redis is running:
```bash
redis-cli ping
```
Should return `PONG`.
### Database Connection Error
Check your PostgreSQL is running and accessible:
```bash
psql -d my_smsa_pio_development
```
### Sidekiq Not Processing Jobs
Ensure Sidekiq is running:
```bash
bundle exec sidekiq
```
## Development Workflow
1. **Console**: `bin/rails console`
2. **Routes**: `bin/rails routes | grep api`
3. **Logs**: `tail -f log/development.log`
4. **Sidekiq Web UI**: Add to routes and visit `/sidekiq`
## Getting Help
- Check `README.md` for complete API documentation
- Check `CLAUDE.md` for development guidance
- Review logs in `log/development.log` and `log/sidekiq.log`
---
Happy coding! 🚀

730
README.md Normal file
View File

@@ -0,0 +1,730 @@
# SMS Gateway API
A Ruby on Rails REST API and WebSocket server for managing SMS messaging through Android gateway devices. This system allows you to send and receive SMS messages programmatically, manage OTP codes, and integrate SMS capabilities into your applications.
## Features
- **Gateway Management**: Register and manage multiple Android SMS gateway devices
- **Inbound SMS**: Receive and process incoming SMS messages
- **Outbound SMS**: Send SMS messages through connected gateway devices
- **OTP Management**: Generate, send, and verify one-time passwords
- **WebSocket Communication**: Real-time bidirectional communication with gateway devices
- **Webhook Support**: Trigger webhooks for SMS events
- **Rate Limiting**: Protect API endpoints from abuse
- **Load Balancing**: Automatically distribute messages across multiple gateways
- **Auto Failover**: Retry failed messages and handle gateway offline scenarios
## Tech Stack
- **Ruby**: 3.4.7
- **Rails**: 8.0.3
- **Database**: PostgreSQL 14+
- **Cache/Queue**: Redis 7+
- **Background Jobs**: Sidekiq 7
- **WebSocket**: Action Cable (Redis adapter)
- **API Authentication**: JWT + API Keys
## Prerequisites
- Ruby 3.4.7
- PostgreSQL 14+
- Redis 7+
- Bundler 2.x
## Installation
### 1. Clone the repository
```bash
git clone <repository-url>
cd MySMSAPio
```
### 2. Install dependencies
```bash
bundle install
```
### 3. Set up environment variables
Create a `.env` file in the root directory:
```env
# Database
DATABASE_URL=postgresql://localhost/my_smsa_pio_development
# Redis
REDIS_URL=redis://localhost:6379/1
# CORS
ALLOWED_ORIGINS=*
# Phone validation
DEFAULT_COUNTRY_CODE=US
# Rails
SECRET_KEY_BASE=your_secret_key_here
RAILS_ENV=development
```
### 4. Create and set up the database
```bash
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed
```
**Important**: Save the API keys displayed after seeding! They won't be shown again.
### 5. Start Redis (if not running)
```bash
redis-server
```
### 6. Start Sidekiq (background jobs)
```bash
bundle exec sidekiq
```
### 7. Start the Rails server
```bash
bin/rails server
```
The API will be available at `http://localhost:3000`
## API Documentation
### Base URL
```
Development: http://localhost:3000
Production: https://your-domain.com
```
### Authentication
All API endpoints (except gateway registration) require an API key in the Authorization header:
```
Authorization: Bearer your_api_key_here
```
There are two types of API keys:
- **Gateway Keys**: Start with `gw_live_` - used by Android gateway devices
- **Client Keys**: Start with `api_live_` - used by your applications
---
## Gateway Device APIs
### Register a New Gateway
Register an Android device as an SMS gateway.
**Endpoint**: `POST /api/v1/gateway/register`
**Request**:
```json
{
"device_id": "unique-device-identifier",
"name": "My Gateway Phone"
}
```
**Response** (201 Created):
```json
{
"success": true,
"api_key": "gw_live_abc123...",
"device_id": "unique-device-identifier",
"websocket_url": "ws://localhost:3000/cable"
}
```
⚠️ **Important**: Save the `api_key` immediately. It will only be shown once!
### Send Heartbeat
Keep the gateway connection alive and update status.
**Endpoint**: `POST /api/v1/gateway/heartbeat`
**Headers**: `Authorization: Bearer gw_live_...`
**Request**:
```json
{
"status": "online",
"messages_in_queue": 5,
"battery_level": 85,
"signal_strength": 4
}
```
**Response** (200 OK):
```json
{
"success": true,
"pending_messages": 2
}
```
### Report Received SMS
Submit an SMS message received by the gateway device.
**Endpoint**: `POST /api/v1/gateway/sms/received`
**Headers**: `Authorization: Bearer gw_live_...`
**Request**:
```json
{
"sender": "+1234567890",
"message": "Hello, this is a test message",
"timestamp": "2025-10-19T10:30:00Z"
}
```
**Response** (200 OK):
```json
{
"success": true,
"message_id": "msg_abc123..."
}
```
### Report SMS Delivery Status
Update the delivery status of an outbound SMS.
**Endpoint**: `POST /api/v1/gateway/sms/status`
**Headers**: `Authorization: Bearer gw_live_...`
**Request**:
```json
{
"message_id": "msg_abc123",
"status": "delivered",
"error_message": null
}
```
Status values: `delivered`, `failed`, `sent`
**Response** (200 OK):
```json
{
"success": true
}
```
---
## Client Application APIs
### Send SMS
Send an SMS message through the gateway.
**Endpoint**: `POST /api/v1/sms/send`
**Headers**: `Authorization: Bearer api_live_...`
**Request**:
```json
{
"to": "+1234567890",
"message": "Your verification code is: 123456"
}
```
**Response** (202 Accepted):
```json
{
"success": true,
"message_id": "msg_xyz789",
"status": "queued"
}
```
### Check SMS Status
Check the delivery status of a sent message.
**Endpoint**: `GET /api/v1/sms/status/:message_id`
**Headers**: `Authorization: Bearer api_live_...`
**Response** (200 OK):
```json
{
"message_id": "msg_xyz789",
"status": "delivered",
"sent_at": "2025-10-19T10:30:00Z",
"delivered_at": "2025-10-19T10:30:05Z",
"failed_at": null,
"error_message": null
}
```
### Get Received SMS
Retrieve inbound SMS messages.
**Endpoint**: `GET /api/v1/sms/received`
**Headers**: `Authorization: Bearer api_live_...`
**Query Parameters**:
- `phone_number` (optional): Filter by phone number
- `since` (optional): ISO 8601 timestamp to filter messages after this time
- `limit` (optional): Number of messages per page (default: 50, max: 100)
**Response** (200 OK):
```json
{
"messages": [
{
"message_id": "msg_abc",
"from": "+1234567890",
"message": "Reply message content",
"received_at": "2025-10-19T10:30:00Z"
}
],
"total": 25,
"page": 1,
"pages": 1
}
```
---
## OTP APIs
### Send OTP
Generate and send a one-time password.
**Endpoint**: `POST /api/v1/otp/send`
**Headers**: `Authorization: Bearer api_live_...`
**Request**:
```json
{
"phone_number": "+1234567890",
"purpose": "authentication",
"expiry_minutes": 5
}
```
**Response** (200 OK):
```json
{
"success": true,
"expires_at": "2025-10-19T10:35:00Z",
"message_id": "msg_otp123"
}
```
**Rate Limits**: Maximum 3 OTP codes per phone number per hour.
### Verify OTP
Verify an OTP code.
**Endpoint**: `POST /api/v1/otp/verify`
**Headers**: `Authorization: Bearer api_live_...`
**Request**:
```json
{
"phone_number": "+1234567890",
"code": "123456"
}
```
**Response** (200 OK) - Success:
```json
{
"success": true,
"verified": true
}
```
**Response** (200 OK) - Failed:
```json
{
"success": false,
"verified": false,
"error": "Invalid or expired OTP",
"attempts_remaining": 2
}
```
---
## Admin APIs
### List Gateways
Get all registered gateway devices.
**Endpoint**: `GET /api/v1/admin/gateways`
**Headers**: `Authorization: Bearer api_live_...`
**Response** (200 OK):
```json
{
"gateways": [
{
"id": 1,
"device_id": "test-gateway-001",
"name": "Test Gateway 1",
"status": "online",
"last_heartbeat_at": "2025-10-19T10:30:00Z",
"messages_sent_today": 145,
"messages_received_today": 23,
"total_messages_sent": 1543,
"total_messages_received": 892,
"active": true,
"priority": 1,
"metadata": {},
"created_at": "2025-10-19T08:00:00Z"
}
]
}
```
### Toggle Gateway Status
Enable or disable a gateway.
**Endpoint**: `POST /api/v1/admin/gateways/:id/toggle`
**Headers**: `Authorization: Bearer api_live_...`
**Response** (200 OK):
```json
{
"success": true,
"gateway": {
"id": 1,
"device_id": "test-gateway-001",
"active": false
}
}
```
### Get System Statistics
Get overall system statistics.
**Endpoint**: `GET /api/v1/admin/stats`
**Headers**: `Authorization: Bearer api_live_...`
**Response** (200 OK):
```json
{
"gateways": {
"total": 2,
"active": 2,
"online": 1,
"offline": 1
},
"messages": {
"total_sent": 5432,
"total_received": 892,
"sent_today": 168,
"received_today": 23,
"total_today": 191,
"pending": 3,
"failed_today": 2
},
"otp": {
"sent_today": 45,
"verified_today": 42,
"verification_rate": 93.33
},
"timestamp": "2025-10-19T10:30:00Z"
}
```
---
## WebSocket Connection (Gateway Devices)
Gateway devices connect via WebSocket for real-time bidirectional communication.
### Connection URL
```
ws://localhost:3000/cable?api_key=gw_live_your_key_here
```
### Subscribe to Gateway Channel
```javascript
{
"command": "subscribe",
"identifier": "{\"channel\":\"GatewayChannel\",\"api_key_digest\":\"sha256_hash_of_api_key\"}"
}
```
### Messages from Server
**Send SMS Command**:
```json
{
"action": "send_sms",
"message_id": "msg_123",
"recipient": "+1234567890",
"message": "Your OTP is: 123456"
}
```
### Messages to Server
**Heartbeat**:
```json
{
"action": "heartbeat",
"battery_level": 85,
"signal_strength": 4,
"messages_in_queue": 0
}
```
**Delivery Report**:
```json
{
"action": "delivery_report",
"message_id": "msg_123",
"status": "delivered"
}
```
**Message Received**:
```json
{
"action": "message_received",
"sender": "+1234567890",
"message": "Hello",
"timestamp": "2025-10-19T10:30:00Z"
}
```
---
## Background Jobs
The system uses Sidekiq for background processing:
### Scheduled Jobs
- **CheckGatewayHealthJob**: Runs every minute to mark offline gateways
- **CleanupExpiredOtpsJob**: Runs every 15 minutes to delete expired OTP codes
- **ResetDailyCountersJob**: Runs daily at midnight to reset message counters
### Processing Jobs
- **SendSmsJob**: Handles outbound SMS delivery
- **ProcessInboundSmsJob**: Processes received SMS and triggers webhooks
- **RetryFailedSmsJob**: Retries failed messages with exponential backoff
- **TriggerWebhookJob**: Executes webhook HTTP calls
---
## Webhooks
Configure webhooks to receive real-time notifications for SMS events.
### Webhook Events
- `sms_received`: Triggered when an inbound SMS is received
- `sms_sent`: Triggered when an outbound SMS is sent
- `sms_failed`: Triggered when an SMS fails to send
### Webhook Payload Example
```json
{
"event": "sms_received",
"message_id": "msg_xyz",
"from": "+1234567890",
"message": "Hello",
"received_at": "2025-10-19T10:30:00Z",
"gateway_id": "test-gateway-001"
}
```
### Webhook Signature
If a `secret_key` is configured, webhooks include an HMAC-SHA256 signature in the `X-Webhook-Signature` header for verification.
---
## Error Responses
All errors follow this format:
```json
{
"error": "Error message here"
}
```
Common HTTP status codes:
- `400 Bad Request`: Invalid request parameters
- `401 Unauthorized`: Missing or invalid API key
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `422 Unprocessable Entity`: Validation errors
- `429 Too Many Requests`: Rate limit exceeded
- `500 Internal Server Error`: Server error
---
## Development
### Running Tests
```bash
bin/rails test
```
### Code Quality
Check code style:
```bash
bin/rubocop
```
Security scan:
```bash
bin/brakeman
```
### Console Access
```bash
bin/rails console
```
### Database Console
```bash
bin/rails dbconsole
```
---
## Deployment
### Using Kamal
This project is configured for deployment with Kamal.
```bash
# Deploy to production
bin/kamal deploy
# View logs
bin/kamal logs
# Access console
bin/kamal console
```
### Environment Variables (Production)
Required environment variables:
```env
DATABASE_URL=postgresql://user:pass@host/database
REDIS_URL=redis://host:6379/1
SECRET_KEY_BASE=your_production_secret
RAILS_ENV=production
ALLOWED_ORIGINS=https://yourdomain.com
DEFAULT_COUNTRY_CODE=US
```
---
## Monitoring
### Health Check
```
GET /up
```
Returns 200 if the application is healthy.
### Sidekiq Web UI
Mount Sidekiq web interface in `config/routes.rb` (protect with authentication in production):
```ruby
require 'sidekiq/web'
mount Sidekiq::Web => '/sidekiq'
```
---
## Architecture
```
┌─────────────────┐ ┌──────────────────┐
│ │ │ │
│ Android SMS │◄───WS──►│ Rails API │
│ Gateway App │ │ Action Cable │
│ │ │ │
└─────────────────┘ └────────┬─────────┘
┌────────┴─────────┐
│ │
┌───────────────────┤ PostgreSQL │
│ │ (Messages, OTP) │
│ │ │
│ └──────────────────┘
│ ┌──────────────────┐
│ │ │
└──────────────────►│ Redis │
│ (Cache, Jobs, │
│ WebSockets) │
│ │
└────────┬─────────┘
┌────────┴─────────┐
│ │
│ Sidekiq │
│ (Background │
│ Jobs) │
│ │
└──────────────────┘
```
---
## License
MIT License
---
## Support
For issues and questions, please create an issue on GitHub.

6
Rakefile Normal file
View File

@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks

179
SESSION_MIDDLEWARE_FIX.md Normal file
View File

@@ -0,0 +1,179 @@
# Session & Flash Middleware Fix
## Issue
```
undefined method 'flash' for an instance of ActionDispatch::Request
```
This error occurred because the application was configured as **API-only mode** (`config.api_only = true`), which disables session and flash middleware by default. However, the admin interface needs these features for:
- Session-based authentication
- Flash messages (success/error notifications)
- CSRF protection
## Solution
Manually include the required middleware in the application configuration while keeping API-only mode for the API endpoints.
## Changes Made
### 1. Updated Application Configuration
**File:** `config/application.rb`
Added middleware explicitly:
```ruby
# Configure API-only mode (but keep session middleware for admin interface)
config.api_only = true
# Include session and flash middleware for admin interface
# Even though this is an API-only app, we need sessions for the admin UI
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
config.middleware.use ActionDispatch::Flash
```
### 2. Updated Admin Base Controller
**File:** `app/controllers/admin/base_controller.rb`
Added CSRF protection:
```ruby
module Admin
class BaseController < ActionController::Base
include Pagy::Backend
# Enable session and flash for admin controllers
# (needed because the app is in API-only mode)
protect_from_forgery with: :exception
layout "admin"
before_action :require_admin
# ...
end
end
```
## Why This Works
### API-Only Mode Benefits (Kept)
- ✅ Faster API responses (no session overhead for API endpoints)
- ✅ RESTful API design
- ✅ No unnecessary middleware for API calls
- ✅ Better performance for mobile/external clients
### Admin Interface Benefits (Added)
- ✅ Session-based authentication
- ✅ Flash messages for user feedback
- ✅ CSRF protection
- ✅ Cookie support for "remember me" features
- ✅ Standard Rails web app behavior
### How Both Coexist
1. **API Controllers** (`ApplicationController < ActionController::API`)
- Don't use sessions or flash
- Use token-based authentication
- Remain lightweight and fast
2. **Admin Controllers** (`Admin::BaseController < ActionController::Base`)
- Use sessions and flash
- Use cookie-based authentication
- Full Rails web app features
## Architecture
```
Rails Application (API-only mode)
├── API Controllers (ActionController::API)
│ ├── /api/v1/sms
│ ├── /api/v1/otp
│ └── /api/v1/gateway/*
│ └── Uses: Token auth, JSON responses
└── Admin Controllers (ActionController::Base)
├── /admin/login
├── /admin/dashboard
├── /admin/api_keys
├── /admin/logs
└── /admin/gateways
└── Uses: Session auth, HTML responses, flash messages
```
## Middleware Stack
Now includes (in order):
1. `ActionDispatch::Cookies` - Cookie handling
2. `ActionDispatch::Session::CookieStore` - Session storage
3. `ActionDispatch::Flash` - Flash messages
These are available to **all** controllers, but only admin controllers use them.
## Verification
Check middleware is loaded:
```bash
bin/rails middleware | grep -E "(Session|Flash|Cookies)"
```
Should output:
```
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
```
Test application:
```bash
bin/rails runner "puts 'AdminUser: ' + AdminUser.first.email"
```
## Security Considerations
### CSRF Protection
- ✅ Enabled for admin controllers via `protect_from_forgery with: :exception`
- ✅ Automatically includes CSRF token in forms
- ✅ Validates token on POST/PUT/PATCH/DELETE requests
### Session Security
- ✅ Uses encrypted cookie store
- ✅ Session expires when browser closes (default)
- ✅ Session data is signed and verified
### Cookie Settings
Default configuration uses secure cookies in production:
- HttpOnly: Yes (prevents XSS)
- Secure: Yes in production (HTTPS only)
- SameSite: Lax (prevents CSRF)
## No Impact on API
The API endpoints remain unchanged:
- ✅ No session overhead
- ✅ No CSRF checks
- ✅ Token-based authentication still works
- ✅ Same performance characteristics
## Testing
### Test Admin Login
```bash
# Start server
bin/rails server
# Visit in browser
open http://localhost:3000/admin/login
```
### Test API Endpoint
```bash
# Should work without sessions
curl -H "Authorization: Bearer your_api_key" \
http://localhost:3000/api/v1/sms/received
```
## Summary
**Fixed**: Flash messages work in admin interface
**Fixed**: Sessions work for authentication
**Kept**: API-only mode for API endpoints
**Kept**: Performance benefits of API-only
**Added**: CSRF protection for admin
**Added**: Cookie support for admin
Both the API and admin interface now work correctly side-by-side! 🎉

281
STARTUP_GUIDE.md Normal file
View File

@@ -0,0 +1,281 @@
# MySMSAPio - Startup Guide
## ⚠️ Important: Configuration Changes Made
The application configuration has been updated to support both API endpoints and the admin web interface. **You MUST restart your Rails server** for these changes to take effect.
## Quick Start
### 1. Stop Any Running Servers
If you have a Rails server running, stop it first (Ctrl+C).
### 2. Start the Server
**Option A: With Tailwind CSS watch (Recommended)**
```bash
bin/dev
```
This starts both Rails server and Tailwind CSS watcher.
**Option B: Rails server only**
```bash
bin/rails server
```
### 3. Access the Admin Interface
Open your browser to:
```
http://localhost:3000/admin/login
```
### 4. Login
```
Email: admin@example.com
Password: password123
```
⚠️ **Remember to change this password in production!**
## What Changed
### Configuration Updates
1. **Disabled API-Only Mode**
- File: `config/application.rb`
- Changed: Commented out `config.api_only = true`
- Reason: Allows admin interface to use sessions and flash messages
2. **Added Session Store**
- File: `config/initializers/session_store.rb`
- Added: Cookie-based session configuration
- Reason: Required for admin authentication
3. **CSRF Protection**
- All admin controllers have CSRF protection enabled
- Forms automatically include CSRF tokens
- API endpoints (ActionController::API) are unaffected
### Why These Changes Were Needed
**The Problem:**
- The app was configured as `api_only = true`
- This disables sessions, flash messages, and cookies
- Admin interface needs these features for authentication
**The Solution:**
- Keep API controllers using `ActionController::API` (fast, stateless)
- Admin controllers use `ActionController::Base` (full Rails features)
- Both work together in the same app
## Architecture
```
MySMSAPio Application
├── API Endpoints (Fast & Stateless)
│ ├── /api/v1/sms/*
│ ├── /api/v1/otp/*
│ └── /api/v1/gateway/*
│ └── Controllers inherit from ActionController::API
│ └── Use: Token authentication, no sessions
└── Admin Interface (Full Rails Features)
├── /admin/login
├── /admin/dashboard
├── /admin/api_keys
├── /admin/logs
└── /admin/gateways
└── Controllers inherit from ActionController::Base
└── Use: Session authentication, flash messages, cookies
```
## Troubleshooting
### Error: "undefined method 'flash'"
**Solution:** Restart the Rails server. Configuration changes require a server restart.
```bash
# Stop the server (Ctrl+C), then:
bin/dev
# or
bin/rails server
```
### Error: "Admin is not a module"
**Solution:** This was already fixed by renaming the model to `AdminUser`. If you see this:
```bash
bin/rails db:migrate
bin/rails db:seed
```
### Login Page Shows 404
**Check routes:**
```bash
bin/rails routes | grep admin
```
Should show routes like `/admin/login`, `/admin/dashboard`, etc.
### Session Not Persisting
**Check middleware:**
```bash
bin/rails middleware | grep Session
```
Should show: `use ActionDispatch::Session::CookieStore`
### API Endpoints Not Working
The API endpoints should work exactly as before. Test with:
```bash
curl -H "Authorization: Bearer your_api_key" \
http://localhost:3000/api/v1/sms/received
```
## Development Workflow
### Daily Development
```bash
# Start everything
bin/dev
# This runs:
# - Rails server (port 3000)
# - Tailwind CSS watcher (auto-rebuild on changes)
```
### Running Tests
```bash
bin/rails test
```
### Database Operations
```bash
# Reset database
bin/rails db:reset
# Run migrations
bin/rails db:migrate
# Seed data
bin/rails db:seed
```
### Code Quality
```bash
# Run RuboCop
bin/rubocop
# Run Brakeman security scanner
bin/brakeman
```
## Production Deployment
### Environment Variables
Make sure these are set:
```bash
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
SECRET_KEY_BASE=your_secret_key
RAILS_ENV=production
```
### Precompile Assets
```bash
bin/rails assets:precompile
bin/rails tailwindcss:build
```
### Change Default Password
```bash
bin/rails console
# In console:
admin = AdminUser.find_by(email: 'admin@example.com')
admin.update!(password: 'your_secure_password')
```
### Enable HTTPS
Update `config/environments/production.rb`:
```ruby
config.force_ssl = true
```
## Features Working
**Admin Dashboard**
- Real-time statistics
- Recent messages
- Gateway status
**API Keys Management**
- Create with permissions
- View all keys
- Revoke keys
**SMS Logs**
- Filter by direction, status, date
- View error messages
- Pagination
**Gateway Management**
- View all gateways
- Activate/deactivate
- Monitor health
**Authentication**
- Session-based login
- Flash messages
- CSRF protection
**Professional UI**
- Tailwind CSS theme
- Responsive design
- Animated indicators
## Need Help?
### Documentation
- `README.md` - Project overview
- `ADMIN_INTERFACE.md` - Complete admin documentation
- `ADMIN_QUICKSTART.md` - Quick reference
- `NAMESPACE_FIX.md` - Model naming fix
- `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration
### Logs
```bash
# Development log
tail -f log/development.log
# Test log
tail -f log/test.log
```
### Rails Console
```bash
bin/rails console
# Check AdminUser
AdminUser.count
AdminUser.first.email
# Check API Keys
ApiKey.count
# Check Gateways
Gateway.count
```
## Summary
1. ✅ Configuration updated to support admin interface
2. ✅ Sessions and flash messages enabled
3. ✅ API endpoints remain fast and stateless
4. ✅ Both admin and API work together
5. ⚠️ **Must restart server after configuration changes**
Start the server with `bin/dev` and access the admin at `http://localhost:3000/admin/login`!
Happy coding! 🚀

237
WEBSOCKET_FIX.md Normal file
View File

@@ -0,0 +1,237 @@
# WebSocket Subscription Rejection Fix
## Issue
**Error**: "GatewayChannel is transmitting the subscription rejection"
## Root Cause
The `GatewayChannel#subscribed` method was attempting to re-authenticate the gateway using `params[:api_key_digest]`, but this caused two problems:
1. **Double Authentication**: The gateway was already authenticated at the connection level in `ApplicationCable::Connection#connect`
2. **Missing Parameter**: The channel was expecting `api_key_digest` as a channel parameter, but it was never being passed
3. **Wrong Layer**: Authentication should happen at the connection level (before subscription), not at the channel subscription level
## Authentication Flow
### Correct Flow (After Fix)
```
1. Client connects to WebSocket
URL: ws://host/cable?api_key=gw_live_...
2. ApplicationCable::Connection#connect
- Extracts api_key from query params
- Hashes it with SHA256
- Finds gateway by api_key_digest
- Sets current_gateway
- Connection accepted ✅
3. Client subscribes to GatewayChannel
Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
4. GatewayChannel#subscribed
- Uses current_gateway (already authenticated)
- Streams to gateway-specific channel
- Updates heartbeat
- Subscription accepted ✅
```
### Previous Flow (Before Fix)
```
1. Client connects to WebSocket
URL: ws://host/cable?api_key=gw_live_...
2. ApplicationCable::Connection#connect
- Authenticates gateway
- Connection accepted ✅
3. Client subscribes to GatewayChannel
Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
4. GatewayChannel#subscribed (BROKEN)
- Looks for params[:api_key_digest] ❌ (doesn't exist)
- Gateway lookup fails ❌
- Subscription rejected ❌
```
## The Fix
### Before (Broken)
```ruby
class GatewayChannel < ApplicationCable::Channel
def subscribed
# Authenticate gateway using API key from params
api_key_digest = params[:api_key_digest] # ❌ This was never passed!
unless api_key_digest
reject # ❌ Always rejected here
return
end
@gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
unless @gateway
reject
return
end
stream_for @gateway
@gateway.heartbeat!
Rails.logger.info("Gateway #{@gateway.device_id} connected via WebSocket")
end
end
```
### After (Fixed)
```ruby
class GatewayChannel < ApplicationCable::Channel
def subscribed
# Gateway is already authenticated at the connection level
# current_gateway is set by ApplicationCable::Connection
@gateway = current_gateway # ✅ Use already-authenticated gateway
unless @gateway
reject
return
end
# Subscribe to gateway-specific channel
stream_for @gateway
# Update gateway status
@gateway.heartbeat!
Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel")
end
end
```
## Key Changes
1. **Removed** `params[:api_key_digest]` lookup (it was never passed)
2. **Use** `current_gateway` which is set by the connection authentication
3. **Simplified** authentication - it only happens once at the connection level
4. **Removed** redundant authentication check
## Connection Authentication
The connection-level authentication in `ApplicationCable::Connection` is the correct place for this:
```ruby
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_gateway
def connect
self.current_gateway = find_verified_gateway
logger.info "Gateway #{current_gateway.device_id} connected"
end
private
def find_verified_gateway
# Get API key from request params (query string)
api_key = request.params[:api_key]
if api_key.blank?
reject_unauthorized_connection
end
# Hash the API key and find gateway
api_key_digest = Digest::SHA256.hexdigest(api_key)
gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
if gateway
gateway
else
reject_unauthorized_connection
end
end
end
end
```
## Testing the Fix
### Test with wscat
```bash
wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here"
```
**After connection**, subscribe to the channel:
```json
{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}
```
**Expected Response**:
```json
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"type": "confirm_subscription"
}
```
### Test from Android
```kotlin
val wsUrl = "ws://192.168.1.100:3000/cable?api_key=$apiKey"
val request = Request.Builder().url(wsUrl).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Connection successful
// Subscribe to channel
val subscribeMsg = JSONObject().apply {
put("command", "subscribe")
put("identifier", """{"channel":"GatewayChannel"}""")
}
webSocket.send(subscribeMsg.toString())
}
override fun onMessage(webSocket: WebSocket, text: String) {
val json = JSONObject(text)
if (json.optString("type") == "confirm_subscription") {
// Subscription successful! ✅
Log.d(TAG, "Subscribed to GatewayChannel")
}
}
})
```
## Why This Matters
### Before Fix
- ❌ All subscription attempts were rejected
- ❌ Gateways couldn't receive SMS send commands
- ❌ Real-time communication was broken
- ❌ Messages stuck in "pending" state
### After Fix
- ✅ Subscriptions work correctly
- ✅ Gateways receive SMS send commands in real-time
- ✅ Bidirectional communication enabled
- ✅ Messages sent immediately to online gateways
## Related Files
- `app/channels/application_cable/connection.rb` - Connection authentication (unchanged, already correct)
- `app/channels/gateway_channel.rb` - Channel subscription (FIXED)
- `CABLE_DOCUMENTATION.md` - WebSocket integration guide (already correct)
- `API_DOCUMENTATION.md` - Full API docs (already correct)
## Summary
The fix removes redundant authentication from the channel subscription layer and properly uses the `current_gateway` that was already authenticated at the connection level. This follows Action Cable best practices where:
1. **Connection authentication** happens when WebSocket connects
2. **Channel subscription** happens after connection is authenticated
3. **No re-authentication** needed in channels - use `current_gateway` or other identifiers set at connection level
WebSocket connections from Android devices should now work correctly! 🚀

502
WEBSOCKET_SETUP.md Normal file
View File

@@ -0,0 +1,502 @@
# WebSocket Connection Setup
## Issue Fixed
**Problem**: Android gateway device couldn't connect to WebSocket server
**Error**:
```
Request origin not allowed
Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
```
**Root Cause**: Action Cable was blocking WebSocket connections from devices on the local network (192.168.x.x) due to origin restrictions.
## Solution Applied
### Configuration Changes
**File**: `config/environments/development.rb`
Added the following configuration:
```ruby
# Allow Action Cable connections from any origin in development
config.action_cable.disable_request_forgery_protection = true
# Allow WebSocket connections from local network
config.action_cable.allowed_request_origins = [
/http:\/\/localhost.*/,
/http:\/\/127\.0\.0\.1.*/,
/http:\/\/192\.168\..*/, # Local network (192.168.0.0/16)
/http:\/\/10\..*/, # Local network (10.0.0.0/8)
/http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\..*/ # Local network (172.16.0.0/12)
]
```
### What This Does
1. **Disables CSRF protection** for Action Cable in development
- Allows WebSocket connections without origin validation
- Safe for development environment
- **Should NOT be used in production**
2. **Allows specific origins**:
- `localhost` and `127.0.0.1` (local machine)
- `192.168.x.x` (most common home/office networks)
- `10.x.x.x` (enterprise networks, Docker)
- `172.16-31.x.x` (Docker bridge networks)
## How to Apply
1. **Restart Rails server**:
```bash
# Stop current server (Ctrl+C or kill process)
lsof -ti:3000 | xargs kill -9
# Start server again
bin/dev
```
2. **Verify Redis is running**:
```bash
redis-cli ping
# Should return: PONG
```
If Redis is not running:
```bash
# macOS (with Homebrew)
brew services start redis
# Linux
sudo systemctl start redis
# Or run manually
redis-server
```
## Testing WebSocket Connection
### Using wscat (Command Line)
Install wscat:
```bash
npm install -g wscat
```
Test connection:
```bash
# Replace with your actual API key
wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here"
```
Expected output:
```json
{"type":"welcome"}
```
### Using Android App
1. **Get API key** from admin interface (when creating gateway)
2. **Configure in Android app**:
- API Base URL: `http://192.168.x.x:3000` (your computer's IP)
- WebSocket URL: `ws://192.168.x.x:3000/cable`
- API Key: `gw_live_...`
3. **Connect** in the app
4. **Check Rails logs** for connection message:
```
Gateway device-001 connected
```
### Finding Your Computer's IP Address
**macOS**:
```bash
ipconfig getifaddr en0 # WiFi
ipconfig getifaddr en1 # Ethernet
```
**Linux**:
```bash
hostname -I | awk '{print $1}'
```
**Windows**:
```cmd
ipconfig | findstr IPv4
```
## Connection Flow
### 1. Client Connects
Android app initiates WebSocket connection:
```
ws://192.168.x.x:3000/cable?api_key=gw_live_abc123...
```
### 2. Server Authenticates
**File**: `app/channels/application_cable/connection.rb`
```ruby
def connect
self.current_gateway = find_verified_gateway
logger.info "Gateway #{current_gateway.device_id} connected"
end
private
def find_verified_gateway
api_key = request.params[:api_key]
return reject_unauthorized_connection if api_key.blank?
api_key_digest = Digest::SHA256.hexdigest(api_key)
gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
gateway || reject_unauthorized_connection
end
```
### 3. Welcome Message
Server sends welcome:
```json
{"type":"welcome"}
```
### 4. Gateway Subscribes
Android app subscribes to GatewayChannel:
```json
{
"command": "subscribe",
"identifier": "{\"channel\":\"GatewayChannel\"}"
}
```
### 5. Server Confirms
```json
{
"identifier": "{\"channel\":\"GatewayChannel\"}",
"type": "confirm_subscription"
}
```
### 6. Communication Begins
Gateway can now:
- Send heartbeats
- Report received SMS
- Report delivery status
- Receive SMS to send
## Production Configuration
### Important: Different Settings for Production
**File**: `config/environments/production.rb`
**DO NOT** disable CSRF protection in production. Instead, specify exact allowed origins:
```ruby
# Production settings (recommended)
config.action_cable.allowed_request_origins = [
'https://yourdomain.com',
'https://www.yourdomain.com'
]
# Or allow all HTTPS origins (less secure)
config.action_cable.allowed_request_origins = /https:.*/
# NEVER do this in production:
# config.action_cable.disable_request_forgery_protection = true
```
### Production WebSocket URL
Use `wss://` (WebSocket Secure) instead of `ws://`:
```
wss://api.yourdomain.com/cable
```
### SSL/TLS Requirements
1. **Enable SSL** in Rails:
```ruby
# config/environments/production.rb
config.force_ssl = true
```
2. **Configure Puma** for SSL:
```ruby
# config/puma.rb
ssl_bind '0.0.0.0', '3000', {
key: '/path/to/server.key',
cert: '/path/to/server.crt'
}
```
3. **Or use reverse proxy** (recommended):
- Nginx or Apache with SSL
- Cloudflare
- AWS Application Load Balancer
- Heroku (automatic SSL)
## Troubleshooting
### Connection Refused
**Error**: `Failed to upgrade to WebSocket`
**Causes**:
1. Rails server not running
2. Redis not running
3. Wrong port
4. Firewall blocking
**Solutions**:
```bash
# Check if server is running
lsof -i:3000
# Check if Redis is running
redis-cli ping
# Check Rails logs
tail -f log/development.log
# Restart server
bin/dev
```
### Authentication Failed
**Error**: `Unauthorized`
**Causes**:
1. Wrong API key
2. Gateway not active
3. API key format incorrect
**Solutions**:
```bash
# Test API key in console
bin/rails console
```
```ruby
api_key = "gw_live_abc123..."
digest = Digest::SHA256.hexdigest(api_key)
gateway = Gateway.find_by(api_key_digest: digest)
puts gateway&.device_id || "NOT FOUND"
puts "Active: #{gateway&.active}"
```
### Origin Not Allowed (in production)
**Error**: `Request origin not allowed`
**Cause**: Origin not in `allowed_request_origins`
**Solution**: Add your domain to allowed origins:
```ruby
config.action_cable.allowed_request_origins = [
'https://yourdomain.com',
'https://api.yourdomain.com'
]
```
### Connection Drops Frequently
**Causes**:
1. Network instability
2. Server restarting
3. Redis connection issues
4. Heartbeat timeout
**Solutions**:
1. **Implement reconnection** in Android app
2. **Monitor Redis**:
```bash
redis-cli
> CLIENT LIST
> MONITOR
```
3. **Check heartbeat interval**: Gateway should send heartbeat every < 2 minutes
4. **Review server logs** for errors
## Monitoring WebSocket Connections
### View Active Connections
**In Rails console**:
```ruby
ActionCable.server.connections.size
```
### Monitor Redis
```bash
redis-cli
> CLIENT LIST | grep cable
> SUBSCRIBE sms_gateway_development*
```
### Check Gateway Status
```ruby
# In Rails console
Gateway.online.each do |g|
puts "#{g.name}: Last heartbeat #{g.last_heartbeat_at}"
end
```
### View Connection Logs
```bash
# Development logs
tail -f log/development.log | grep -i "cable\|gateway\|websocket"
# Production logs
tail -f log/production.log | grep -i "cable\|gateway\|websocket"
```
## Security Considerations
### Development (Current Setup)
**Acceptable**:
- CSRF protection disabled
- All origins allowed
- HTTP connections
- Local network only
⚠️ **Not for production**
### Production Requirements
🔒 **Must Have**:
- HTTPS/WSS only (`config.force_ssl = true`)
- Specific allowed origins
- CSRF protection enabled
- Rate limiting on connections
- Authentication required
- Connection monitoring
- Audit logging
### Best Practices
1. **API Key Security**:
- Never log API keys
- Use environment variables
- Rotate keys regularly
- Revoke compromised keys immediately
2. **Connection Limits**:
- Limit connections per gateway
- Implement backoff strategy
- Monitor connection attempts
- Alert on suspicious activity
3. **Network Security**:
- Use VPN for remote access
- Implement IP whitelisting
- Use SSL/TLS certificates
- Enable firewall rules
## Example Android App Code
### Connecting to WebSocket
```kotlin
import okhttp3.*
import java.util.concurrent.TimeUnit
class GatewayWebSocket(
private val apiKey: String,
private val websocketUrl: String
) {
private var webSocket: WebSocket? = null
private val client = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
fun connect() {
val request = Request.Builder()
.url("$websocketUrl?api_key=$apiKey")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d("WebSocket", "Connected!")
subscribe()
}
override fun onMessage(webSocket: WebSocket, text: String) {
Log.d("WebSocket", "Received: $text")
handleMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e("WebSocket", "Connection failed: ${t.message}")
reconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d("WebSocket", "Closed: $reason")
reconnect()
}
})
}
private fun subscribe() {
val subscribeMessage = """
{
"command": "subscribe",
"identifier": "{\"channel\":\"GatewayChannel\"}"
}
""".trimIndent()
webSocket?.send(subscribeMessage)
}
private fun handleMessage(json: String) {
val message = JSONObject(json)
when (message.optString("type")) {
"welcome" -> Log.d("WebSocket", "Welcome received")
"ping" -> Log.d("WebSocket", "Ping received")
"confirm_subscription" -> Log.d("WebSocket", "Subscribed to GatewayChannel")
}
}
fun disconnect() {
webSocket?.close(1000, "Normal closure")
}
private fun reconnect() {
Handler(Looper.getMainLooper()).postDelayed({
connect()
}, 5000) // Reconnect after 5 seconds
}
}
```
## Summary
**Fixed**: WebSocket connection now works from local network
**Configuration**: Action Cable allows connections from 192.168.x.x, 10.x.x.x, etc.
**Security**: CSRF protection disabled for development only
**Production**: Different, more secure settings required
**Next Steps**:
1. Restart your Rails server
2. Ensure Redis is running
3. Try connecting from Android app
4. Check Rails logs for "Gateway XXX connected" message
The WebSocket server is now ready to accept connections from your Android gateway devices on the local network! 🚀

0
app/assets/builds/.keep Normal file
View File

0
app/assets/images/.keep Normal file
View File

View File

@@ -0,0 +1,10 @@
/*
* This is a manifest file that'll be compiled into application.css.
*
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
* depending on specificity.
*
* Consider organizing styles into separate files for maintainability.
*/

View File

@@ -0,0 +1,36 @@
@import "tailwindcss";
/* Custom Admin Theme */
@theme {
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,31 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_gateway
def connect
self.current_gateway = find_verified_gateway
logger.info "Gateway #{current_gateway.device_id} connected"
end
private
def find_verified_gateway
# Get API key from request params
api_key = request.params[:api_key]
if api_key.blank?
reject_unauthorized_connection
end
# Hash the API key and find gateway
api_key_digest = Digest::SHA256.hexdigest(api_key)
gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
if gateway
gateway
else
reject_unauthorized_connection
end
end
end
end

View File

@@ -0,0 +1,99 @@
class GatewayChannel < ApplicationCable::Channel
def subscribed
# Gateway is already authenticated at the connection level
# current_gateway is set by ApplicationCable::Connection
@gateway = current_gateway
unless @gateway
reject
return
end
# Subscribe to gateway-specific channel
stream_for @gateway
# Update gateway status
@gateway.heartbeat!
Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel")
end
def unsubscribed
# Update gateway status when disconnected
if @gateway
@gateway.mark_offline!
Rails.logger.info("Gateway #{@gateway.device_id} disconnected from WebSocket")
end
end
def receive(data)
return unless @gateway
# Handle incoming messages from gateway
case data["action"]
when "heartbeat"
handle_heartbeat(data)
when "delivery_report"
handle_delivery_report(data)
when "message_received"
handle_message_received(data)
else
Rails.logger.warn("Unknown action received: #{data['action']}")
end
rescue StandardError => e
Rails.logger.error("Error processing gateway message: #{e.message}")
end
private
def handle_heartbeat(data)
@gateway.heartbeat!
# Update metadata if provided
metadata = {
battery_level: data["battery_level"],
signal_strength: data["signal_strength"],
messages_in_queue: data["messages_in_queue"]
}.compact
@gateway.update(metadata: metadata) if metadata.any?
end
def handle_delivery_report(data)
message_id = data["message_id"]
status = data["status"]
error_message = data["error_message"]
sms = SmsMessage.find_by(message_id: message_id)
return unless sms
case status
when "delivered"
sms.mark_delivered!
when "failed"
sms.mark_failed!(error_message)
RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry?
end
end
def handle_message_received(data)
sender = data["sender"]
message = data["message"]
timestamp = data["timestamp"] || Time.current
# Create inbound SMS
sms = SmsMessage.create!(
gateway: @gateway,
direction: "inbound",
phone_number: sender,
message_body: message,
status: "delivered",
delivered_at: timestamp
)
@gateway.increment_received_count!
# Process inbound message
ProcessInboundSmsJob.perform_later(sms.id)
end
end

View File

@@ -0,0 +1,76 @@
module Admin
class ApiKeysController < BaseController
def index
@api_keys = ApiKey.order(created_at: :desc)
end
def new
@api_key = ApiKey.new
end
def create
# Build permissions hash
permissions = {}
permissions["send_sms"] = params.dig(:api_key, :send_sms) == "1"
permissions["receive_sms"] = params.dig(:api_key, :receive_sms) == "1"
permissions["manage_gateways"] = params.dig(:api_key, :manage_gateways) == "1"
permissions["manage_otp"] = params.dig(:api_key, :manage_otp) == "1"
# Parse expiration date if provided
expires_at = if params.dig(:api_key, :expires_at).present?
Time.parse(params[:api_key][:expires_at])
else
nil
end
# Generate API key
result = ApiKey.generate!(
name: params[:api_key][:name],
permissions: permissions,
expires_at: expires_at
)
# Store in session to pass to show action
session[:new_api_key_id] = result[:api_key].id
session[:new_api_raw_key] = result[:raw_key]
redirect_to admin_api_key_path(result[:api_key])
rescue StandardError => e
Rails.logger.error "API Key creation failed: #{e.message}\n#{e.backtrace.join("\n")}"
flash.now[:alert] = "Error creating API key: #{e.message}"
@api_key = ApiKey.new(name: params.dig(:api_key, :name))
render :new, status: :unprocessable_entity
end
def show
@api_key = ApiKey.find(params[:id])
# Check if this is a newly created key (from session)
if session[:new_api_key_id] == @api_key.id && session[:new_api_raw_key].present?
@raw_key = session[:new_api_raw_key]
# Clear session data after retrieving
session.delete(:new_api_key_id)
session.delete(:new_api_raw_key)
else
# This is an existing key being viewed (shouldn't normally happen)
redirect_to admin_api_keys_path, alert: "Cannot view API key details after creation"
end
end
def destroy
@api_key = ApiKey.find(params[:id])
@api_key.revoke!
redirect_to admin_api_keys_path, notice: "API key revoked successfully"
rescue => e
redirect_to admin_api_keys_path, alert: "Error revoking API key: #{e.message}"
end
def toggle
@api_key = ApiKey.find(params[:id])
@api_key.update!(active: !@api_key.active)
redirect_to admin_api_keys_path, notice: "API key #{@api_key.active? ? 'activated' : 'deactivated'}"
rescue => e
redirect_to admin_api_keys_path, alert: "Error updating API key: #{e.message}"
end
end
end

View File

@@ -0,0 +1,8 @@
module Admin
class ApiTesterController < BaseController
def index
@api_keys = ApiKey.active_keys.order(created_at: :desc)
@gateways = Gateway.order(created_at: :desc)
end
end
end

View File

@@ -0,0 +1,30 @@
module Admin
class BaseController < ActionController::Base
include Pagy::Backend
# Enable session and flash for admin controllers
# (needed because the app is in API-only mode)
protect_from_forgery with: :exception
layout "admin"
before_action :require_admin
private
def current_admin
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
end
helper_method :current_admin
def logged_in?
current_admin.present?
end
helper_method :logged_in?
def require_admin
unless logged_in?
redirect_to admin_login_path, alert: "Please log in to continue"
end
end
end
end

View File

@@ -0,0 +1,20 @@
module Admin
class DashboardController < BaseController
def index
@stats = {
total_gateways: Gateway.count,
online_gateways: Gateway.online.count,
total_api_keys: ApiKey.count,
active_api_keys: ApiKey.active_keys.count,
messages_today: SmsMessage.where("created_at >= ?", Time.current.beginning_of_day).count,
messages_sent_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "outbound").count,
messages_received_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "inbound").count,
failed_messages_today: SmsMessage.where("created_at >= ? AND status = ?", Time.current.beginning_of_day, "failed").count,
pending_messages: SmsMessage.pending.count
}
@recent_messages = SmsMessage.order(created_at: :desc).limit(10)
@recent_gateways = Gateway.order(last_heartbeat_at: :desc).limit(5)
end
end
end

View File

@@ -0,0 +1,152 @@
module Admin
class GatewaysController < BaseController
def index
@gateways = Gateway.order(created_at: :desc)
end
def new
@gateway = Gateway.new
end
def create
@gateway = Gateway.new(
device_id: params[:gateway][:device_id],
name: params[:gateway][:name],
priority: params[:gateway][:priority] || 1,
status: "offline"
)
# Generate API key for the gateway
raw_key = @gateway.generate_api_key!
# Store in session to pass to show action
session[:new_gateway_id] = @gateway.id
session[:new_gateway_raw_key] = raw_key
redirect_to admin_gateway_path(@gateway)
rescue StandardError => e
Rails.logger.error "Gateway creation failed: #{e.message}\n#{e.backtrace.join("\n")}"
flash.now[:alert] = "Error creating gateway: #{e.message}"
@gateway ||= Gateway.new
render :new, status: :unprocessable_entity
end
def show
@gateway = Gateway.find(params[:id])
# Check if this is a newly created gateway (from session)
if session[:new_gateway_id] == @gateway.id && session[:new_gateway_raw_key].present?
@raw_key = session[:new_gateway_raw_key]
@is_new = true
# Generate QR code with configuration data
@qr_code_data = generate_qr_code_data(@raw_key)
# Clear session data after retrieving
session.delete(:new_gateway_id)
session.delete(:new_gateway_raw_key)
else
@is_new = false
@recent_messages = SmsMessage.where(gateway_id: @gateway.id).order(created_at: :desc).limit(20)
end
end
private
def generate_qr_code_data(api_key)
require "rqrcode"
# Determine the base URL and WebSocket URL
base_url = request.base_url
ws_url = request.base_url.sub(/^http/, "ws") + "/cable"
# Create JSON configuration for the Android app
config_data = {
api_key: api_key,
api_base_url: base_url,
websocket_url: ws_url,
version: "1.0"
}.to_json
# Generate QR code
qr = RQRCode::QRCode.new(config_data, level: :h)
# Return as SVG string
qr.as_svg(
offset: 0,
color: "000",
shape_rendering: "crispEdges",
module_size: 4,
standalone: true,
use_path: true
)
end
def toggle
@gateway = Gateway.find(params[:id])
@gateway.update!(active: !@gateway.active)
redirect_to admin_gateways_path, notice: "Gateway #{@gateway.active? ? 'activated' : 'deactivated'}"
rescue => e
redirect_to admin_gateways_path, alert: "Error updating gateway: #{e.message}"
end
def test
@gateway = Gateway.find(params[:id])
rescue ActiveRecord::RecordNotFound
redirect_to admin_gateways_path, alert: "Gateway not found"
end
def check_connection
@gateway = Gateway.find(params[:id])
# Check if gateway is online based on recent heartbeat
if @gateway.online?
render json: {
status: "success",
message: "Gateway is online",
last_heartbeat: @gateway.last_heartbeat_at,
time_ago: helpers.time_ago_in_words(@gateway.last_heartbeat_at)
}
else
render json: {
status: "error",
message: "Gateway is offline",
last_heartbeat: @gateway.last_heartbeat_at,
time_ago: @gateway.last_heartbeat_at ? helpers.time_ago_in_words(@gateway.last_heartbeat_at) : "never"
}
end
rescue StandardError => e
render json: { status: "error", message: e.message }, status: :internal_server_error
end
def send_test_sms
@gateway = Gateway.find(params[:id])
phone_number = params[:phone_number]
message_body = params[:message_body]
if phone_number.blank? || message_body.blank?
render json: { status: "error", message: "Phone number and message are required" }, status: :unprocessable_entity
return
end
# Create test SMS message
sms = SmsMessage.create!(
direction: "outbound",
phone_number: phone_number,
message_body: message_body,
gateway: @gateway,
metadata: { test: true, sent_from: "admin_interface" }
)
render json: {
status: "success",
message: "Test SMS queued for sending",
message_id: sms.message_id,
sms_status: sms.status
}
rescue StandardError => e
Rails.logger.error "Test SMS failed: #{e.message}\n#{e.backtrace.join("\n")}"
render json: { status: "error", message: e.message }, status: :internal_server_error
end
end
end

View File

@@ -0,0 +1,37 @@
module Admin
class LogsController < BaseController
def index
@pagy, @messages = pagy(
apply_filters(SmsMessage).order(created_at: :desc),
items: 50
)
respond_to do |format|
format.html
format.turbo_stream
end
end
private
def apply_filters(scope)
scope = scope.where(direction: params[:direction]) if params[:direction].present?
scope = scope.where(status: params[:status]) if params[:status].present?
scope = scope.where(gateway_id: params[:gateway_id]) if params[:gateway_id].present?
if params[:phone_number].present?
scope = scope.where("phone_number LIKE ?", "%#{params[:phone_number]}%")
end
if params[:start_date].present?
scope = scope.where("created_at >= ?", Time.parse(params[:start_date]))
end
if params[:end_date].present?
scope = scope.where("created_at <= ?", Time.parse(params[:end_date]).end_of_day)
end
scope
end
end
end

View File

@@ -0,0 +1,38 @@
module Admin
class SessionsController < ActionController::Base
layout "admin"
# CSRF protection is enabled by default in ActionController::Base
# We need it for the create action but not for the new (GET) action
protect_from_forgery with: :exception
def new
redirect_to admin_dashboard_path if current_admin
end
def create
admin = AdminUser.find_by(email: params[:email]&.downcase)
if admin&.authenticate(params[:password])
session[:admin_id] = admin.id
admin.update_last_login!
redirect_to admin_dashboard_path, notice: "Welcome back, #{admin.name}!"
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
def destroy
session.delete(:admin_id)
redirect_to admin_login_path, notice: "You have been logged out"
end
private
def current_admin
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
end
helper_method :current_admin
end
end

View File

@@ -0,0 +1,49 @@
module Api
module V1
module Admin
class GatewaysController < ApplicationController
include ApiAuthenticatable
# GET /api/v1/admin/gateways
def index
gateways = ::Gateway.order(created_at: :desc)
render json: {
gateways: gateways.map { |gateway|
{
id: gateway.id,
device_id: gateway.device_id,
name: gateway.name,
status: gateway.status,
last_heartbeat_at: gateway.last_heartbeat_at,
messages_sent_today: gateway.messages_sent_today,
messages_received_today: gateway.messages_received_today,
total_messages_sent: gateway.total_messages_sent,
total_messages_received: gateway.total_messages_received,
active: gateway.active,
priority: gateway.priority,
metadata: gateway.metadata,
created_at: gateway.created_at
}
}
}
end
# POST /api/v1/admin/gateways/:id/toggle
def toggle
gateway = ::Gateway.find(params[:id])
gateway.update!(active: !gateway.active)
render json: {
success: true,
gateway: {
id: gateway.id,
device_id: gateway.device_id,
active: gateway.active
}
}
end
end
end
end
end

View File

@@ -0,0 +1,60 @@
module Api
module V1
module Admin
class StatsController < ApplicationController
include ApiAuthenticatable
# GET /api/v1/admin/stats
def index
today = Time.current.beginning_of_day
# Gateway stats
total_gateways = ::Gateway.count
active_gateways = ::Gateway.active.count
online_gateways = ::Gateway.online.count
# Message stats
total_messages_sent = ::Gateway.sum(:total_messages_sent)
total_messages_received = ::Gateway.sum(:total_messages_received)
messages_sent_today = ::Gateway.sum(:messages_sent_today)
messages_received_today = ::Gateway.sum(:messages_received_today)
# Pending and failed messages
pending_messages = SmsMessage.pending.count
failed_messages_today = SmsMessage.failed
.where("created_at >= ?", today)
.count
# OTP stats
otps_sent_today = OtpCode.where("created_at >= ?", today).count
otps_verified_today = OtpCode.where("verified_at >= ?", today).count
render json: {
gateways: {
total: total_gateways,
active: active_gateways,
online: online_gateways,
offline: total_gateways - online_gateways
},
messages: {
total_sent: total_messages_sent,
total_received: total_messages_received,
sent_today: messages_sent_today,
received_today: messages_received_today,
total_today: messages_sent_today + messages_received_today,
pending: pending_messages,
failed_today: failed_messages_today
},
otp: {
sent_today: otps_sent_today,
verified_today: otps_verified_today,
verification_rate: otps_sent_today > 0 ? (otps_verified_today.to_f / otps_sent_today * 100).round(2) : 0
},
timestamp: Time.current
}
end
end
end
end
end

View File

@@ -0,0 +1,10 @@
module Api
module V1
module Gateway
class BaseController < ApplicationController
include ApiAuthenticatable
include RateLimitable
end
end
end
end

View File

@@ -0,0 +1,30 @@
module Api
module V1
module Gateway
class HeartbeatsController < BaseController
def create
current_gateway.heartbeat!
# Update metadata with device info
metadata = {
battery_level: params[:battery_level],
signal_strength: params[:signal_strength],
messages_in_queue: params[:messages_in_queue]
}.compact
current_gateway.update(metadata: metadata) if metadata.any?
# Get count of pending messages for this gateway
pending_count = SmsMessage.pending
.where(gateway_id: [nil, current_gateway.id])
.count
render json: {
success: true,
pending_messages: pending_count
}
end
end
end
end
end

View File

@@ -0,0 +1,53 @@
module Api
module V1
module Gateway
class RegistrationsController < ApplicationController
skip_before_action :authenticate_api_key!, only: [:create]
def create
device_id = params.require(:device_id)
name = params[:name] || "Gateway #{device_id[0..7]}"
# Check if gateway already exists
gateway = ::Gateway.find_by(device_id: device_id)
if gateway
render json: {
success: false,
error: "Gateway already registered"
}, status: :conflict
return
end
# Create new gateway
gateway = ::Gateway.new(
device_id: device_id,
name: name,
status: "offline"
)
raw_key = gateway.generate_api_key!
render json: {
success: true,
api_key: raw_key,
device_id: gateway.device_id,
websocket_url: websocket_url
}, status: :created
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
private
def websocket_url
if Rails.env.production?
"wss://#{request.host}/cable"
else
"ws://#{request.host}:#{request.port}/cable"
end
end
end
end
end
end

View File

@@ -0,0 +1,61 @@
module Api
module V1
module Gateway
class SmsController < BaseController
# POST /api/v1/gateway/sms/received
def received
sender = params.require(:sender)
message = params.require(:message)
timestamp = params[:timestamp] || Time.current
# Create inbound SMS message
sms = SmsMessage.create!(
gateway: current_gateway,
direction: "inbound",
phone_number: sender,
message_body: message,
status: "delivered",
delivered_at: timestamp
)
# Increment received counter
current_gateway.increment_received_count!
# Process inbound message asynchronously
ProcessInboundSmsJob.perform_later(sms.id)
render json: {
success: true,
message_id: sms.message_id
}
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
# POST /api/v1/gateway/sms/status
def status
message_id = params.require(:message_id)
status = params.require(:status)
error_message = params[:error_message]
sms = SmsMessage.find_by!(message_id: message_id)
case status
when "delivered"
sms.mark_delivered!
when "failed"
sms.mark_failed!(error_message)
# Retry if possible
RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry?
when "sent"
sms.update!(status: "sent", sent_at: Time.current)
end
render json: { success: true }
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
end
end
end
end

View File

@@ -0,0 +1,86 @@
module Api
module V1
class OtpController < ApplicationController
include ApiAuthenticatable
include RateLimitable
# POST /api/v1/otp/send
def send_otp
phone_number = params.require(:phone_number)
purpose = params[:purpose] || "authentication"
expiry_minutes = params[:expiry_minutes]&.to_i || 5
# Rate limit by phone number
return unless rate_limit_by_phone!(phone_number, limit: 3, period: 1.hour)
# Validate phone number
phone = Phonelib.parse(phone_number)
unless phone.valid?
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
return
end
# Send OTP
result = OtpCode.send_otp(
phone.e164,
purpose: purpose,
expiry_minutes: expiry_minutes,
ip_address: request.remote_ip
)
render json: {
success: true,
expires_at: result[:otp].expires_at,
message_id: result[:sms].message_id
}
rescue ActiveRecord::RecordInvalid => e
# Rate limit error from OTP model
if e.record.errors[:base].any?
render json: {
error: e.record.errors[:base].first
}, status: :too_many_requests
else
render json: {
error: e.message,
details: e.record.errors.full_messages
}, status: :unprocessable_entity
end
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
# POST /api/v1/otp/verify
def verify
phone_number = params.require(:phone_number)
code = params.require(:code)
# Validate phone number
phone = Phonelib.parse(phone_number)
unless phone.valid?
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
return
end
# Verify OTP
result = OtpCode.verify(phone.e164, code)
if result[:success]
render json: {
success: true,
verified: true
}
else
attempts_remaining = 3 - (result[:attempts_remaining] || 0)
render json: {
success: false,
verified: false,
error: result[:error],
attempts_remaining: [attempts_remaining, 0].max
}
end
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
end
end
end

View File

@@ -0,0 +1,90 @@
module Api
module V1
class SmsController < ApplicationController
include ApiAuthenticatable
include RateLimitable
include Pagy::Backend
# POST /api/v1/sms/send
def send_sms
return unless rate_limit_by_api_key!(limit: 100, period: 1.minute)
phone_number = params.require(:to)
message_body = params.require(:message)
# Validate phone number
phone = Phonelib.parse(phone_number)
unless phone.valid?
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
return
end
# Create outbound SMS message
sms = SmsMessage.create!(
direction: "outbound",
phone_number: phone.e164,
message_body: message_body,
status: "queued"
)
render json: {
success: true,
message_id: sms.message_id,
status: sms.status
}, status: :accepted
rescue ActionController::ParameterMissing => e
render json: { error: e.message }, status: :bad_request
end
# GET /api/v1/sms/status/:message_id
def status
message_id = params.require(:message_id)
sms = SmsMessage.find_by!(message_id: message_id)
render json: {
message_id: sms.message_id,
status: sms.status,
sent_at: sms.sent_at,
delivered_at: sms.delivered_at,
failed_at: sms.failed_at,
error_message: sms.error_message
}
end
# GET /api/v1/sms/received
def received
query = SmsMessage.inbound.recent
# Filter by phone number if provided
if params[:phone_number].present?
query = query.where(phone_number: params[:phone_number])
end
# Filter by date if provided
if params[:since].present?
since_time = Time.parse(params[:since])
query = query.where("created_at >= ?", since_time)
end
# Paginate results
pagy, messages = pagy(query, items: params[:limit] || 50)
render json: {
messages: messages.map { |sms|
{
message_id: sms.message_id,
from: sms.phone_number,
message: sms.message_body,
received_at: sms.created_at
}
},
total: pagy.count,
page: pagy.page,
pages: pagy.pages
}
rescue ArgumentError => e
render json: { error: "Invalid date format" }, status: :bad_request
end
end
end
end

View File

@@ -0,0 +1,22 @@
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable
rescue_from ActionController::ParameterMissing, with: :render_bad_request
private
def render_not_found(exception)
render json: { error: exception.message }, status: :not_found
end
def render_unprocessable(exception)
render json: {
error: exception.message,
details: exception.record.errors.full_messages
}, status: :unprocessable_entity
end
def render_bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end

View File

View File

@@ -0,0 +1,73 @@
module ApiAuthenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_api_key!
end
private
def authenticate_api_key!
api_key = extract_api_key
return render_unauthorized("Missing API key") unless api_key
key_digest = Digest::SHA256.hexdigest(api_key)
if api_key.start_with?("gw_")
authenticate_gateway(key_digest)
else
authenticate_client_api_key(key_digest)
end
end
def authenticate_gateway(key_digest)
@current_gateway = Gateway.find_by(api_key_digest: key_digest, active: true)
unless @current_gateway
render_unauthorized("Invalid gateway API key")
end
end
def authenticate_client_api_key(key_digest)
@current_api_key = ApiKey.find_by(key_digest: key_digest, active: true)
unless @current_api_key
render_unauthorized("Invalid API key")
return
end
# Check if key has expired
if @current_api_key.expires_at.present? && @current_api_key.expires_at < Time.current
render_unauthorized("API key has expired")
return
end
@current_api_key.touch(:last_used_at)
end
def extract_api_key
auth_header = request.headers["Authorization"]
return nil unless auth_header
# Support both "Bearer token" and just "token"
auth_header.sub(/^Bearer\s+/, "")
end
def render_unauthorized(message = "Unauthorized")
render json: { error: message }, status: :unauthorized
end
def current_gateway
@current_gateway
end
def current_api_key
@current_api_key
end
def require_permission(permission)
unless @current_api_key&.can?(permission)
render json: { error: "Insufficient permissions" }, status: :forbidden
end
end
end

View File

@@ -0,0 +1,54 @@
module RateLimitable
extend ActiveSupport::Concern
private
def rate_limit_check!(key, limit:, period:)
cache_key = "rate_limit:#{key}"
count = Rails.cache.read(cache_key) || 0
if count >= limit
render_rate_limit_exceeded(period)
return false
end
Rails.cache.write(cache_key, count + 1, expires_in: period)
true
end
def rate_limit_increment(key, period:)
cache_key = "rate_limit:#{key}"
current_count = Rails.cache.read(cache_key) || 0
Rails.cache.write(cache_key, current_count + 1, expires_in: period)
end
def rate_limit_reset(key)
cache_key = "rate_limit:#{key}"
Rails.cache.delete(cache_key)
end
def render_rate_limit_exceeded(retry_after)
render json: {
error: "Rate limit exceeded",
retry_after: retry_after.to_i
}, status: :too_many_requests
end
# Rate limit based on IP address
def rate_limit_by_ip!(limit:, period:)
ip_address = request.remote_ip
rate_limit_check!("ip:#{ip_address}", limit: limit, period: period)
end
# Rate limit based on API key
def rate_limit_by_api_key!(limit:, period:)
return true unless @current_api_key
rate_limit_check!("api_key:#{@current_api_key.id}", limit: limit, period: period)
end
# Rate limit based on phone number
def rate_limit_by_phone!(phone_number, limit:, period:)
rate_limit_check!("phone:#{phone_number}", limit: limit, period: period)
end
end

View File

@@ -0,0 +1,9 @@
module AdminHelper
def current_admin
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
end
def logged_in?
current_admin.present?
end
end

View File

@@ -0,0 +1,12 @@
module ApplicationHelper
include Pagy::Frontend
# Admin authentication helpers
def current_admin
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
end
def logged_in?
current_admin.present?
end
end

View File

@@ -0,0 +1,3 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"

View File

@@ -0,0 +1,9 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -0,0 +1,4 @@
// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

View File

@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View File

@@ -0,0 +1,14 @@
class CheckGatewayHealthJob < ApplicationJob
queue_as :default
def perform
# Mark gateways as offline if no heartbeat in last 2 minutes
offline_count = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago)
.where.not(status: "offline")
.update_all(status: "offline")
if offline_count > 0
Rails.logger.warn("Marked #{offline_count} gateways as offline due to missing heartbeat")
end
end
end

View File

@@ -0,0 +1,8 @@
class CleanupExpiredOtpsJob < ApplicationJob
queue_as :low_priority
def perform
deleted_count = OtpCode.cleanup_expired!
Rails.logger.info("Cleaned up #{deleted_count} expired OTP codes")
end
end

View File

@@ -0,0 +1,57 @@
class ProcessInboundSmsJob < ApplicationJob
queue_as :default
def perform(sms_message_id)
sms = SmsMessage.find(sms_message_id)
# Skip if not inbound
return unless sms.inbound?
# Trigger webhooks for sms_received event
trigger_webhooks(sms)
# Check if this is an OTP reply
check_otp_reply(sms)
Rails.logger.info("Processed inbound SMS #{sms.message_id} from #{sms.phone_number}")
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error("SMS message not found: #{e.message}")
rescue StandardError => e
Rails.logger.error("Failed to process inbound SMS #{sms_message_id}: #{e.message}")
end
private
def trigger_webhooks(sms)
webhooks = WebhookConfig.for_event_type("sms_received")
webhooks.each do |webhook|
payload = {
event: "sms_received",
message_id: sms.message_id,
from: sms.phone_number,
message: sms.message_body,
received_at: sms.created_at,
gateway_id: sms.gateway&.device_id
}
webhook.trigger(payload)
end
end
def check_otp_reply(sms)
# Extract potential OTP code from message (6 digits)
code_match = sms.message_body.match(/\b\d{6}\b/)
return unless code_match
code = code_match[0]
# Try to verify if there's a pending OTP for this phone number
otp = OtpCode.valid_codes.find_by(phone_number: sms.phone_number, code: code)
if otp
otp.update!(verified: true, verified_at: Time.current)
Rails.logger.info("Auto-verified OTP for #{sms.phone_number}")
end
end
end

View File

@@ -0,0 +1,8 @@
class ResetDailyCountersJob < ApplicationJob
queue_as :default
def perform
Gateway.reset_daily_counters!
Rails.logger.info("Reset daily counters for all gateways")
end
end

View File

@@ -0,0 +1,22 @@
class RetryFailedSmsJob < ApplicationJob
queue_as :default
def perform(sms_message_id)
sms = SmsMessage.find(sms_message_id)
# Only retry if message can be retried
return unless sms.can_retry?
# Reset status to queued and increment retry count
sms.increment_retry!
sms.update!(status: "queued", error_message: nil)
# Re-queue for sending with exponential backoff
wait_time = (2 ** sms.retry_count).minutes
SendSmsJob.set(wait: wait_time).perform_later(sms.id)
Rails.logger.info("Retrying failed SMS #{sms.message_id} (attempt #{sms.retry_count})")
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error("SMS message not found: #{e.message}")
end
end

41
app/jobs/send_sms_job.rb Normal file
View File

@@ -0,0 +1,41 @@
class SendSmsJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(sms_message_id)
sms = SmsMessage.find(sms_message_id)
# Skip if already sent or not outbound
return unless sms.outbound? && sms.status == "queued"
# Find available gateway
gateway = Gateway.available_for_sending
unless gateway
Rails.logger.warn("No available gateway for message #{sms.message_id}")
sms.update!(status: "pending")
# Retry later
SendSmsJob.set(wait: 1.minute).perform_later(sms_message_id)
return
end
# Update message status
sms.mark_sent!(gateway)
# Broadcast message to gateway via WebSocket
GatewayChannel.broadcast_to(gateway, {
action: "send_sms",
message_id: sms.message_id,
recipient: sms.phone_number,
message: sms.message_body
})
Rails.logger.info("Sent SMS #{sms.message_id} to gateway #{gateway.device_id}")
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error("SMS message not found: #{e.message}")
rescue StandardError => e
Rails.logger.error("Failed to send SMS #{sms_message_id}: #{e.message}")
sms&.increment_retry!
raise
end
end

View File

@@ -0,0 +1,31 @@
class TriggerWebhookJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(webhook_config_id, payload)
webhook = WebhookConfig.find(webhook_config_id)
# Skip if webhook is not active
return unless webhook.active?
success = webhook.execute(payload)
if success
Rails.logger.info("Webhook #{webhook.name} triggered successfully")
else
Rails.logger.warn("Webhook #{webhook.name} failed")
raise StandardError, "Webhook execution failed" if attempts_count < webhook.retry_count
end
rescue ActiveRecord::RecordNotFound => e
Rails.logger.error("Webhook config not found: #{e.message}")
rescue StandardError => e
Rails.logger.error("Webhook trigger failed: #{e.message}")
raise if attempts_count < (webhook&.retry_count || 3)
end
private
def attempts_count
executions
end
end

View File

@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

11
app/models/admin_user.rb Normal file
View File

@@ -0,0 +1,11 @@
class AdminUser < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
validates :password, length: { minimum: 8 }, if: -> { password.present? }
def update_last_login!
update!(last_login_at: Time.current)
end
end

72
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,72 @@
class ApiKey < ApplicationRecord
# Normalize permissions to always be a Hash
attribute :permissions, :jsonb, default: {}
before_validation :ensure_permissions_is_hash
validates :name, presence: true
validates :key_digest, presence: true, uniqueness: true
validates :key_prefix, presence: true
scope :active_keys, -> { where(active: true) }
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at < ?", Time.current) }
scope :valid, -> { active_keys.where("expires_at IS NULL OR expires_at > ?", Time.current) }
# Generate a new API key
def self.generate!(name:, permissions: {}, expires_at: nil)
raw_key = "api_live_#{SecureRandom.hex(32)}"
key_digest = Digest::SHA256.hexdigest(raw_key)
key_prefix = raw_key[0..11] # First 12 chars for identification
api_key = create!(
name: name,
key_digest: key_digest,
key_prefix: key_prefix,
permissions: permissions,
expires_at: expires_at
)
{ api_key: api_key, raw_key: raw_key }
end
# Authenticate with a raw key
def self.authenticate(raw_key)
return nil unless raw_key.present?
key_digest = Digest::SHA256.hexdigest(raw_key)
api_key = valid.find_by(key_digest: key_digest)
if api_key
api_key.touch(:last_used_at)
end
api_key
end
# Check if API key is still active and not expired
def active_and_valid?
active && (expires_at.nil? || expires_at > Time.current)
end
# Check if API key has specific permission
def can?(permission)
permissions.fetch(permission.to_s, false)
end
# Revoke API key
def revoke!
update!(active: false)
end
# Deactivate expired keys
def self.deactivate_expired!
expired.update_all(active: false)
end
private
def ensure_permissions_is_hash
self.permissions = {} if permissions.nil?
self.permissions = {} unless permissions.is_a?(Hash)
end
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

View File

@@ -0,0 +1,81 @@
module Metrics
extend ActiveSupport::Concern
class_methods do
def increment_counter(metric_name, amount = 1)
cache_key = "metrics:#{metric_name}"
current_value = Rails.cache.read(cache_key) || 0
Rails.cache.write(cache_key, current_value + amount)
end
def decrement_counter(metric_name, amount = 1)
cache_key = "metrics:#{metric_name}"
current_value = Rails.cache.read(cache_key) || 0
new_value = [current_value - amount, 0].max
Rails.cache.write(cache_key, new_value)
end
def get_counter(metric_name)
cache_key = "metrics:#{metric_name}"
Rails.cache.read(cache_key) || 0
end
def reset_counter(metric_name)
cache_key = "metrics:#{metric_name}"
Rails.cache.delete(cache_key)
end
def set_gauge(metric_name, value)
cache_key = "metrics:gauge:#{metric_name}"
Rails.cache.write(cache_key, value)
end
def get_gauge(metric_name)
cache_key = "metrics:gauge:#{metric_name}"
Rails.cache.read(cache_key)
end
# Record timing metrics
def record_timing(metric_name, duration_ms)
cache_key = "metrics:timing:#{metric_name}"
timings = Rails.cache.read(cache_key) || []
timings << duration_ms
# Keep only last 100 measurements
timings = timings.last(100)
Rails.cache.write(cache_key, timings)
end
def get_timing_stats(metric_name)
cache_key = "metrics:timing:#{metric_name}"
timings = Rails.cache.read(cache_key) || []
return nil if timings.empty?
{
count: timings.size,
avg: timings.sum / timings.size.to_f,
min: timings.min,
max: timings.max,
p95: percentile(timings, 95),
p99: percentile(timings, 99)
}
end
private
def percentile(array, percent)
return nil if array.empty?
sorted = array.sort
k = (percent / 100.0) * (sorted.length - 1)
f = k.floor
c = k.ceil
if f == c
sorted[k]
else
sorted[f] * (c - k) + sorted[c] * (k - f)
end
end
end
end

68
app/models/gateway.rb Normal file
View File

@@ -0,0 +1,68 @@
class Gateway < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
has_many :sms_messages, dependent: :nullify
before_validation :ensure_metadata_is_hash
validates :device_id, presence: true, uniqueness: true
validates :api_key_digest, presence: true
validates :status, inclusion: { in: %w[online offline error] }
scope :online, -> { where(status: "online") }
scope :offline, -> { where(status: "offline") }
scope :active, -> { where(active: true) }
scope :by_priority, -> { order(priority: :desc, id: :asc) }
# Find the best available gateway for sending messages
def self.available_for_sending
online.active.by_priority.first
end
# Generate a new API key for the gateway
def generate_api_key!
raw_key = "gw_live_#{SecureRandom.hex(32)}"
self.api_key_digest = Digest::SHA256.hexdigest(raw_key)
save!
raw_key
end
# Check if gateway is currently online based on heartbeat
def online?
status == "online" && last_heartbeat_at.present? && last_heartbeat_at > 2.minutes.ago
end
# Update heartbeat timestamp and status
def heartbeat!
update!(status: "online", last_heartbeat_at: Time.current)
end
# Mark gateway as offline
def mark_offline!
update!(status: "offline")
end
# Increment message counters
def increment_sent_count!
increment!(:messages_sent_today)
increment!(:total_messages_sent)
end
def increment_received_count!
increment!(:messages_received_today)
increment!(:total_messages_received)
end
# Reset daily counters (called by scheduled job)
def self.reset_daily_counters!
update_all(messages_sent_today: 0, messages_received_today: 0)
end
private
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end

118
app/models/otp_code.rb Normal file
View File

@@ -0,0 +1,118 @@
class OtpCode < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
validates :phone_number, presence: true
validates :code, presence: true, length: { is: 6 }
validates :expires_at, presence: true
validates :purpose, presence: true
validate :phone_number_format
validate :rate_limit_check, on: :create
before_validation :ensure_metadata_is_hash
before_validation :generate_code, on: :create, unless: :code?
before_validation :set_expiry, on: :create, unless: :expires_at?
before_validation :normalize_phone_number
scope :valid_codes, -> { where(verified: false).where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :verified_codes, -> { where(verified: true) }
# Verify an OTP code
def self.verify(phone_number, code)
normalized_phone = normalize_phone_number_string(phone_number)
otp = valid_codes.find_by(phone_number: normalized_phone, code: code)
return { success: false, error: "Invalid or expired OTP", attempts_remaining: 0 } unless otp
otp.increment!(:attempts)
# Lock out after 3 failed attempts
if otp.attempts > 3
otp.update!(expires_at: Time.current)
return { success: false, error: "Too many attempts. OTP expired.", attempts_remaining: 0 }
end
# Successfully verified
otp.update!(verified: true, verified_at: Time.current)
{ success: true, verified: true }
end
# Generate and send OTP
def self.send_otp(phone_number, purpose: "authentication", expiry_minutes: 5, ip_address: nil)
normalized_phone = normalize_phone_number_string(phone_number)
otp = create!(
phone_number: normalized_phone,
purpose: purpose,
expires_at: expiry_minutes.minutes.from_now,
ip_address: ip_address
)
# Create SMS message for sending
sms = SmsMessage.create!(
direction: "outbound",
phone_number: normalized_phone,
message_body: "Your OTP code is: #{otp.code}. Valid for #{expiry_minutes} minutes. Do not share this code."
)
{ otp: otp, sms: sms }
end
# Clean up expired OTP codes
def self.cleanup_expired!
expired.where(verified: false).delete_all
end
# Check if OTP is still active and usable
def active_and_usable?
!verified && expires_at > Time.current && attempts < 3
end
private
def generate_code
self.code = format("%06d", SecureRandom.random_number(1_000_000))
end
def set_expiry
self.expires_at = 5.minutes.from_now
end
def normalize_phone_number
return unless phone_number.present?
self.phone_number = self.class.normalize_phone_number_string(phone_number)
end
def self.normalize_phone_number_string(number)
number.gsub(/[^\d+]/, "")
end
def phone_number_format
return unless phone_number.present?
phone = Phonelib.parse(phone_number)
unless phone.valid?
errors.add(:phone_number, "is not a valid phone number")
end
end
def rate_limit_check
return unless phone_number.present?
# Max 3 OTP per phone per hour
recent_count = OtpCode.where(phone_number: phone_number)
.where("created_at > ?", 1.hour.ago)
.count
if recent_count >= 3
errors.add(:base, "Rate limit exceeded. Maximum 3 OTP codes per hour.")
end
end
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end

110
app/models/sms_message.rb Normal file
View File

@@ -0,0 +1,110 @@
class SmsMessage < ApplicationRecord
# Normalize metadata to always be a Hash
attribute :metadata, :jsonb, default: {}
belongs_to :gateway, optional: true
validates :phone_number, presence: true
validates :message_body, presence: true, length: { maximum: 1600 }
validates :direction, presence: true, inclusion: { in: %w[inbound outbound] }
validates :message_id, presence: true, uniqueness: true
validates :status, inclusion: { in: %w[pending queued sent delivered failed] }
validate :phone_number_format
before_validation :ensure_metadata_is_hash
before_validation :generate_message_id, on: :create
before_validation :normalize_phone_number
after_create_commit :enqueue_sending, if: :outbound?
scope :pending, -> { where(status: "pending") }
scope :queued, -> { where(status: "queued") }
scope :sent, -> { where(status: "sent") }
scope :delivered, -> { where(status: "delivered") }
scope :failed, -> { where(status: "failed") }
scope :inbound, -> { where(direction: "inbound") }
scope :outbound, -> { where(direction: "outbound") }
scope :recent, -> { order(created_at: :desc) }
# Check message direction
def outbound?
direction == "outbound"
end
def inbound?
direction == "inbound"
end
# Check if message can be retried
def can_retry?
failed? && retry_count < 3
end
# Mark message as sent
def mark_sent!(gateway)
update!(
status: "sent",
gateway: gateway,
sent_at: Time.current
)
gateway.increment_sent_count!
end
# Mark message as delivered
def mark_delivered!
update!(
status: "delivered",
delivered_at: Time.current
)
end
# Mark message as failed
def mark_failed!(error_msg = nil)
update!(
status: "failed",
failed_at: Time.current,
error_message: error_msg
)
end
# Increment retry counter
def increment_retry!
increment!(:retry_count)
end
# Check if message status is failed
def failed?
status == "failed"
end
private
def generate_message_id
self.message_id ||= "msg_#{SecureRandom.hex(16)}"
end
def normalize_phone_number
return unless phone_number.present?
# Remove spaces and special characters
self.phone_number = phone_number.gsub(/[^\d+]/, "")
end
def phone_number_format
return unless phone_number.present?
phone = Phonelib.parse(phone_number)
unless phone.valid?
errors.add(:phone_number, "is not a valid phone number")
end
end
def enqueue_sending
SendSmsJob.perform_later(id)
end
def ensure_metadata_is_hash
self.metadata = {} if metadata.nil?
self.metadata = {} unless metadata.is_a?(Hash)
end
end

View File

@@ -0,0 +1,54 @@
class WebhookConfig < ApplicationRecord
validates :name, presence: true
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
validates :event_type, presence: true, inclusion: { in: %w[sms_received sms_sent sms_failed] }
validates :timeout, numericality: { greater_than: 0, less_than_or_equal_to: 120 }
validates :retry_count, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 5 }
scope :active_webhooks, -> { where(active: true) }
scope :for_event, ->(event_type) { where(event_type: event_type) }
# Trigger webhook with payload
def trigger(payload)
return unless active?
TriggerWebhookJob.perform_later(id, payload)
end
# Execute webhook HTTP request
def execute(payload)
headers = {
"Content-Type" => "application/json",
"User-Agent" => "MySMSAPI-Webhook/1.0"
}
# Add signature if secret key is present
if secret_key.present?
signature = generate_signature(payload)
headers["X-Webhook-Signature"] = signature
end
response = HTTParty.post(
url,
body: payload.to_json,
headers: headers,
timeout: timeout
)
response.success?
rescue StandardError => e
Rails.logger.error("Webhook execution failed: #{e.message}")
false
end
# Find active webhooks for a specific event
def self.for_event_type(event_type)
active_webhooks.for_event(event_type)
end
private
def generate_signature(payload)
OpenSSL::HMAC.hexdigest("SHA256", secret_key, payload.to_json)
end
end

View File

@@ -0,0 +1,101 @@
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">API Keys</h1>
<p class="mt-2 text-sm text-gray-600">Manage API keys for client access and authentication</p>
</div>
<%= link_to new_admin_api_key_path, class: "mt-4 sm:mt-0 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %>
<i class="fas fa-plus"></i>
Create New API Key
<% end %>
</div>
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
<% if @api_keys.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Key Prefix</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Permissions</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Used</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% @api_keys.each do |api_key| %>
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900"><%= api_key.name %></div>
</td>
<td class="px-6 py-4">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= api_key.key_prefix %>...</code>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
<% perms = api_key.permissions || {} %>
<% perms = {} unless perms.is_a?(Hash) %>
<% if perms.any? %>
<% perms.select { |_, v| v }.keys.each do |perm| %>
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<%= perm.to_s.humanize %>
</span>
<% end %>
<% else %>
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">None</span>
<% end %>
</div>
</td>
<td class="px-6 py-4">
<% if api_key.active_and_valid? %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Active
</span>
<% elsif !api_key.active %>
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Revoked</span>
<% else %>
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Expired</span>
<% end %>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<% if api_key.last_used_at %>
<%= time_ago_in_words(api_key.last_used_at) %> ago
<% else %>
Never
<% end %>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<%= api_key.created_at.strftime("%Y-%m-%d") %>
</td>
<td class="px-6 py-4 text-right text-sm font-medium">
<% if api_key.active %>
<%= button_to admin_api_key_path(api_key), method: :delete, class: "inline-flex items-center gap-1 text-red-600 hover:text-red-900", data: { confirm: "Are you sure you want to revoke this API key?" } do %>
<i class="fas fa-ban"></i>
Revoke
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-14 text-center">
<i class="fas fa-key text-4xl text-gray-300"></i>
<h3 class="mt-4 text-sm font-semibold text-gray-900">No API keys</h3>
<p class="mt-2 text-sm text-gray-500">Get started by creating a new API key.</p>
<div class="mt-6">
<%= link_to new_admin_api_key_path, class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
<i class="fas fa-plus"></i>
Create API Key
<% end %>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,105 @@
<div class="space-y-6">
<!-- Page header with back link -->
<div class="flex items-center gap-4">
<%= link_to admin_api_keys_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
<i class="fas fa-arrow-left"></i>
Back to API Keys
<% end %>
</div>
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Create New API Key</h1>
<p class="mt-2 text-sm text-gray-600">Generate a new API key with specific permissions for your application.</p>
</div>
<!-- Form card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-8 max-w-2xl">
<%= form_with url: admin_api_keys_path, method: :post, local: true, class: "space-y-6" do |f| %>
<!-- Name field -->
<div>
<%= label_tag "api_key[name]", "Name", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-tag text-gray-400"></i>
</div>
<%= text_field_tag "api_key[name]", nil,
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
placeholder: "My Application",
required: true %>
</div>
<p class="mt-1 text-sm text-gray-500">A descriptive name to identify this API key</p>
</div>
<!-- Permissions checkboxes -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Permissions</label>
<div class="space-y-3">
<label class="relative flex items-start">
<div class="flex items-center h-6">
<%= check_box_tag "api_key[send_sms]", "1", true,
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
</div>
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">Send SMS</span>
<p class="text-sm text-gray-500">Allow sending outbound SMS messages</p>
</div>
</label>
<label class="relative flex items-start">
<div class="flex items-center h-6">
<%= check_box_tag "api_key[receive_sms]", "1", true,
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
</div>
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">Receive SMS</span>
<p class="text-sm text-gray-500">Allow receiving and querying inbound SMS</p>
</div>
</label>
<label class="relative flex items-start">
<div class="flex items-center h-6">
<%= check_box_tag "api_key[manage_gateways]", "1", false,
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
</div>
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">Manage Gateways</span>
<p class="text-sm text-gray-500">Allow managing gateway devices and settings</p>
</div>
</label>
<label class="relative flex items-start">
<div class="flex items-center h-6">
<%= check_box_tag "api_key[manage_otp]", "1", true,
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
</div>
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">Manage OTP</span>
<p class="text-sm text-gray-500">Allow generating and verifying OTP codes</p>
</div>
</label>
</div>
</div>
<!-- Expiration field -->
<div>
<%= label_tag "api_key[expires_at]", "Expiration Date (Optional)", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-calendar text-gray-400"></i>
</div>
<%= datetime_local_field_tag "api_key[expires_at]", nil,
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %>
</div>
<p class="mt-1 text-sm text-gray-500">Leave empty for no expiration</p>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-3 pt-4">
<%= submit_tag "Create API Key",
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
<%= link_to "Cancel", admin_api_keys_path,
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,139 @@
<div class="space-y-6">
<!-- Page header -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">API Key Created Successfully!</h1>
<p class="mt-2 text-sm text-gray-600">Your new API key has been generated and is ready to use.</p>
</div>
<!-- Warning alert -->
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-yellow-800">Important: Save this key now!</h3>
<p class="mt-1 text-sm text-yellow-700">
This is the only time you'll be able to see the full API key. Make sure to copy it and store it securely.
If you lose it, you'll need to generate a new key.
</p>
</div>
</div>
</div>
<!-- API Key display card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Your New API Key</h3>
<button
onclick="copyToClipboard('<%= @raw_key %>')"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200">
<i class="fas fa-copy"></i>
Copy to Clipboard
</button>
</div>
<div class="relative rounded-lg bg-gray-900 px-4 py-4 overflow-x-auto">
<code class="text-sm font-mono text-green-400 break-all"><%= @raw_key %></code>
</div>
</div>
<!-- API Key Details card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Key Details</h3>
<dl class="divide-y divide-gray-100">
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Name</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @api_key.name %></dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Key Prefix</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @api_key.key_prefix %>...</code>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Permissions</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<div class="flex flex-wrap gap-2">
<% perms = @api_key.permissions || {} %>
<% perms = {} unless perms.is_a?(Hash) %>
<% perms.select { |_, v| v }.keys.each do |perm| %>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<i class="fas fa-check"></i>
<%= perm.to_s.humanize %>
</span>
<% end %>
</div>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Expiration</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<% if @api_key.expires_at %>
<div class="flex items-center gap-2">
<i class="fas fa-calendar text-gray-400"></i>
<%= @api_key.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
</div>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-infinity"></i>
Never expires
</span>
<% end %>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Status</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Active
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Created</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<%= @api_key.created_at.strftime("%B %d, %Y at %l:%M %p") %>
</dd>
</div>
</dl>
</div>
<!-- Back link -->
<div class="flex items-center gap-3">
<%= link_to admin_api_keys_path,
class: "inline-flex items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" do %>
<i class="fas fa-arrow-left"></i>
Back to API Keys
<% end %>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show success feedback
const button = event.target.closest('button');
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
button.classList.add('bg-green-600', 'hover:bg-green-500');
button.classList.remove('bg-blue-600', 'hover:bg-blue-500');
setTimeout(function() {
button.innerHTML = originalHTML;
button.classList.remove('bg-green-600', 'hover:bg-green-500');
button.classList.add('bg-blue-600', 'hover:bg-blue-500');
}, 2000);
}, function(err) {
alert('Failed to copy: ' + err);
});
}
</script>

View File

@@ -0,0 +1,466 @@
<div class="space-y-6">
<!-- Page header -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">API Tester</h1>
<p class="mt-2 text-sm text-gray-600">Test all API endpoints with interactive forms. View request/response in real-time.</p>
</div>
<!-- API Key Selection Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Authentication</h3>
<div class="space-y-4">
<!-- Available API Keys (for reference) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Available API Keys (Reference Only)</label>
<div class="rounded-lg bg-gray-50 p-4 space-y-2">
<% if @api_keys.any? %>
<% @api_keys.each do |key| %>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">
<i class="fas fa-key text-blue-500"></i> <%= key.name %>
</span>
<span class="font-mono text-xs text-gray-500"><%= key.key_prefix %>***</span>
</div>
<% end %>
<p class="text-xs text-gray-500 mt-2">
<i class="fas fa-info-circle"></i>
Raw API keys are only shown once during creation for security. Enter your saved API key below.
</p>
<% else %>
<p class="text-sm text-gray-500">No API keys found. Create one first.</p>
<% end %>
</div>
</div>
<!-- Enter API Key -->
<div>
<label for="api-key-input" class="block text-sm font-medium text-gray-700 mb-2">
Enter API Key <span class="text-red-500">*</span>
</label>
<input
type="text"
id="api-key-input"
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs"
placeholder="api_live_... or gw_live_..."
required>
<p class="mt-1 text-xs text-gray-500">
Client API keys start with <code class="bg-gray-100 px-1 py-0.5 rounded">api_live_</code>,
Gateway keys start with <code class="bg-gray-100 px-1 py-0.5 rounded">gw_live_</code>
</p>
</div>
<!-- Base URL -->
<div>
<label for="base-url" class="block text-sm font-medium text-gray-700 mb-2">Base URL</label>
<input
type="text"
id="base-url"
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs"
value="<%= request.base_url %>">
</div>
</div>
</div>
<!-- Endpoint Testing Tabs -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
<!-- Tab Navigation -->
<div class="border-b border-gray-200">
<nav class="flex gap-4 px-6 pt-6" aria-label="Tabs">
<button onclick="switchTab('send-sms')" id="tab-send-sms" class="tab-button active">
<i class="fas fa-paper-plane"></i> Send SMS
</button>
<button onclick="switchTab('check-status')" id="tab-check-status" class="tab-button">
<i class="fas fa-info-circle"></i> Check Status
</button>
<button onclick="switchTab('send-otp')" id="tab-send-otp" class="tab-button">
<i class="fas fa-key"></i> Send OTP
</button>
<button onclick="switchTab('verify-otp')" id="tab-verify-otp" class="tab-button">
<i class="fas fa-check-circle"></i> Verify OTP
</button>
<button onclick="switchTab('gateway-register')" id="tab-gateway-register" class="tab-button">
<i class="fas fa-mobile-alt"></i> Register Gateway
</button>
<button onclick="switchTab('gateway-heartbeat')" id="tab-gateway-heartbeat" class="tab-button">
<i class="fas fa-heartbeat"></i> Heartbeat
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="px-6 py-6">
<!-- Send SMS Tab -->
<div id="content-send-sms" class="tab-content active">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send SMS</h3>
<p class="text-sm text-gray-600 mb-4">POST /api/v1/sms/send</p>
<form id="form-send-sms" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Message</label>
<textarea name="message" rows="3" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="Your message here" required></textarea>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-paper-plane"></i> Send SMS
</button>
</form>
</div>
<!-- Check Status Tab -->
<div id="content-check-status" class="tab-content">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Check SMS Status</h3>
<p class="text-sm text-gray-600 mb-4">GET /api/v1/sms/status/:message_id</p>
<form id="form-check-status" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Message ID</label>
<input type="text" name="message_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs" placeholder="msg_abc123..." required>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-search"></i> Check Status
</button>
</form>
</div>
<!-- Send OTP Tab -->
<div id="content-send-otp" class="tab-content">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send OTP</h3>
<p class="text-sm text-gray-600 mb-4">POST /api/v1/otp/send</p>
<form id="form-send-otp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-key"></i> Send OTP
</button>
</form>
</div>
<!-- Verify OTP Tab -->
<div id="content-verify-otp" class="tab-content">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Verify OTP</h3>
<p class="text-sm text-gray-600 mb-4">POST /api/v1/otp/verify</p>
<form id="form-verify-otp" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">OTP Code</label>
<input type="text" name="code" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="123456" maxlength="6" required>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-check-circle"></i> Verify OTP
</button>
</form>
</div>
<!-- Gateway Register Tab -->
<div id="content-gateway-register" class="tab-content">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Register Gateway</h3>
<p class="text-sm text-gray-600 mb-4">POST /api/v1/gateway/register</p>
<form id="form-gateway-register" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Device ID</label>
<input type="text" name="device_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="android-001" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Device Name</label>
<input type="text" name="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="My Phone" required>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-mobile-alt"></i> Register Gateway
</button>
</form>
</div>
<!-- Gateway Heartbeat Tab -->
<div id="content-gateway-heartbeat" class="tab-content">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send Heartbeat</h3>
<p class="text-sm text-gray-600 mb-4">POST /api/v1/gateway/heartbeat</p>
<form id="form-gateway-heartbeat" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Battery Level (%)</label>
<input type="number" name="battery_level" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="85" min="0" max="100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Signal Strength (0-4)</label>
<input type="number" name="signal_strength" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="4" min="0" max="4">
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
<i class="fas fa-heartbeat"></i> Send Heartbeat
</button>
</form>
</div>
</div>
</div>
<!-- Request/Response Display -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Request -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Request</h3>
<button onclick="copyRequest()" class="text-sm text-blue-600 hover:text-blue-500">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<pre id="request-display" class="bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-800 overflow-x-auto max-h-96 overflow-y-auto"><span class="text-gray-400">Request will appear here...</span></pre>
</div>
<!-- Response -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Response</h3>
<button onclick="copyResponse()" class="text-sm text-blue-600 hover:text-blue-500">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<pre id="response-display" class="bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-800 overflow-x-auto max-h-96 overflow-y-auto"><span class="text-gray-400">Response will appear here...</span></pre>
</div>
</div>
<!-- Info Card -->
<div class="rounded-lg bg-blue-50 px-4 py-4 ring-1 ring-blue-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-blue-800">API Testing Tips</h3>
<ul class="mt-2 text-sm text-blue-700 list-disc list-inside space-y-1">
<li>Select an API key from the dropdown or enter a custom key</li>
<li>All requests use the Authorization header with Bearer token</li>
<li>Gateway endpoints require gateway API keys (gw_live_...)</li>
<li>Client endpoints require client API keys (api_live_...)</li>
<li>View full request and response in real-time</li>
</ul>
</div>
</div>
</div>
</div>
<style>
.tab-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button:hover {
color: #111827;
}
.tab-button.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
<script>
let currentRequest = '';
let currentResponse = '';
// Tab switching
function switchTab(tabName) {
// Hide all tab contents
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// Deactivate all tab buttons
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
});
// Show selected tab content
document.getElementById('content-' + tabName).classList.add('active');
document.getElementById('tab-' + tabName).classList.add('active');
}
// Get API key
function getApiKey() {
const apiKey = document.getElementById('api-key-input').value.trim();
if (!apiKey) {
alert('Please enter an API key');
return null;
}
// Validate format
if (!apiKey.startsWith('api_live_') && !apiKey.startsWith('gw_live_')) {
alert('Invalid API key format. Keys should start with "api_live_" or "gw_live_"');
return null;
}
return apiKey;
}
// Get base URL
function getBaseUrl() {
return document.getElementById('base-url').value.trim();
}
// Display request
function displayRequest(method, url, headers, body) {
let request = `${method} ${url}\n\n`;
request += 'Headers:\n';
Object.entries(headers).forEach(([key, value]) => {
request += `${key}: ${value}\n`;
});
if (body) {
request += '\nBody:\n';
request += JSON.stringify(body, null, 2);
}
currentRequest = request;
document.getElementById('request-display').textContent = request;
}
// Display response
function displayResponse(status, statusText, data) {
let response = `Status: ${status} ${statusText}\n\n`;
response += JSON.stringify(data, null, 2);
currentResponse = response;
const displayElement = document.getElementById('response-display');
displayElement.textContent = response;
// Color code based on status
if (status >= 200 && status < 300) {
displayElement.classList.add('text-green-700');
displayElement.classList.remove('text-red-700', 'text-gray-800');
} else {
displayElement.classList.add('text-red-700');
displayElement.classList.remove('text-green-700', 'text-gray-800');
}
}
// Copy functions
function copyRequest() {
navigator.clipboard.writeText(currentRequest);
alert('Request copied to clipboard!');
}
function copyResponse() {
navigator.clipboard.writeText(currentResponse);
alert('Response copied to clipboard!');
}
// Generic API call
async function makeApiCall(method, endpoint, body = null) {
const apiKey = getApiKey();
if (!apiKey) return;
const baseUrl = getBaseUrl();
const url = `${baseUrl}${endpoint}`;
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
displayRequest(method, endpoint, headers, body);
try {
const options = {
method: method,
headers: headers
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
const data = await response.json();
displayResponse(response.status, response.statusText, data);
} catch (error) {
displayResponse(0, 'Error', { error: error.message });
}
}
// Form handlers
document.getElementById('form-send-sms').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const body = {
to: formData.get('phone_number'), // API expects 'to' parameter
message: formData.get('message')
};
await makeApiCall('POST', '/api/v1/sms/send', body);
});
document.getElementById('form-check-status').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const messageId = formData.get('message_id');
await makeApiCall('GET', `/api/v1/sms/status/${messageId}`);
});
document.getElementById('form-send-otp').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const body = {
phone_number: formData.get('phone_number')
};
await makeApiCall('POST', '/api/v1/otp/send', body);
});
document.getElementById('form-verify-otp').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const body = {
phone_number: formData.get('phone_number'),
code: formData.get('code')
};
await makeApiCall('POST', '/api/v1/otp/verify', body);
});
document.getElementById('form-gateway-register').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const body = {
device_id: formData.get('device_id'),
name: formData.get('name')
};
await makeApiCall('POST', '/api/v1/gateway/register', body);
});
document.getElementById('form-gateway-heartbeat').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const body = {
device_info: {
battery_level: parseInt(formData.get('battery_level')) || 0,
signal_strength: parseInt(formData.get('signal_strength')) || 0
}
};
await makeApiCall('POST', '/api/v1/gateway/heartbeat', body);
});
</script>

View File

@@ -0,0 +1,230 @@
<div class="space-y-8">
<!-- Page header -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Dashboard</h1>
<p class="mt-2 text-sm text-gray-600">Welcome back! Here's what's happening with your SMS gateway today.</p>
</div>
<!-- Stats grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- Gateways stat -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-blue-500 p-3">
<i class="fas fa-mobile-alt text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Gateways</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:total_gateways] %></p>
<p class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<%= @stats[:online_gateways] %> online
</p>
</dd>
</div>
<!-- API Keys stat -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-green-500 p-3">
<i class="fas fa-key text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">API Keys</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:active_api_keys] %></p>
<p class="ml-2 flex items-baseline text-sm text-gray-600">
of <%= @stats[:total_api_keys] %> total
</p>
</dd>
</div>
<!-- Messages Today stat -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-yellow-500 p-3">
<i class="fas fa-paper-plane text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Today</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:messages_today] %></p>
</dd>
<div class="ml-16 mt-1 flex items-center gap-3 text-xs">
<span class="inline-flex items-center gap-1 text-green-600">
<i class="fas fa-arrow-up"></i> <%= @stats[:messages_sent_today] %> sent
</span>
<span class="inline-flex items-center gap-1 text-blue-600">
<i class="fas fa-arrow-down"></i> <%= @stats[:messages_received_today] %> received
</span>
</div>
</div>
<!-- Failed Messages stat -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-red-500 p-3">
<i class="fas fa-exclamation-triangle text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Failed Today</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:failed_messages_today] %></p>
<p class="ml-2 flex items-baseline text-sm text-gray-600">
<%= @stats[:pending_messages] %> pending
</p>
</dd>
</div>
</div>
<!-- Recent Messages -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 class="text-lg font-semibold leading-6 text-gray-900">Recent Messages</h3>
<p class="mt-1 text-sm text-gray-500">Latest SMS activity across all gateways</p>
</div>
<div class="overflow-hidden">
<% if @recent_messages.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Phone Number</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Direction</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Gateway</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% @recent_messages.each do |msg| %>
<tr class="hover:bg-gray-50 transition-colors">
<td class="whitespace-nowrap px-6 py-4 text-sm">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= msg.message_id[0..15] %>...</code>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"><%= msg.phone_number %></td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% if msg.direction == "outbound" %>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<i class="fas fa-arrow-up"></i> Outbound
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-arrow-down"></i> Inbound
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% case msg.status %>
<% when "delivered" %>
<span class="inline-flex rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">Delivered</span>
<% when "sent" %>
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Sent</span>
<% when "failed" %>
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Failed</span>
<% when "pending" %>
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Pending</span>
<% else %>
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10"><%= msg.status.titleize %></span>
<% end %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500"><%= msg.gateway&.name || "-" %></td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500"><%= time_ago_in_words(msg.created_at) %> ago</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-6 py-4">
<%= link_to admin_logs_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %>
View all logs
<i class="fas fa-arrow-right"></i>
<% end %>
</div>
<% else %>
<div class="px-6 py-14 text-center">
<i class="fas fa-inbox text-4xl text-gray-300"></i>
<p class="mt-4 text-sm text-gray-500">No messages yet</p>
</div>
<% end %>
</div>
</div>
<!-- Gateway Status -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 class="text-lg font-semibold leading-6 text-gray-900">Gateway Status</h3>
<p class="mt-1 text-sm text-gray-500">Active gateway devices and their performance</p>
</div>
<div class="overflow-hidden">
<% if @recent_gateways.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Device ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Messages Today</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Heartbeat</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% @recent_gateways.each do |gateway| %>
<tr class="hover:bg-gray-50 transition-colors">
<td class="whitespace-nowrap px-6 py-4">
<%= link_to gateway.name, admin_gateway_path(gateway), class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= gateway.device_id %></code>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% if gateway.status == "online" %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Online
</span>
<% else %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
Offline
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1 text-green-600">
<i class="fas fa-arrow-up text-xs"></i> <%= gateway.messages_sent_today %>
</span>
<span class="mx-1 text-gray-300">|</span>
<span class="inline-flex items-center gap-1 text-blue-600">
<i class="fas fa-arrow-down text-xs"></i> <%= gateway.messages_received_today %>
</span>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<% if gateway.last_heartbeat_at %>
<%= time_ago_in_words(gateway.last_heartbeat_at) %> ago
<% else %>
Never
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-6 py-4">
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %>
View all gateways
<i class="fas fa-arrow-right"></i>
<% end %>
</div>
<% else %>
<div class="px-6 py-14 text-center">
<i class="fas fa-mobile-alt text-4xl text-gray-300"></i>
<p class="mt-4 text-sm text-gray-500">No gateways registered yet</p>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,168 @@
<div class="space-y-6">
<!-- Page header -->
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Gateways</h1>
<p class="mt-2 text-sm text-gray-600">Manage your SMS gateway devices and monitor their status.</p>
</div>
<%= link_to new_admin_gateway_path, class: "mt-4 sm:mt-0 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %>
<i class="fas fa-plus"></i>
Register New Gateway
<% end %>
</div>
<!-- Gateways table card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
<% if @gateways.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Device ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Active</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Priority</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Today</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Total</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Heartbeat</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wide text-gray-500">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% @gateways.each do |gateway| %>
<tr class="hover:bg-gray-50 transition-colors">
<!-- Name -->
<td class="whitespace-nowrap px-6 py-4">
<%= link_to gateway.name, admin_gateway_path(gateway),
class: "text-sm font-semibold text-blue-600 hover:text-blue-500 transition-colors" %>
</td>
<!-- Device ID -->
<td class="whitespace-nowrap px-6 py-4">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= gateway.device_id %></code>
</td>
<!-- Status with pulse indicator -->
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% if gateway.status == "online" %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Online
</span>
<% else %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
Offline
</span>
<% end %>
</td>
<!-- Active badge -->
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% if gateway.active %>
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-check mr-1"></i> Active
</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">
<i class="fas fa-times mr-1"></i> Inactive
</span>
<% end %>
</td>
<!-- Priority -->
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 font-medium text-center">
<%= gateway.priority %>
</td>
<!-- Messages Today -->
<td class="whitespace-nowrap px-6 py-4 text-sm">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 text-green-600">
<i class="fas fa-arrow-up text-xs"></i>
<span class="font-medium"><%= gateway.messages_sent_today %></span>
</span>
<span class="text-gray-300">|</span>
<span class="inline-flex items-center gap-1 text-blue-600">
<i class="fas fa-arrow-down text-xs"></i>
<span class="font-medium"><%= gateway.messages_received_today %></span>
</span>
</div>
</td>
<!-- Total Messages -->
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 text-green-600">
<i class="fas fa-arrow-up text-xs"></i>
<span><%= gateway.total_messages_sent %></span>
</span>
<span class="text-gray-300">|</span>
<span class="inline-flex items-center gap-1 text-blue-600">
<i class="fas fa-arrow-down text-xs"></i>
<span><%= gateway.total_messages_received %></span>
</span>
</div>
</td>
<!-- Last Heartbeat -->
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<% if gateway.last_heartbeat_at %>
<div class="flex flex-col">
<span class="font-medium text-gray-900"><%= time_ago_in_words(gateway.last_heartbeat_at) %> ago</span>
<span class="text-xs text-gray-400"><%= gateway.last_heartbeat_at.strftime("%m/%d/%y %H:%M") %></span>
</div>
<% else %>
<span class="text-gray-400 italic">Never</span>
<% end %>
</td>
<!-- Created -->
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<%= gateway.created_at.strftime("%m/%d/%y") %>
</td>
<!-- Actions -->
<td class="whitespace-nowrap px-6 py-4 text-center">
<div class="flex items-center justify-center gap-2">
<%= link_to test_admin_gateway_path(gateway),
class: "inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %>
<i class="fas fa-vial"></i>
Test
<% end %>
<%= button_to toggle_admin_gateway_path(gateway), method: :post,
class: gateway.active ?
"inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" :
"inline-flex items-center gap-2 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %>
<% if gateway.active %>
<i class="fas fa-ban"></i>
Deactivate
<% else %>
<i class="fas fa-check"></i>
Activate
<% end %>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<!-- Empty state -->
<div class="px-6 py-14 text-center">
<div class="mx-auto h-20 w-20 flex items-center justify-center rounded-full bg-gray-100">
<i class="fas fa-mobile-alt text-3xl text-gray-400"></i>
</div>
<p class="mt-4 text-base font-medium text-gray-900">No gateways registered yet</p>
<p class="mt-2 text-sm text-gray-500">Gateway devices will appear here once they connect via the API.</p>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,87 @@
<div class="space-y-6">
<!-- Page header with back link -->
<div class="flex items-center gap-4">
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
<i class="fas fa-arrow-left"></i>
Back to Gateways
<% end %>
</div>
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Register New Gateway</h1>
<p class="mt-2 text-sm text-gray-600">Add a new Android gateway device to your SMS system.</p>
</div>
<!-- Form card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-8 max-w-2xl">
<%= form_with url: admin_gateways_path, method: :post, local: true, class: "space-y-6" do |f| %>
<!-- Device ID field -->
<div>
<%= label_tag "gateway[device_id]", "Device ID", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-mobile-alt text-gray-400"></i>
</div>
<%= text_field_tag "gateway[device_id]", nil,
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
placeholder: "device-001",
required: true %>
</div>
<p class="mt-1 text-sm text-gray-500">A unique identifier for this gateway device (e.g., phone model, serial number)</p>
</div>
<!-- Name field -->
<div>
<%= label_tag "gateway[name]", "Gateway Name", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-tag text-gray-400"></i>
</div>
<%= text_field_tag "gateway[name]", nil,
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
placeholder: "Office Phone",
required: true %>
</div>
<p class="mt-1 text-sm text-gray-500">A friendly name to identify this gateway</p>
</div>
<!-- Priority field -->
<div>
<%= label_tag "gateway[priority]", "Priority", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-sort-numeric-up text-gray-400"></i>
</div>
<%= number_field_tag "gateway[priority]", 1,
min: 1,
max: 10,
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %>
</div>
<p class="mt-1 text-sm text-gray-500">Priority level (1-10). Higher priority gateways are used first for sending messages.</p>
</div>
<!-- Information box -->
<div class="rounded-lg bg-blue-50 px-4 py-4 ring-1 ring-blue-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-600 text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-blue-800">Gateway API Key</h3>
<p class="mt-1 text-sm text-blue-700">
After creating the gateway, you'll receive a unique API key. You'll need to configure this key in your Android gateway app to connect it to the system.
</p>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-3 pt-4">
<%= submit_tag "Register Gateway",
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
<%= link_to "Cancel", admin_gateways_path,
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,565 @@
<div class="space-y-6">
<!-- Back link -->
<div class="flex items-center gap-4">
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
<i class="fas fa-arrow-left"></i>
Back to Gateways
<% end %>
</div>
<% if @is_new && @raw_key.present? %>
<!-- New gateway created - show API key -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Gateway Created Successfully!</h1>
<p class="mt-2 text-sm text-gray-600">Your new gateway has been registered and is ready to connect.</p>
</div>
<!-- Warning alert -->
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-yellow-800">Important: Save this API key now!</h3>
<p class="mt-1 text-sm text-yellow-700">
This is the only time you'll be able to see the full API key. You need to configure this key in your Android gateway app to connect it to the system.
</p>
</div>
</div>
</div>
<!-- QR Code and API Key display -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- QR Code Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Scan QR Code</h3>
<p class="text-sm text-gray-600 mb-6">Scan this QR code with your Android gateway app to auto-configure</p>
<div class="flex justify-center mb-4">
<div class="bg-white p-4 rounded-lg shadow-inner border-2 border-gray-200">
<%= @qr_code_data.html_safe %>
</div>
</div>
<div class="rounded-lg bg-blue-50 px-4 py-3 ring-1 ring-blue-600/10">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle"></i>
QR code contains API key, API base URL, and WebSocket URL
</p>
</div>
</div>
</div>
<!-- API Key Manual Entry Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900">Manual Configuration</h3>
<button
onclick="copyAllConfig()"
class="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-500 transition-all duration-200">
<i class="fas fa-copy"></i>
Copy All
</button>
</div>
<p class="text-sm text-gray-600">Or manually enter these details if QR scanning is unavailable</p>
</div>
<div class="space-y-4">
<!-- API Base URL -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">API Base URL</label>
<div class="flex items-center gap-2">
<div class="flex-1 relative rounded-lg bg-gray-50 px-3 py-2 border border-gray-200">
<code class="text-xs font-mono text-gray-800 break-all" id="api-base-url"><%= request.base_url %></code>
</div>
<button
onclick="copyField('api-base-url')"
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<!-- WebSocket URL -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">WebSocket URL</label>
<div class="flex items-center gap-2">
<div class="flex-1 relative rounded-lg bg-gray-50 px-3 py-2 border border-gray-200">
<code class="text-xs font-mono text-gray-800 break-all" id="ws-url"><%= request.base_url.sub(/^http/, 'ws') %>/cable</code>
</div>
<button
onclick="copyField('ws-url')"
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<!-- API Key -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">API Key</label>
<div class="flex items-center gap-2">
<div class="flex-1 relative rounded-lg bg-gray-900 px-3 py-2">
<code class="text-xs font-mono text-green-400 break-all" id="api-key"><%= @raw_key %></code>
</div>
<button
onclick="copyField('api-key')"
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Gateway Details card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Gateway Details</h3>
<dl class="divide-y divide-gray-100">
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @gateway.device_id %></code>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Name</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Priority</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
<%= @gateway.priority %>
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Status</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
Offline (Waiting for connection)
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Created</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %>
</dd>
</div>
</dl>
</div>
<!-- Next steps card -->
<div class="rounded-lg bg-blue-50 px-6 py-6 ring-1 ring-blue-600/10">
<h3 class="text-sm font-semibold text-blue-800 mb-3">Quick Setup Guide</h3>
<div class="mb-4">
<h4 class="text-xs font-semibold text-blue-800 mb-2">Option 1: QR Code (Recommended)</h4>
<ol class="list-decimal list-inside space-y-1.5 text-xs text-blue-700 ml-2">
<li>Install the Android SMS Gateway app on your device</li>
<li>Open the app and look for "Scan QR Code" option</li>
<li>Scan the QR code above - configuration will be applied automatically</li>
<li>Start the gateway service in the app</li>
</ol>
</div>
<div>
<h4 class="text-xs font-semibold text-blue-800 mb-2">Option 2: Manual Entry</h4>
<ol class="list-decimal list-inside space-y-1.5 text-xs text-blue-700 ml-2">
<li>Install the Android SMS Gateway app on your device</li>
<li>Open the app and navigate to Settings</li>
<li>Copy and paste each field from the "Manual Configuration" section above</li>
<li>Save the configuration and start the gateway service</li>
</ol>
</div>
<div class="mt-4 pt-4 border-t border-blue-200">
<p class="text-xs text-blue-700">
<i class="fas fa-info-circle"></i>
The gateway will appear as <span class="font-semibold">"Online"</span> once it successfully connects to the server.
</p>
</div>
</div>
<script>
function copyField(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent;
navigator.clipboard.writeText(text).then(function() {
const button = event.target.closest('button');
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
button.classList.add('bg-green-600', 'hover:bg-green-500');
button.classList.remove('bg-blue-600', 'hover:bg-blue-500');
setTimeout(function() {
button.innerHTML = originalHTML;
button.classList.remove('bg-green-600', 'hover:bg-green-500');
button.classList.add('bg-blue-600', 'hover:bg-blue-500');
}, 2000);
}, function(err) {
alert('Failed to copy: ' + err);
});
}
function copyAllConfig() {
const apiBaseUrl = document.getElementById('api-base-url').textContent;
const wsUrl = document.getElementById('ws-url').textContent;
const apiKey = document.getElementById('api-key').textContent;
const configText = `API Base URL: ${apiBaseUrl}\nWebSocket URL: ${wsUrl}\nAPI Key: ${apiKey}`;
navigator.clipboard.writeText(configText).then(function() {
const button = event.target.closest('button');
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
button.classList.add('bg-green-600', 'hover:bg-green-500');
button.classList.remove('bg-gray-600', 'hover:bg-gray-500');
setTimeout(function() {
button.innerHTML = originalHTML;
button.classList.remove('bg-green-600', 'hover:bg-green-500');
button.classList.add('bg-gray-600', 'hover:bg-gray-500');
}, 2000);
}, function(err) {
alert('Failed to copy: ' + err);
});
}
</script>
<% else %>
<!-- Existing gateway view -->
<!-- Page header -->
<div class="border-b border-gray-200 pb-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900"><%= @gateway.name %></h1>
<p class="mt-2 text-sm text-gray-600">Gateway device details and statistics</p>
</div>
<!-- Status indicator -->
<div>
<% if @gateway.status == "online" %>
<span class="inline-flex items-center gap-2 rounded-full bg-green-50 px-4 py-2 text-sm font-medium text-green-700 ring-2 ring-inset ring-green-700/20">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
Online
</span>
<% else %>
<span class="inline-flex items-center gap-2 rounded-full bg-red-50 px-4 py-2 text-sm font-medium text-red-700 ring-2 ring-inset ring-red-700/20">
<span class="h-3 w-3 rounded-full bg-red-500"></span>
Offline
</span>
<% end %>
</div>
</div>
</div>
<!-- Stats grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Status card -->
<div class="relative overflow-hidden rounded-xl <%= @gateway.status == 'online' ? 'bg-green-500' : 'bg-red-500' %> px-4 py-5 shadow-sm sm:px-6">
<dt>
<div class="absolute rounded-lg bg-white/20 p-3">
<i class="fas fa-signal text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-white/90">Connection Status</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-white"><%= @gateway.status.titleize %></p>
</dd>
</div>
<!-- Active status card -->
<div class="relative overflow-hidden rounded-xl <%= @gateway.active ? 'bg-blue-500' : 'bg-gray-400' %> px-4 py-5 shadow-sm sm:px-6">
<dt>
<div class="absolute rounded-lg bg-white/20 p-3">
<i class="fas fa-power-off text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-white/90">Active Status</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-white"><%= @gateway.active ? 'Active' : 'Inactive' %></p>
</dd>
</div>
<!-- Priority card -->
<div class="relative overflow-hidden rounded-xl bg-purple-500 px-4 py-5 shadow-sm sm:px-6">
<dt>
<div class="absolute rounded-lg bg-white/20 p-3">
<i class="fas fa-sort-amount-up text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-white/90">Priority Level</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-white"><%= @gateway.priority %></p>
</dd>
</div>
<!-- Messages sent today -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-green-500 p-3">
<i class="fas fa-arrow-up text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Sent Today</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.messages_sent_today %></p>
</dd>
</div>
<!-- Messages received today -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-blue-500 p-3">
<i class="fas fa-arrow-down text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Received Today</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.messages_received_today %></p>
</dd>
</div>
<!-- Total messages -->
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
<dt>
<div class="absolute rounded-lg bg-yellow-500 p-3">
<i class="fas fa-paper-plane text-xl text-white"></i>
</div>
<p class="ml-16 truncate text-sm font-medium text-gray-500">Total Messages</p>
</dt>
<dd class="ml-16 flex items-baseline">
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.total_messages_sent + @gateway.total_messages_received %></p>
</dd>
</div>
</div>
<!-- Details card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Gateway Details</h3>
<dl class="divide-y divide-gray-100">
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<code class="rounded bg-gray-100 px-3 py-1.5 text-sm font-mono text-gray-800"><%= @gateway.device_id %></code>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Name</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Status</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<% if @gateway.status == "online" %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Online
</span>
<% else %>
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
Offline
</span>
<% end %>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Active</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<% if @gateway.active %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-check"></i> Active
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">
<i class="fas fa-times"></i> Inactive
</span>
<% end %>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Priority</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
<%= @gateway.priority %>
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Last Heartbeat</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<% if @gateway.last_heartbeat_at %>
<div class="flex items-center gap-2">
<i class="fas fa-heartbeat text-red-500"></i>
<span><%= @gateway.last_heartbeat_at.strftime("%B %d, %Y at %l:%M:%S %p") %></span>
<span class="text-gray-500">(<%= time_ago_in_words(@gateway.last_heartbeat_at) %> ago)</span>
</div>
<% else %>
<span class="text-gray-400 italic">Never</span>
<% end %>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Total Messages Sent</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center gap-1 text-green-600 font-semibold">
<i class="fas fa-arrow-up"></i>
<%= @gateway.total_messages_sent %>
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Total Messages Received</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center gap-1 text-blue-600 font-semibold">
<i class="fas fa-arrow-down"></i>
<%= @gateway.total_messages_received %>
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Created</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
<%= @gateway.updated_at.strftime("%B %d, %Y at %l:%M %p") %>
</dd>
</div>
</dl>
<!-- Metadata section -->
<% if @gateway.metadata.present? %>
<div class="mt-6 pt-6 border-t border-gray-200">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Device Metadata</h4>
<div class="rounded-lg bg-gray-900 px-4 py-4 overflow-x-auto">
<pre class="text-xs font-mono text-green-400"><%= JSON.pretty_generate(@gateway.metadata) %></pre>
</div>
</div>
<% end %>
<!-- Action buttons -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex items-center gap-3">
<%= link_to test_admin_gateway_path(@gateway),
class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %>
<i class="fas fa-vial"></i>
Test Gateway
<% end %>
<%= button_to toggle_admin_gateway_path(@gateway), method: :post,
class: @gateway.active ?
"inline-flex items-center gap-2 rounded-lg bg-red-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" :
"inline-flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %>
<% if @gateway.active %>
<i class="fas fa-ban"></i>
Deactivate Gateway
<% else %>
<i class="fas fa-check"></i>
Activate Gateway
<% end %>
<% end %>
</div>
</div>
</div>
<!-- Recent messages card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Recent Messages</h3>
<p class="mt-1 text-sm text-gray-500">Last <%= @recent_messages.size %> messages from this gateway</p>
</div>
<% if @recent_messages.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message ID</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Phone Number</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Direction</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% @recent_messages.each do |msg| %>
<tr class="hover:bg-gray-50 transition-colors">
<td class="whitespace-nowrap px-6 py-4 text-sm">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= msg.message_id[0..15] %>...</code>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
<%= msg.phone_number %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% if msg.direction == "outbound" %>
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
<i class="fas fa-arrow-up"></i> Outbound
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-arrow-down"></i> Inbound
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<% case msg.status %>
<% when "delivered" %>
<span class="inline-flex rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">Delivered</span>
<% when "sent" %>
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Sent</span>
<% when "failed" %>
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Failed</span>
<% when "pending" %>
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Pending</span>
<% else %>
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10"><%= msg.status.titleize %></span>
<% end %>
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<%= msg.created_at.strftime("%m/%d/%y %H:%M") %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-14 text-center">
<i class="fas fa-inbox text-4xl text-gray-300"></i>
<p class="mt-4 text-sm font-medium text-gray-900">No messages yet</p>
<p class="mt-2 text-sm text-gray-500">Messages will appear here once this gateway starts processing SMS.</p>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,353 @@
<% if @gateway.nil? %>
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
<p class="text-red-800">Error: Gateway not found</p>
<%= link_to "Back to Gateways", admin_gateways_path, class: "text-red-600 underline" %>
</div>
<% else %>
<div class="space-y-6">
<!-- Back link -->
<div class="flex items-center gap-4">
<%= link_to admin_gateway_path(@gateway), class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
<i class="fas fa-arrow-left"></i>
Back to Gateway Details
<% end %>
</div>
<!-- Page header -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Test Gateway: <%= @gateway.name %></h1>
<p class="mt-2 text-sm text-gray-600">Test connection and send test SMS messages to verify gateway functionality.</p>
</div>
<!-- Gateway Status Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Gateway Status</h3>
<button
onclick="checkConnection()"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200">
<i class="fas fa-sync-alt"></i>
Refresh Status
</button>
</div>
<div id="status-container" class="space-y-4">
<!-- Status will be loaded here -->
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
<!-- Connection Information Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Connection Information</h3>
<dl class="divide-y divide-gray-100">
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @gateway.device_id %></code>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Gateway Name</dt>
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Priority</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
<%= @gateway.priority %>
</span>
</dd>
</div>
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium text-gray-900">Active</dt>
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
<% if @gateway.active %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
<i class="fas fa-check"></i> Active
</span>
<% else %>
<span class="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
<i class="fas fa-times"></i> Inactive
</span>
<% end %>
</dd>
</div>
</dl>
</div>
<!-- Send Test SMS Card -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send Test SMS</h3>
<p class="text-sm text-gray-600 mb-6">Send a test SMS message through this gateway to verify it's working correctly.</p>
<form id="test-sms-form" class="space-y-6">
<!-- Phone Number -->
<div>
<label for="phone_number" class="block text-sm font-medium text-gray-700">Phone Number</label>
<div class="mt-1 relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<i class="fas fa-phone text-gray-400"></i>
</div>
<input
type="tel"
id="phone_number"
name="phone_number"
class="block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3"
placeholder="+959123456789"
required>
</div>
<p class="mt-1 text-sm text-gray-500">Enter phone number with country code (e.g., +959123456789)</p>
</div>
<!-- Message Body -->
<div>
<label for="message_body" class="block text-sm font-medium text-gray-700">Message</label>
<div class="mt-1">
<textarea
id="message_body"
name="message_body"
rows="4"
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3"
placeholder="This is a test message from the admin interface."
required>This is a test message from MySMSAPio admin interface. Gateway: <%= @gateway.name %></textarea>
</div>
<p class="mt-1 text-sm text-gray-500" id="char-count">160 characters remaining</p>
</div>
<!-- Result Display -->
<div id="sms-result" class="hidden">
<!-- Success/Error message will be displayed here -->
</div>
<!-- Submit Button -->
<div class="flex items-center gap-3">
<button
type="submit"
class="inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200">
<i class="fas fa-paper-plane"></i>
<span id="submit-text">Send Test SMS</span>
</button>
<button
type="button"
onclick="resetForm()"
class="inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200">
<i class="fas fa-redo"></i>
Reset Form
</button>
</div>
</form>
</div>
<!-- Warning Card -->
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
</div>
<div>
<h3 class="text-sm font-semibold text-yellow-800">Important Notes</h3>
<ul class="mt-2 text-sm text-yellow-700 list-disc list-inside space-y-1">
<li>Test SMS messages will be sent to real phone numbers</li>
<li>Ensure the gateway is online and connected before testing</li>
<li>Standard SMS charges may apply to the recipient</li>
<li>Test messages are marked with metadata for identification</li>
</ul>
</div>
</div>
</div>
</div>
<script>
// Check connection on page load
document.addEventListener('DOMContentLoaded', function() {
checkConnection();
updateCharCount();
});
// Update character count
const messageBody = document.getElementById('message_body');
messageBody.addEventListener('input', updateCharCount);
function updateCharCount() {
const length = messageBody.value.length;
const remaining = 160 - length;
const charCount = document.getElementById('char-count');
if (remaining < 0) {
charCount.textContent = `${Math.abs(remaining)} characters over limit (message will be split into ${Math.ceil(length / 160)} parts)`;
charCount.classList.add('text-red-600');
charCount.classList.remove('text-gray-500');
} else {
charCount.textContent = `${remaining} characters remaining`;
charCount.classList.remove('text-red-600');
charCount.classList.add('text-gray-500');
}
}
// Check gateway connection
async function checkConnection() {
const container = document.getElementById('status-container');
container.innerHTML = '<div class="flex items-center justify-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
try {
const response = await fetch('<%= check_connection_admin_gateway_path(@gateway) %>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
}
});
const data = await response.json();
if (data.status === 'success') {
container.innerHTML = `
<div class="rounded-lg bg-green-50 px-4 py-4 ring-1 ring-green-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-green-800">Gateway is Online</h3>
<p class="mt-1 text-sm text-green-700">
Last heartbeat: ${data.time_ago} ago
</p>
<p class="mt-1 text-xs text-green-600">
${data.last_heartbeat}
</p>
</div>
</div>
</div>
`;
} else {
container.innerHTML = `
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-times-circle text-red-600 text-2xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800">Gateway is Offline</h3>
<p class="mt-1 text-sm text-red-700">
${data.message}
</p>
${data.last_heartbeat ? `<p class="mt-1 text-xs text-red-600">Last seen: ${data.time_ago} ago</p>` : '<p class="mt-1 text-xs text-red-600">Never connected</p>'}
</div>
</div>
</div>
`;
}
} catch (error) {
container.innerHTML = `
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-red-600 text-2xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800">Error Checking Status</h3>
<p class="mt-1 text-sm text-red-700">${error.message}</p>
</div>
</div>
</div>
`;
}
}
// Handle form submission
document.getElementById('test-sms-form').addEventListener('submit', async function(e) {
e.preventDefault();
const submitButton = e.target.querySelector('button[type="submit"]');
const submitText = document.getElementById('submit-text');
const resultDiv = document.getElementById('sms-result');
// Disable button and show loading
submitButton.disabled = true;
submitText.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
const formData = {
phone_number: document.getElementById('phone_number').value,
message_body: document.getElementById('message_body').value
};
try {
const response = await fetch('<%= send_test_sms_admin_gateway_path(@gateway) %>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.status === 'success') {
resultDiv.className = 'rounded-lg bg-green-50 px-4 py-4 ring-1 ring-green-600/10';
resultDiv.innerHTML = `
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-600 text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-green-800">Test SMS Sent Successfully!</h3>
<p class="mt-1 text-sm text-green-700">${data.message}</p>
<div class="mt-2 text-xs text-green-600">
<p>Message ID: <code class="bg-green-100 px-2 py-1 rounded">${data.message_id}</code></p>
<p class="mt-1">Status: <span class="font-semibold">${data.sms_status}</span></p>
</div>
</div>
</div>
`;
} else {
resultDiv.className = 'rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10';
resultDiv.innerHTML = `
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-times-circle text-red-600 text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800">Failed to Send Test SMS</h3>
<p class="mt-1 text-sm text-red-700">${data.message}</p>
</div>
</div>
`;
}
resultDiv.classList.remove('hidden');
} catch (error) {
resultDiv.className = 'rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10';
resultDiv.innerHTML = `
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-red-600 text-xl"></i>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800">Error</h3>
<p class="mt-1 text-sm text-red-700">${error.message}</p>
</div>
</div>
`;
resultDiv.classList.remove('hidden');
} finally {
// Re-enable button
submitButton.disabled = false;
submitText.innerHTML = '<i class="fas fa-paper-plane"></i> Send Test SMS';
}
});
function resetForm() {
document.getElementById('test-sms-form').reset();
document.getElementById('sms-result').classList.add('hidden');
updateCharCount();
}
</script>
<% end %>

Some files were not shown because too many files have changed in this diff Show More