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