smswire 0.0.1.pre → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -3
  3. data/README.md +222 -9
  4. data/RELEASING.md +49 -0
  5. data/app/controllers/smswire/development_controller.rb +17 -0
  6. data/app/controllers/smswire/inbound_messages_controller.rb +9 -0
  7. data/app/controllers/smswire/inbox_controller.rb +31 -0
  8. data/app/controllers/smswire/previews_controller.rb +22 -0
  9. data/app/controllers/smswire/status_callbacks_controller.rb +8 -0
  10. data/app/controllers/smswire/webhooks_controller.rb +49 -0
  11. data/app/models/smswire/application_record.rb +5 -0
  12. data/app/models/smswire/consent.rb +71 -0
  13. data/app/models/smswire/delivery.rb +157 -0
  14. data/app/models/smswire/inbound_message.rb +25 -0
  15. data/app/views/layouts/smswire/application.html.erb +60 -0
  16. data/app/views/smswire/inbox/index.html.erb +56 -0
  17. data/app/views/smswire/previews/index.html.erb +21 -0
  18. data/app/views/smswire/previews/show.html.erb +34 -0
  19. data/config/routes.rb +10 -0
  20. data/db/migrate/20261008000000_create_smswire_deliveries.rb +47 -0
  21. data/db/migrate/20261008000001_create_smswire_consents_and_inbound_messages.rb +34 -0
  22. data/docs/SPEC.md +164 -43
  23. data/docs/guides/compliance.md +124 -0
  24. data/docs/guides/getting-started.md +154 -0
  25. data/docs/guides/migrating-from-noticed-twilio.md +105 -0
  26. data/docs/guides/migrating-from-twilio-ruby.md +146 -0
  27. data/docs/guides/writing-a-provider.md +126 -0
  28. data/lib/generators/smswire/install/install_generator.rb +44 -0
  29. data/lib/generators/smswire/install/templates/application_messenger.rb.tt +3 -0
  30. data/lib/generators/smswire/install/templates/initializer.rb.tt +35 -0
  31. data/lib/generators/smswire/messenger/messenger_generator.rb +51 -0
  32. data/lib/generators/smswire/messenger/templates/messenger.rb.tt +14 -0
  33. data/lib/generators/smswire/messenger/templates/preview.rb.tt +13 -0
  34. data/lib/generators/smswire/messenger/templates/test.rb.tt +18 -0
  35. data/lib/generators/smswire/messenger/templates/view.text.erb.tt +1 -0
  36. data/lib/generators/smswire/provider/provider_generator.rb +36 -0
  37. data/lib/generators/smswire/provider/templates/provider.rb.tt +52 -0
  38. data/lib/generators/smswire/provider/templates/test.rb.tt +29 -0
  39. data/lib/smswire/base.rb +123 -0
  40. data/lib/smswire/body_formatter.rb +14 -0
  41. data/lib/smswire/callbacks.rb +24 -0
  42. data/lib/smswire/configuration.rb +224 -0
  43. data/lib/smswire/delivery_job.rb +29 -0
  44. data/lib/smswire/engine.rb +52 -0
  45. data/lib/smswire/errors.rb +59 -0
  46. data/lib/smswire/http.rb +65 -0
  47. data/lib/smswire/inbound.rb +11 -0
  48. data/lib/smswire/inbound_handler.rb +29 -0
  49. data/lib/smswire/interceptors/allowlist.rb +36 -0
  50. data/lib/smswire/keyword_messenger.rb +12 -0
  51. data/lib/smswire/keywords.rb +51 -0
  52. data/lib/smswire/log_subscriber.rb +57 -0
  53. data/lib/smswire/message.rb +72 -0
  54. data/lib/smswire/message_delivery.rb +69 -0
  55. data/lib/smswire/noticed_delivery_method.rb +54 -0
  56. data/lib/smswire/parameterized_messenger.rb +24 -0
  57. data/lib/smswire/phone_number.rb +65 -0
  58. data/lib/smswire/pipeline.rb +114 -0
  59. data/lib/smswire/preview.rb +54 -0
  60. data/lib/smswire/provider_contract.rb +158 -0
  61. data/lib/smswire/providers/base.rb +66 -0
  62. data/lib/smswire/providers/log.rb +23 -0
  63. data/lib/smswire/providers/null.rb +12 -0
  64. data/lib/smswire/providers/telnyx.rb +178 -0
  65. data/lib/smswire/providers/test.rb +59 -0
  66. data/lib/smswire/providers/twilio.rb +167 -0
  67. data/lib/smswire/providers/vonage.rb +193 -0
  68. data/lib/smswire/providers.rb +38 -0
  69. data/lib/smswire/quiet_hours.rb +45 -0
  70. data/lib/smswire/rate_limit.rb +27 -0
  71. data/lib/smswire/receipt.rb +11 -0
  72. data/lib/smswire/recipient.rb +38 -0
  73. data/lib/smswire/result.rb +40 -0
  74. data/lib/smswire/rspec.rb +133 -0
  75. data/lib/smswire/segments.rb +81 -0
  76. data/lib/smswire/sender.rb +27 -0
  77. data/lib/smswire/status_handler.rb +25 -0
  78. data/lib/smswire/status_update.rb +8 -0
  79. data/lib/smswire/test_helper.rb +81 -0
  80. data/lib/smswire/testing.rb +72 -0
  81. data/lib/smswire/version.rb +1 -1
  82. data/lib/smswire.rb +70 -6
  83. data/smswire.gemspec +9 -5
  84. metadata +195 -10
  85. data/Gemfile +0 -7
  86. data/Rakefile +0 -8
