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

326
JSONB_FIXES.md Normal file
View 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!