completed SMS gateway project
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user