@@ -0,0 +1,157 @@
1
+ module Smswire
2
+ # One outbound SMS, from claim through provider status callbacks.
3
+ #
4
+ # Status lifecycle: pending, then queued / accepted / sent from the
5
+ # provider, then delivered, undelivered, or failed, and optionally read.
6
+ # suppressed means an interceptor cancelled it. Status only moves forward,
7
+ # so out-of-order callbacks are ignored.
8
+ class Delivery < ApplicationRecord
9
+ STATUSES = %w[pending suppressed queued accepted sent delivered undelivered failed read].freeze
10
+ PROGRESS = {
11
+ "pending" => 0, "suppressed" => 0, "queued" => 1, "accepted" => 2, "sent" => 3,
12
+ "delivered" => 4, "undelivered" => 4, "failed" => 4, "read" => 5
13
+ }.freeze
14
+ RECLAIMABLE = %w[pending failed suppressed].freeze
15
+ TERMINAL_FAILURES = %w[undelivered failed].freeze
16
+
17
+ # A claim older than this is treated as abandoned by a crashed worker.
18
+ CLAIM_TIMEOUT = 2.minutes
19
+
20
+ enum :status, STATUSES.index_with(&:itself)
21
+
22
+ belongs_to :recipient, polymorphic: true, optional: true
23
+
24
+ scope :recent, -> { order(created_at: :desc) }
25
+ scope :for_number, ->(number) { where(to_number: number) }
26
+
27
+ class << self
28
+ # Create or reuse the delivery row for +message+ and claim it for
29
+ # sending. Returns nil when an identical message was already sent
30
+ # inside the dedupe window, or another worker holds the claim.
31
+ def claim_for(message, provider:)
32
+ key = Smswire.config.dedupe_window ? message.idempotency_key : nil
33
+
34
+ if key && (existing = find_by(idempotency_key: key))
35
+ if existing.created_at < Smswire.config.dedupe_window.ago
36
+ where(id: existing.id, idempotency_key: key).update_all(idempotency_key: nil)
37
+ else
38
+ return existing.claim
39
+ end
40
+ end
41
+
42
+ create!(attributes_for(message, provider:).merge(
43
+ idempotency_key: key, status: "pending", attempts: 1, claimed_at: Time.current
44
+ ))
45
+ rescue ActiveRecord::RecordNotUnique
46
+ nil
47
+ end
48
+
49
+ # Apply a provider status callback. Looks up by delivery id when the
50
+ # callback URL carried one, falling back to the provider message id.
51
+ def apply_status_update(update, provider:, delivery_id: nil)
52
+ delivery = find_by(id: delivery_id, provider: provider.to_s) if delivery_id.present?
53
+ if delivery.nil? || (delivery.provider_id.present? && delivery.provider_id != update.provider_id)
54
+ delivery = find_by(provider: provider.to_s, provider_id: update.provider_id)
55
+ end
56
+ delivery&.advance!(update.status, provider_id: update.provider_id,
57
+ error_code: update.error_code, error_message: update.error_message)
58
+ delivery
59
+ end
60
+
61
+ def attributes_for(message, provider:)
62
+ record = message.recipient&.record
63
+ {
64
+ messenger: message.messenger,
65
+ action: message.action,
66
+ to_number: message.to,
67
+ from_number: message.from,
68
+ messaging_service: message.messaging_service,
69
+ recipient: (record if record.is_a?(ActiveRecord::Base) && record.persisted?),
70
+ category: message.category.to_s,
71
+ body: (message.body if Smswire.config.store_body?(message.category)),
72
+ segments: message.segments,
73
+ encoding: message.encoding.to_s,
74
+ provider: provider.to_s,
75
+ metadata: message.metadata.presence
76
+ }
77
+ end
78
+ end
79
+
80
+ # Atomically claim a reusable row for another send attempt.
81
+ def claim
82
+ return nil unless RECLAIMABLE.include?(status)
83
+
84
+ now = Time.current
85
+ claimed = self.class
86
+ .where(id:, status: RECLAIMABLE)
87
+ .where("claimed_at IS NULL OR claimed_at < ?", now - CLAIM_TIMEOUT)
88
+ .update_all(["claimed_at = ?, attempts = attempts + 1, status = 'pending', " \
89
+ "error_code = NULL, error_message = NULL, failed_at = NULL, updated_at = ?", now, now])
90
+ reload if claimed == 1
91
+ end
92
+
93
+ def record_receipt!(receipt, message)
94
+ now = Time.current
95
+ with_lock do
96
+ assign_attributes(final_message_attributes(message))
97
+ assign_attributes(provider_id: receipt.provider_id, segments: receipt.segments || message.segments,
98
+ price_amount: receipt.price_amount, price_currency: receipt.price_currency,
99
+ sent_at: now, claimed_at: nil)
100
+ apply_status(receipt.status.to_s, now) if progresses_to?(receipt.status)
101
+ save!
102
+ end
103
+ end
104
+
105
+ def record_failure!(error, message)
106
+ update!(final_message_attributes(message).merge(
107
+ status: "failed", error_code: error.provider_code, error_message: error.message,
108
+ failed_at: Time.current, status_updated_at: Time.current, claimed_at: nil
109
+ ))
110
+ end
111
+
112
+ # Release the claim after a transient error so a retry can reclaim it.
113
+ def release!(error)
114
+ update!(claimed_at: nil, error_code: error.provider_code, error_message: error.message)
115
+ end
116
+
117
+ def record_suppression!(message)
118
+ update!(final_message_attributes(message).merge(status: "suppressed", claimed_at: nil, status_updated_at: Time.current))
119
+ end
120
+
121
+ def advance!(new_status, provider_id: nil, error_code: nil, error_message: nil)
122
+ with_lock do
123
+ self.provider_id ||= provider_id
124
+ if progresses_to?(new_status)
125
+ apply_status(new_status.to_s, Time.current)
126
+ self.error_code = error_code if error_code.present?
127
+ self.error_message = error_message if error_message.present?
128
+ end
129
+ save!
130
+ end
131
+ end
132
+
133
+ def progresses_to?(new_status)
134
+ PROGRESS.fetch(new_status.to_s) > PROGRESS.fetch(status)
135
+ end
136
+
137
+ private
138
+
139
+ def apply_status(new_status, at)
140
+ self.status = new_status
141
+ self.status_updated_at = at
142
+ self.delivered_at = at if new_status == "delivered"
143
+ self.failed_at = at if TERMINAL_FAILURES.include?(new_status)
144
+ end
145
+
146
+ def final_message_attributes(message)
147
+ {
148
+ to_number: message.to,
149
+ from_number: message.from,
150
+ messaging_service: message.messaging_service,
151
+ body: (message.body if Smswire.config.store_body?(message.category)),
152
+ segments: message.segments,
153
+ encoding: message.encoding.to_s
154
+ }
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,25 @@
1
+ module Smswire
2
+ # A message received from a provider webhook. Webhook retries for the
3
+ # same provider message id are recorded once.
4
+ class InboundMessage < ApplicationRecord
5
+ KEYWORDS = %w[stop start help].freeze
6
+
7
+ scope :recent, -> { order(created_at: :desc) }
8
+
9
+ # Returns the new record, or nil when this provider message id was
10
+ # already received.
11
+ def self.receive(inbound, provider:)
12
+ create!(
13
+ provider: provider.to_s,
14
+ provider_id: inbound.provider_id,
15
+ from_number: PhoneNumber.normalize(inbound.from) || inbound.from,
16
+ to_number: inbound.to,
17
+ messaging_service: inbound.messaging_service,
18
+ body: inbound.body,
19
+ raw: inbound.raw
20
+ )
21
+ rescue ActiveRecord::RecordNotUnique
22
+ nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,60 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? yield(:title) : "Smswire" %></title>
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ :root { --bg: #f6f7f9; --panel: #fff; --text: #1d2330; --muted: #667085; --line: #e3e6ea;
10
+ --accent: #2f6fed; --bubble: #e9ecf1; --warn-bg: #fff4e5; --warn: #8a4b00; --ok: #137333; --bad: #b3261e; --on-accent: #fff; }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root { --bg: #14161a; --panel: #1c1f25; --text: #e6e8eb; --muted: #9aa3af; --line: #2c313a;
13
+ --accent: #7aa2ff; --bubble: #2a2f38; --warn-bg: #3a2a12; --warn: #ffcc80; --ok: #81c995; --bad: #f28b82; --on-accent: #10131a; }
14
+ }
15
+ * { box-sizing: border-box; }
16
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, sans-serif; }
17
+ header { display: flex; gap: 20px; align-items: baseline; padding: 14px 24px; border-bottom: 1px solid var(--line); background: var(--panel); }
18
+ header strong { font-size: 17px; }
19
+ header a { color: var(--muted); text-decoration: none; }
20
+ header a.active, header a:hover { color: var(--accent); }
21
+ main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 48px; }
22
+ h1 { font-size: 22px; margin: 0 0 16px; }
23
+ h2 { font-size: 16px; margin: 28px 0 10px; }
24
+ a { color: var(--accent); }
25
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
26
+ .muted { color: var(--muted); }
27
+ .flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: var(--warn-bg); color: var(--warn); }
28
+ table { width: 100%; border-collapse: collapse; }
29
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
30
+ th { font-weight: 600; color: var(--muted); font-size: 13px; }
31
+ .table-wrap { overflow-x: auto; }
32
+ .table-wrap table { min-width: 680px; }
33
+ .preview { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 24px; align-items: start; }
34
+ @media (max-width: 720px) { .preview { grid-template-columns: 1fr; } }
35
+ .phone { background: var(--panel); border: 1px solid var(--line); border-radius: 28px; padding: 24px 16px; min-height: 320px; }
36
+ .bubble { background: var(--bubble); border-radius: 18px 18px 18px 4px; padding: 10px 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
37
+ .warn { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; margin-top: 16px; }
38
+ .status { font-weight: 600; }
39
+ .status-delivered, .status-accepted, .status-queued, .status-sent, .status-read { color: var(--ok); }
40
+ .status-failed, .status-undelivered { color: var(--bad); }
41
+ .body { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 420px; }
42
+ form.inline { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: 10px; align-items: end; }
43
+ label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
44
+ input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); }
45
+ button, input[type="submit"] { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: var(--accent); color: var(--on-accent); cursor: pointer; }
46
+ code { font-size: 13px; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <header>
51
+ <strong>Smswire</strong>
52
+ <% if Smswire.config.show_previews %><%= link_to "Previews", previews_path, class: ("active" if controller_name == "previews") %><% end %>
53
+ <% if Smswire.config.show_inbox %><%= link_to "Inbox", inbox_path, class: ("active" if controller_name == "inbox") %><% end %>
54
+ </header>
55
+ <main>
56
+ <% flash.each do |_type, text| %><div class="flash"><%= text %></div><% end %>
57
+ <%= yield %>
58
+ </main>
59
+ </body>
60
+ </html>
@@ -0,0 +1,56 @@
1
+ <% content_for :title, "SMS inbox" %>
2
+ <h1>SMS inbox</h1>
3
+
4
+ <div class="panel">
5
+ <strong>Simulate a reply</strong>
6
+ <p class="muted">Runs the same handling as the inbound webhook, including STOP, START, and HELP.</p>
7
+ <%= form_with url: inbox_inbound_path, class: "inline" do |form| %>
8
+ <label>From <%= form.text_field :from, placeholder: "+15555550100" %></label>
9
+ <label>To <%= form.text_field :to, value: @default_to %></label>
10
+ <label>Body <%= form.text_field :body, placeholder: "STOP" %></label>
11
+ <%= form.submit "Receive" %>
12
+ <% end %>
13
+ </div>
14
+
15
+ <h2>Sent</h2>
16
+ <div class="panel table-wrap">
17
+ <% if @deliveries.empty? %>
18
+ <span class="muted">Nothing sent yet.</span>
19
+ <% else %>
20
+ <table>
21
+ <tr><th>When</th><th>To</th><th>Message</th><th>Status</th><th>Details</th></tr>
22
+ <% @deliveries.each do |delivery| %>
23
+ <tr id="delivery_<%= delivery.id %>">
24
+ <td><%= time_ago_in_words(delivery.created_at) %> ago</td>
25
+ <td><%= delivery.to_number %></td>
26
+ <td class="body"><%= delivery.body || content_tag(:span, "(body not stored)", class: "muted") %></td>
27
+ <td class="status status-<%= delivery.status %>"><%= delivery.status %></td>
28
+ <td class="muted">
29
+ <%= [delivery.messenger, delivery.action].compact.join("#") %><br>
30
+ <%= delivery.category %> · <%= pluralize(delivery.segments.to_i, "segment") %> · <%= delivery.provider %>
31
+ <% if delivery.error_message.present? %><br><%= delivery.error_code %> <%= delivery.error_message %><% end %>
32
+ </td>
33
+ </tr>
34
+ <% end %>
35
+ </table>
36
+ <% end %>
37
+ </div>
38
+
39
+ <h2>Received</h2>
40
+ <div class="panel table-wrap">
41
+ <% if @inbound_messages.empty? %>
42
+ <span class="muted">Nothing received yet.</span>
43
+ <% else %>
44
+ <table>
45
+ <tr><th>When</th><th>From</th><th>Message</th><th>Keyword</th></tr>
46
+ <% @inbound_messages.each do |message| %>
47
+ <tr>
48
+ <td><%= time_ago_in_words(message.created_at) %> ago</td>
49
+ <td><%= message.from_number %></td>
50
+ <td class="body"><%= message.body %></td>
51
+ <td><%= message.keyword&.upcase %></td>
52
+ </tr>
53
+ <% end %>
54
+ </table>
55
+ <% end %>
56
+ </div>
@@ -0,0 +1,21 @@
1
+ <% content_for :title, "SMS previews" %>
2
+ <h1>SMS previews</h1>
3
+ <% if @previews.empty? %>
4
+ <p class="muted">No previews found. Add classes ending in <code>Preview</code> under
5
+ <%= safe_join(Smswire.config.preview_paths.map { |path| content_tag(:code, path) }, " or ") %>,
6
+ or run <code>bin/rails generate smswire:messenger</code>.</p>
7
+ <% end %>
8
+ <% @previews.each do |preview| %>
9
+ <h2><%= preview.preview_name.camelize %></h2>
10
+ <div class="panel">
11
+ <% if preview.messages.empty? %>
12
+ <span class="muted">No public methods.</span>
13
+ <% else %>
14
+ <ul>
15
+ <% preview.messages.each do |name| %>
16
+ <li><%= link_to name, preview_path(path: "#{preview.preview_name}/#{name}") %></li>
17
+ <% end %>
18
+ </ul>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,34 @@
1
+ <% content_for :title, "#{@preview.preview_name.camelize}##{@message_name}" %>
2
+ <p><%= link_to "All previews", previews_path %></p>
3
+ <h1><%= @preview.preview_name.camelize %>#<%= @message_name %></h1>
4
+
5
+ <% if @message.nil? %>
6
+ <div class="panel muted">This action returned without calling <code>text</code>, so nothing would be sent.</div>
7
+ <% else %>
8
+ <div class="preview">
9
+ <div class="phone">
10
+ <div class="bubble"><%= @message.body %></div>
11
+ <% @message.media_urls.each do |url| %>
12
+ <p class="muted">Media: <%= link_to url, url %></p>
13
+ <% end %>
14
+ </div>
15
+ <div class="panel">
16
+ <table>
17
+ <tr><th>To</th><td><%= @message.to %></td></tr>
18
+ <tr><th>From</th><td><%= @message.from || @message.messaging_service || "(default)" %></td></tr>
19
+ <tr><th>Category</th><td><%= @message.category %></td></tr>
20
+ <tr><th>Provider</th><td><%= @message.provider || Smswire.config.default_provider %></td></tr>
21
+ <tr><th>Encoding</th><td data-encoding><%= @analysis.encoding_label %></td></tr>
22
+ <tr><th>Segments</th><td data-segments><%= pluralize(@analysis.segments, "segment") %></td></tr>
23
+ <tr><th>Length</th><td><%= @analysis.units %> units, <%= @analysis.remaining %> left in this segment</td></tr>
24
+ </table>
25
+ <p><%= link_to "Raw text", preview_path(path: params[:path], raw: 1) %></p>
26
+ </div>
27
+ </div>
28
+ <% if @non_gsm.any? %>
29
+ <div class="warn" data-non-gsm>
30
+ These characters switch the message to UCS-2, which fits 70 units per segment instead of 160:
31
+ <code><%= @non_gsm.join(" ") %></code>
32
+ </div>
33
+ <% end %>
34
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ Smswire::Engine.routes.draw do
2
+ post "status/:provider", to: "status_callbacks#create", as: :status_callback
3
+ post "inbound/:provider", to: "inbound_messages#create", as: :inbound_message
4
+
5
+ # Development tools; each answers 404 unless enabled.
6
+ get "previews", to: "previews#index", as: :previews
7
+ get "previews/*path", to: "previews#show", as: :preview, format: false
8
+ get "inbox", to: "inbox#index", as: :inbox
9
+ post "inbox/inbound", to: "inbox#receive", as: :inbox_inbound
10
+ end
@@ -0,0 +1,47 @@
1
+ class CreateSmswireDeliveries < ActiveRecord::Migration[7.1]
2
+ def change
3
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
4
+
5
+ create_table :smswire_deliveries, id: primary_key_type do |t|
6
+ t.string :messenger
7
+ t.string :action
8
+ t.string :to_number, null: false
9
+ t.string :from_number
10
+ t.string :messaging_service
11
+ t.references :recipient, polymorphic: true, type: foreign_key_type, index: true
12
+ t.string :category, null: false
13
+ t.text :body
14
+ t.integer :segments
15
+ t.string :encoding
16
+ t.string :provider, null: false
17
+ t.string :provider_id
18
+ t.string :status, null: false, default: "pending"
19
+ t.string :error_code
20
+ t.text :error_message
21
+ t.string :idempotency_key
22
+ t.decimal :price_amount, precision: 12, scale: 5
23
+ t.string :price_currency
24
+ t.json :metadata
25
+ t.integer :attempts, null: false, default: 0
26
+ t.datetime :claimed_at
27
+ t.datetime :sent_at
28
+ t.datetime :delivered_at
29
+ t.datetime :failed_at
30
+ t.datetime :status_updated_at
31
+ t.timestamps
32
+ end
33
+
34
+ add_index :smswire_deliveries, [:provider, :provider_id], unique: true
35
+ add_index :smswire_deliveries, :idempotency_key, unique: true
36
+ add_index :smswire_deliveries, [:to_number, :created_at]
37
+ add_index :smswire_deliveries, [:status, :created_at]
38
+ end
39
+
40
+ private
41
+
42
+ def primary_and_foreign_key_types
43
+ config = Rails.configuration.generators
44
+ setting = config.options[config.orm][:primary_key_type]
45
+ [setting || :primary_key, setting || :bigint]
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ class CreateSmswireConsentsAndInboundMessages < ActiveRecord::Migration[7.1]
2
+ def change
3
+ primary_key_type = Rails.configuration.generators.options[Rails.configuration.generators.orm][:primary_key_type]
4
+
5
+ create_table :smswire_consents, id: primary_key_type || :primary_key do |t|
6
+ t.string :phone, null: false
7
+ t.string :scope, null: false, default: "default"
8
+ t.string :status, null: false, default: "unknown"
9
+ t.string :source
10
+ t.datetime :opted_in_at
11
+ t.datetime :opted_out_at
12
+ t.string :last_keyword
13
+ t.datetime :last_keyword_at
14
+ t.json :metadata
15
+ t.timestamps
16
+ end
17
+ add_index :smswire_consents, [:phone, :scope], unique: true
18
+
19
+ create_table :smswire_inbound_messages, id: primary_key_type || :primary_key do |t|
20
+ t.string :provider, null: false
21
+ t.string :provider_id, null: false
22
+ t.string :from_number, null: false
23
+ t.string :to_number
24
+ t.string :messaging_service
25
+ t.text :body
26
+ t.string :keyword
27
+ t.datetime :handled_at
28
+ t.json :raw
29
+ t.timestamps
30
+ end
31
+ add_index :smswire_inbound_messages, [:provider, :provider_id], unique: true
32
+ add_index :smswire_inbound_messages, [:from_number, :created_at]
33
+ end
34
+ end