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

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

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

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

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

View File

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

View File

View File

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

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

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

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

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

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

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

View File

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