completed SMS gateway project
This commit is contained in:
51
.dockerignore
Normal file
51
.dockerignore
Normal 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
9
.gitattributes
vendored
Normal 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
12
.github/dependabot.yml
vendored
Normal 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
101
.github/workflows/ci.yml
vendored
Normal 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
37
.gitignore
vendored
Normal 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
|
||||||
3
.kamal/hooks/docker-setup.sample
Executable file
3
.kamal/hooks/docker-setup.sample
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Docker set up on $KAMAL_HOSTS..."
|
||||||
3
.kamal/hooks/post-app-boot.sample
Executable file
3
.kamal/hooks/post-app-boot.sample
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||||
14
.kamal/hooks/post-deploy.sample
Executable file
14
.kamal/hooks/post-deploy.sample
Executable 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"
|
||||||
3
.kamal/hooks/post-proxy-reboot.sample
Executable file
3
.kamal/hooks/post-proxy-reboot.sample
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
|
||||||
3
.kamal/hooks/pre-app-boot.sample
Executable file
3
.kamal/hooks/pre-app-boot.sample
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||||
51
.kamal/hooks/pre-build.sample
Executable file
51
.kamal/hooks/pre-build.sample
Executable 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
47
.kamal/hooks/pre-connect.sample
Executable 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
122
.kamal/hooks/pre-deploy.sample
Executable 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
|
||||||
3
.kamal/hooks/pre-proxy-reboot.sample
Executable file
3
.kamal/hooks/pre-proxy-reboot.sample
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
|
||||||
17
.kamal/secrets
Normal file
17
.kamal/secrets
Normal 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
8
.rubocop.yml
Normal 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
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ruby-3.4.7
|
||||||
757
ADMIN_COMPLETE.md
Normal file
757
ADMIN_COMPLETE.md
Normal 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
376
ADMIN_INTERFACE.md
Normal 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
229
ADMIN_QUICKSTART.md
Normal 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
1020
API_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
984
CABLE_DOCUMENTATION.md
Normal file
984
CABLE_DOCUMENTATION.md
Normal 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
430
CLAUDE.md
Normal 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
72
Dockerfile
Normal 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
355
FIXES_APPLIED.md
Normal 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
378
GATEWAY_MANAGEMENT.md
Normal 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
614
GATEWAY_TESTING.md
Normal 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
77
Gemfile
Normal 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
447
Gemfile.lock
Normal 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
326
JSONB_FIXES.md
Normal 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
87
NAMESPACE_FIX.md
Normal 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
183
PERMISSIONS_FIX.md
Normal 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
2
Procfile.dev
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
web: bin/rails server
|
||||||
|
css: bin/rails tailwindcss:watch
|
||||||
449
QR_CODE_SETUP.md
Normal file
449
QR_CODE_SETUP.md
Normal 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
137
QUICKSTART.md
Normal 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
730
README.md
Normal 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
6
Rakefile
Normal 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
179
SESSION_MIDDLEWARE_FIX.md
Normal 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
281
STARTUP_GUIDE.md
Normal 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
237
WEBSOCKET_FIX.md
Normal 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
502
WEBSOCKET_SETUP.md
Normal 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
0
app/assets/builds/.keep
Normal file
0
app/assets/images/.keep
Normal file
0
app/assets/images/.keep
Normal file
10
app/assets/stylesheets/application.css
Normal file
10
app/assets/stylesheets/application.css
Normal 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.
|
||||||
|
*/
|
||||||
36
app/assets/tailwind/application.css
Normal file
36
app/assets/tailwind/application.css
Normal 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;
|
||||||
|
}
|
||||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
31
app/channels/application_cable/connection.rb
Normal file
31
app/channels/application_cable/connection.rb
Normal 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
|
||||||
99
app/channels/gateway_channel.rb
Normal file
99
app/channels/gateway_channel.rb
Normal 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
|
||||||
76
app/controllers/admin/api_keys_controller.rb
Normal file
76
app/controllers/admin/api_keys_controller.rb
Normal 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
|
||||||
8
app/controllers/admin/api_tester_controller.rb
Normal file
8
app/controllers/admin/api_tester_controller.rb
Normal 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
|
||||||
30
app/controllers/admin/base_controller.rb
Normal file
30
app/controllers/admin/base_controller.rb
Normal 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
|
||||||
20
app/controllers/admin/dashboard_controller.rb
Normal file
20
app/controllers/admin/dashboard_controller.rb
Normal 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
|
||||||
152
app/controllers/admin/gateways_controller.rb
Normal file
152
app/controllers/admin/gateways_controller.rb
Normal 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
|
||||||
37
app/controllers/admin/logs_controller.rb
Normal file
37
app/controllers/admin/logs_controller.rb
Normal 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
|
||||||
38
app/controllers/admin/sessions_controller.rb
Normal file
38
app/controllers/admin/sessions_controller.rb
Normal 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
|
||||||
49
app/controllers/api/v1/admin/gateways_controller.rb
Normal file
49
app/controllers/api/v1/admin/gateways_controller.rb
Normal 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
|
||||||
60
app/controllers/api/v1/admin/stats_controller.rb
Normal file
60
app/controllers/api/v1/admin/stats_controller.rb
Normal 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
|
||||||
10
app/controllers/api/v1/gateway/base_controller.rb
Normal file
10
app/controllers/api/v1/gateway/base_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
module Gateway
|
||||||
|
class BaseController < ApplicationController
|
||||||
|
include ApiAuthenticatable
|
||||||
|
include RateLimitable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
30
app/controllers/api/v1/gateway/heartbeats_controller.rb
Normal file
30
app/controllers/api/v1/gateway/heartbeats_controller.rb
Normal 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
|
||||||
53
app/controllers/api/v1/gateway/registrations_controller.rb
Normal file
53
app/controllers/api/v1/gateway/registrations_controller.rb
Normal 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
|
||||||
61
app/controllers/api/v1/gateway/sms_controller.rb
Normal file
61
app/controllers/api/v1/gateway/sms_controller.rb
Normal 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
|
||||||
86
app/controllers/api/v1/otp_controller.rb
Normal file
86
app/controllers/api/v1/otp_controller.rb
Normal 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
|
||||||
90
app/controllers/api/v1/sms_controller.rb
Normal file
90
app/controllers/api/v1/sms_controller.rb
Normal 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
|
||||||
22
app/controllers/application_controller.rb
Normal file
22
app/controllers/application_controller.rb
Normal 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
|
||||||
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
73
app/controllers/concerns/api_authenticatable.rb
Normal file
73
app/controllers/concerns/api_authenticatable.rb
Normal 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
|
||||||
54
app/controllers/concerns/rate_limitable.rb
Normal file
54
app/controllers/concerns/rate_limitable.rb
Normal 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
|
||||||
9
app/helpers/admin_helper.rb
Normal file
9
app/helpers/admin_helper.rb
Normal 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
|
||||||
12
app/helpers/application_helper.rb
Normal file
12
app/helpers/application_helper.rb
Normal 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
|
||||||
3
app/javascript/application.js
Normal file
3
app/javascript/application.js
Normal 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"
|
||||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal 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 }
|
||||||
7
app/javascript/controllers/hello_controller.js
Normal file
7
app/javascript/controllers/hello_controller.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
connect() {
|
||||||
|
this.element.textContent = "Hello World!"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/javascript/controllers/index.js
Normal file
4
app/javascript/controllers/index.js
Normal 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)
|
||||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal 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
|
||||||
14
app/jobs/check_gateway_health_job.rb
Normal file
14
app/jobs/check_gateway_health_job.rb
Normal 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
|
||||||
8
app/jobs/cleanup_expired_otps_job.rb
Normal file
8
app/jobs/cleanup_expired_otps_job.rb
Normal 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
|
||||||
57
app/jobs/process_inbound_sms_job.rb
Normal file
57
app/jobs/process_inbound_sms_job.rb
Normal 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
|
||||||
8
app/jobs/reset_daily_counters_job.rb
Normal file
8
app/jobs/reset_daily_counters_job.rb
Normal 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
|
||||||
22
app/jobs/retry_failed_sms_job.rb
Normal file
22
app/jobs/retry_failed_sms_job.rb
Normal 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
41
app/jobs/send_sms_job.rb
Normal 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
|
||||||
31
app/jobs/trigger_webhook_job.rb
Normal file
31
app/jobs/trigger_webhook_job.rb
Normal 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
|
||||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal 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
11
app/models/admin_user.rb
Normal 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
72
app/models/api_key.rb
Normal 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
|
||||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
class ApplicationRecord < ActiveRecord::Base
|
||||||
|
primary_abstract_class
|
||||||
|
end
|
||||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
81
app/models/concerns/metrics.rb
Normal file
81
app/models/concerns/metrics.rb
Normal 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
68
app/models/gateway.rb
Normal 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
118
app/models/otp_code.rb
Normal 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
110
app/models/sms_message.rb
Normal 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
|
||||||
54
app/models/webhook_config.rb
Normal file
54
app/models/webhook_config.rb
Normal 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
|
||||||
101
app/views/admin/api_keys/index.html.erb
Normal file
101
app/views/admin/api_keys/index.html.erb
Normal 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>
|
||||||
105
app/views/admin/api_keys/new.html.erb
Normal file
105
app/views/admin/api_keys/new.html.erb
Normal 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>
|
||||||
139
app/views/admin/api_keys/show.html.erb
Normal file
139
app/views/admin/api_keys/show.html.erb
Normal 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>
|
||||||
466
app/views/admin/api_tester/index.html.erb
Normal file
466
app/views/admin/api_tester/index.html.erb
Normal 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>
|
||||||
230
app/views/admin/dashboard/index.html.erb
Normal file
230
app/views/admin/dashboard/index.html.erb
Normal 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>
|
||||||
168
app/views/admin/gateways/index.html.erb
Normal file
168
app/views/admin/gateways/index.html.erb
Normal 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>
|
||||||
87
app/views/admin/gateways/new.html.erb
Normal file
87
app/views/admin/gateways/new.html.erb
Normal 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>
|
||||||
565
app/views/admin/gateways/show.html.erb
Normal file
565
app/views/admin/gateways/show.html.erb
Normal 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>
|
||||||
353
app/views/admin/gateways/test.html.erb
Normal file
353
app/views/admin/gateways/test.html.erb
Normal 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
Reference in New Issue
Block a user