completed SMS gateway project

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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