Skip to content

Commit d31f779

Browse files
dadachiclaude
andauthored
Add push notifications scaffolding via noticed v2 (#59)
* Add push notifications scaffolding via noticed v2 PR #1 of 5 in #58. Scaffolds the Rails-side groundwork for native push notifications. Provider integration (APNs + FCM) and ItemTag AASM wiring follow in PR #2 once APNs .p8 and FCM service-account JSON are provisioned. Client work (free + paid iOS/Android) follows in PRs #3-5. What lands here - noticed v2 gem + the two engine migrations (Noticed::Event, Noticed::Notification, both UUID-keyed to match this substrate's primary_key_type) - Device model + migration: shopkeeper-scoped, unique on [platform, token], last_active_at for staleness scoping; ios/android enum - Api::V1::Shopkeeper::DevicesController: POST /api/v1/shopkeeper/devices — idempotent upsert (rebinds token to current_shopkeeper if it previously belonged to someone else, e.g. shared device after sign-out/sign-in); 201 on create, 200 on touch DELETE /api/v1/shopkeeper/devices/:id — unregister (404 on someone else's device, scoped via current_shopkeeper.devices) - DevicePolicy + DeviceSerializer following existing substrate conventions (BasePolicy + JSONAPI::Serializer) - ApplicationNotifier base + example ItemTagCalledNotifier (no delivery methods wired yet — title/body/url are i18n-resolved via notification_methods so PR #2 just needs to add deliver_by :ios + :android and trigger from ItemTag's AASM complete event) - Shopkeeper.has_many :devices (dependent: :destroy) and :notifications (as: :recipient, class: Noticed::Notification) - Locale entries under notifiers.item_tag_called Tests: 21 new runs (Device model 9, DevicesController 8, notifier 4), 0 failures. Full suite now 419 runs / 868 assertions / 0 failures / 0 errors / 0 skips. rubocop clean (239 files, 0 offenses). * Wire deliver_by :action_push_native via Rails-native action_push_native Install action_push_native 0.3.x and generate ApplicationPushNotification, ApplicationPushDevice, ApplicationPushNotificationJob, and config/push.yml. Add deliver_by :action_push_native to ItemTagCalledNotifier so push notifications route through Rails 8.1's Action Push Native (single abstraction over APNs + FCM) instead of the Noticed gem's per-platform :ios / :fcm deliverers. APNs/FCM credentials remain placeholders in config/push.yml — provision via bin/rails credentials:edit before enabling delivery. Bridging the existing Device registration API to ApplicationPushDevice (so registered tokens actually flow into Action Push Native delivery) is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Generalize notifier copy: %{number} → %{name}, drop "Number" prefix Substrate post-Phase-1 (#45) is generic single-resource CRUD, not queue-only — `ItemTag.name` can be a queue number ("A001"), a pet name ("Mittens"), a task title, etc. The copy "Number %{number} is up" only reads correctly for the queue case, and the agent's renamer doesn't substitute the word "Number" (it's not in the rename plan), so the wrong copy ships to every renamed app. Change to "%{name} is ready" — generalizes cleanly across the queue, reservation, vet-clinic, and task-tracker domains the substrate targets. Body unchanged. Test passes; rubocop clean. * Generalize notifier: drop "Called" + state-verb copy (rename-resistant) Reverses the previous "Number → name" / "is ready" decision once it became clear that any state-verb baked into the substrate's notifier title or class name fights the agent's domain-adapt step. The agent extends/renames the AASM state machine per spec (idled/completed → e.g. waiting/seated for restaurant, pending/seen for vet clinic), but its rename plan only handles the four model-level tokens (Shop / Shopkeeper / ItemTag / NativeAppTemplate). State names cascading into notifier file/class/locale-key/title are out of scope for the rename-safety contract (#57). So the substrate's notifier ships state-verb-free: - File: item_tag_called_notifier.rb → item_tag_notifier.rb - Class: ItemTagCalledNotifier → ItemTagNotifier - Locale key: notifiers.item_tag_called → notifiers.item_tag - Title: "%{name} is ready" → "%{name}" - Body: "Please proceed to %{shop}." → "%{shop}" `ItemTag` itself IS in the rename plan, so file/class/locale-key cascade through `item_tag → patient/reservation/todo` cleanly. `%{name}` and `%{shop}` are interpolation keys, not renameable tokens. Result: substrate copy survives any state-verb rewrite the agent's adapt step does, at the cost of vague substrate copy. The adapt step can rewrite richer per-domain copy when it wants. Tests + rubocop clean. * Swap notifier title/body: shop in title, item name in body Push-notification UX convention is source-in-title, event-in-body — WhatsApp (sender → message), Slack (channel → message), Calendar (event → location). Shop is the recognizable persistent entity that anchors the notification; item name is variable per-event content. Title: %{name} → %{shop} Body: %{shop} → %{name} Tests + rubocop clean. * credentials.yml.tt: add action_push_native APNs + FCM placeholders config/push.yml looks up Rails.application.credentials.dig( :action_push_native, :apns, :key_id) and friends, but the credentials template that seeds `bin/rails credentials:edit` on first generation didn't expose those keys. Fresh developers would hit silent nil on first push delivery without knowing where the lookup expected the secret. Adds the same shape Resend's api_key already follows: empty placeholder under the documented key path. Comment notes which inputs are needed (APNs key_id + .p8 contents, FCM service-account JSON). * push.yml: move team_id/topic/project_id to credentials too Three deployment-specific values were still hard-coded as placeholders in config/push.yml: - apple.team_id (Apple Developer team identifier — per-deployer) - apple.topic (iOS bundle identifier — per-deployment) - google.project_id (Firebase project identifier — per-deployment) These don't belong in source. apple.topic in particular is a rename- pipeline trap: the agent renames the iOS bundle id when generating a domain-customized variant (com.nativeapptemplate.* → com.<spec>.*), but the rename pipeline only operates on code/locales/OpenAPI — not on push.yml strings. So a hard-coded `your.bundle.identifier` here silently desyncs from the renamed app's actual bundle id and push delivery breaks with a non-obvious error. Move all three to Rails.application.credentials.dig(:action_push_native, ...) so they're deploy-time configuration, not source-controlled state. Add the same fields to the credentials.yml.tt template so `bin/rails credentials:edit` exposes the expected key paths. Tests + rubocop clean. * openapi.yaml: document Device endpoints + schemas Layer 1 of the agent's reviewer (per the agent's docs/SPEC.md) checks OpenAPI parity between Rails ↔ iOS networking ↔ Android repository layers. Adding the Device controller without the corresponding spec entries means PRs #3-5 (the iOS/Android push registration clients) wouldn't have a contract to integrate against and would fail Layer 1 contract-parity scan. Adds: - Tag: Devices - Path POST /devices: idempotent register; 201 on create, 200 on touch, 422 on validation error - Path DELETE /devices/{deviceId}: 204 no_content, 404 if device isn't owned by current_shopkeeper - Schemas: DeviceAttributes, Device, DeviceCreateRequest (jsonapi-style envelope to match the rest of the API) YAML parses; paths now 25, schemas now 38. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6288521 commit d31f779

26 files changed

Lines changed: 703 additions & 1 deletion

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## [Unreleased]
44

5+
## 2026-05-10
6+
7+
- Add push notifications scaffolding via `noticed` v2 (#58)
8+
- New `Device` model + migration (UUID primary key, unique on `[platform, token]`, `last_active_at` for staleness scope)
9+
- New `Api::V1::Shopkeeper::DevicesController` — POST `/devices` is idempotent upsert (rebinds token to current shopkeeper); DELETE `/devices/:id` unregisters
10+
- Add `ApplicationNotifier` base class + example `ItemTagNotifier` with `deliver_by :action_push_native` wiring (Apple + Google push via Rails-native `action_push_native` 0.3.x)
11+
- Generate `ApplicationPushNotification` / `ApplicationPushDevice` / `ApplicationPushNotificationJob` and `config/push.yml` (APNs/FCM credentials still placeholders — provision via `bin/rails credentials:edit` before enabling delivery; ItemTag AASM trigger lands in a follow-up)
12+
- Note: the existing `Device` registration API (`POST /api/v1/shopkeeper/devices`) writes to the custom `Device` model, not `ApplicationPushDevice` — bridging the two (so registered tokens flow into Action Push Native delivery) is a follow-up
13+
- `Shopkeeper has_many :devices, :notifications`; new locale entries under `notifiers.item_tag` — title/body deliberately kept generic (`%{name}` / `%{shop}` only, no state-verbs like "completed" or "ready") so the agent's domain-adapt step can rewrite richer per-domain copy without fighting baked-in queue semantics
14+
- 21 new test runs (Device model + DevicesController + notifier); full suite still 0 failures
15+
516
## 2026-05-02
617

718
- Phase 1: Rails API substrate v2 refactor (#45) — turn queue-specific template into generic single-resource CRUD substrate (Shop → ItemTag)

Gemfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ gem "importmap-rails"
5555
gem "tailwindcss-rails", "~> 4.0"
5656
gem "rack-attack"
5757
gem "resend"
58+
59+
# Push notifications via APNs (iOS) and FCM (Android). Provider integration
60+
# (apnotic + googleauth + initializer with credentials) lands in a follow-up
61+
# PR; this PR scaffolds the model + controller + base notifier only.
62+
gem "noticed", "~> 2.7"
5863
# Fix LoadError: cannot load such file -- csv
5964
gem "csv", "~> 3.3"
6065

@@ -97,3 +102,5 @@ group :test do
97102
gem "selenium-webdriver", ">= 4.20.1"
98103
gem "webmock"
99104
end
105+
106+
gem "action_push_native", "~> 0.3.1"

Gemfile.lock

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ GEM
33
specs:
44
aasm (5.5.2)
55
concurrent-ruby (~> 1.0)
6+
action_push_native (0.3.1)
7+
activejob (>= 8.0)
8+
activerecord (>= 8.0)
9+
googleauth (~> 1.14)
10+
httpx (~> 1.6)
11+
jwt (>= 2)
12+
railties (>= 8.0)
613
action_text-trix (2.1.18)
714
railties
815
actioncable (8.1.3)
@@ -153,6 +160,12 @@ GEM
153160
erubi (1.13.1)
154161
et-orbi (1.4.0)
155162
tzinfo
163+
faraday (2.14.1)
164+
faraday-net_http (>= 2.0, < 3.5)
165+
json
166+
logger
167+
faraday-net_http (3.4.2)
168+
net-http (~> 0.5)
156169
ffi (1.17.4-aarch64-linux-gnu)
157170
ffi (1.17.4-arm-linux-gnu)
158171
ffi (1.17.4-arm64-darwin)
@@ -164,11 +177,26 @@ GEM
164177
raabro (~> 1.4)
165178
globalid (1.3.0)
166179
activesupport (>= 6.1)
180+
google-cloud-env (2.3.1)
181+
base64 (~> 0.2)
182+
faraday (>= 1.0, < 3.a)
183+
google-logging-utils (0.2.0)
184+
googleauth (1.16.2)
185+
faraday (>= 1.0, < 3.a)
186+
google-cloud-env (~> 2.2)
187+
google-logging-utils (~> 0.1)
188+
jwt (>= 1.4, < 4.0)
189+
multi_json (~> 1.11)
190+
os (>= 0.9, < 2.0)
191+
signet (>= 0.16, < 2.a)
167192
hashdiff (1.2.1)
193+
http-2 (1.1.3)
168194
httparty (0.24.2)
169195
csv
170196
mini_mime (>= 1.0.0)
171197
multi_xml (>= 0.5.2)
198+
httpx (1.7.6)
199+
http-2 (>= 1.1.3)
172200
i18n (1.14.8)
173201
concurrent-ruby (~> 1.0)
174202
image_processing (1.14.0)
@@ -188,6 +216,8 @@ GEM
188216
json (2.19.4)
189217
jsonapi-serializer (2.2.0)
190218
activesupport (>= 4.2)
219+
jwt (3.1.2)
220+
base64
191221
language_server-protocol (3.17.0.5)
192222
lint_roller (1.1.0)
193223
logger (1.7.0)
@@ -232,8 +262,11 @@ GEM
232262
stimulus-rails
233263
turbo-rails
234264
msgpack (1.8.0)
265+
multi_json (1.21.1)
235266
multi_xml (0.8.1)
236267
bigdecimal (>= 3.1, < 5)
268+
net-http (0.9.1)
269+
uri (>= 0.11.1)
237270
net-imap (0.6.4)
238271
date
239272
net-protocol
@@ -257,7 +290,10 @@ GEM
257290
racc (~> 1.4)
258291
nokogiri (1.19.3-x86_64-linux-gnu)
259292
racc (~> 1.4)
293+
noticed (2.9.3)
294+
rails (>= 6.1.0)
260295
orm_adapter (0.5.0)
296+
os (1.1.4)
261297
ostruct (0.6.3)
262298
overcommit (0.69.0)
263299
childprocess (>= 0.6.3, < 6)
@@ -393,6 +429,11 @@ GEM
393429
rexml (~> 3.2, >= 3.2.5)
394430
rubyzip (>= 1.2.2, < 4.0)
395431
websocket (~> 1.0)
432+
signet (0.21.0)
433+
addressable (~> 2.8)
434+
faraday (>= 0.17.5, < 3.a)
435+
jwt (>= 1.5, < 4.0)
436+
multi_json (~> 1.10)
396437
smart_properties (1.17.0)
397438
solid_cable (3.0.12)
398439
actioncable (>= 7.2)
@@ -469,6 +510,7 @@ PLATFORMS
469510

470511
DEPENDENCIES
471512
aasm
513+
action_push_native (~> 0.3.1)
472514
acts_as_tenant
473515
after_commit_everywhere (~> 1.6)
474516
bootsnap (>= 1.4.2)
@@ -488,6 +530,7 @@ DEPENDENCIES
488530
minitest-mock
489531
mission_control-jobs
490532
nokogiri (>= 1.12.5)
533+
noticed (~> 2.7)
491534
overcommit
492535
pagy (~> 43)
493536
pg
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
class Api::V1::Shopkeeper::DevicesController < Api::V1::Shopkeeper::BaseController
2+
before_action :set_device, only: %i[destroy]
3+
4+
# POST /api/v1/shopkeeper/devices
5+
#
6+
# Idempotent registration. Same (platform, token) tuple on a re-POST
7+
# updates last_active_at instead of creating a duplicate. Re-binding a
8+
# token to a different shopkeeper (e.g. user signed out + new user
9+
# signed in on same device) reassigns the device row.
10+
def create
11+
authorize Device
12+
13+
device = Device.find_or_initialize_by(
14+
platform: device_params[:platform],
15+
token: device_params[:token]
16+
)
17+
device.shopkeeper = current_shopkeeper
18+
device.bundle_id = device_params[:bundle_id]
19+
device.last_active_at = Time.current
20+
21+
if device.save
22+
render json: DeviceSerializer.new(device).serializable_hash, status: device.previously_new_record? ? :created : :ok
23+
else
24+
render_validation_error(device)
25+
end
26+
end
27+
28+
# DELETE /api/v1/shopkeeper/devices/:id
29+
def destroy
30+
authorize @device
31+
32+
@device.destroy
33+
head :no_content
34+
end
35+
36+
private
37+
38+
def set_device
39+
@device = current_shopkeeper.devices.find(params[:id])
40+
end
41+
42+
def device_params
43+
params.require(:device).permit(:token, :platform, :bundle_id)
44+
end
45+
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
2+
# Enable logging job arguments (default: false)
3+
# self.log_arguments = true
4+
5+
# Report job retries via the `Rails.error` reporter (default: false)
6+
# self.report_job_retries = true
7+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class ApplicationPushDevice < ActionPushNative::Device
2+
# Customize TokenError handling (default: destroy!)
3+
# rescue_from (ActionPushNative::TokenError) { Rails.logger.error("Device #{id} token is invalid") }
4+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class ApplicationPushNotification < ActionPushNative::Notification
2+
# Set a custom job queue_name
3+
# queue_as :realtime
4+
5+
# Controls whether push notifications are enabled (default: !Rails.env.test?)
6+
# self.enabled = Rails.env.production?
7+
8+
# Define a custom callback to modify or abort the notification before it is sent
9+
# before_delivery do |notification|
10+
# throw :abort if Notification.find(notification.context[:notification_id]).expired?
11+
# end
12+
end

app/models/device.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class Device < ApplicationRecord
2+
belongs_to :shopkeeper
3+
4+
enum :platform, {ios: "ios", android: "android"}
5+
6+
validates :token, presence: true, uniqueness: {scope: :platform}
7+
validates :platform, presence: true
8+
9+
scope :active, -> { where("last_active_at > ?", 90.days.ago) }
10+
11+
before_validation :touch_last_active_at, on: :create
12+
13+
private
14+
15+
def touch_last_active_at
16+
self.last_active_at ||= Time.current
17+
end
18+
end

app/models/shopkeeper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class Shopkeeper < ApplicationRecord
1212
has_many :created_shops, class_name: "Shop", foreign_key: :created_by_id, inverse_of: :created_by
1313
has_many :created_item_tags, class_name: "ItemTag", foreign_key: :created_by_id, inverse_of: :created_by, dependent: :nullify
1414
has_many :completed_item_tags, class_name: "ItemTag", foreign_key: :completed_by_id, inverse_of: :completed_by, dependent: :nullify
15+
has_many :devices, dependent: :destroy
16+
has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"
1517

1618
attribute :token, :string
1719
attribute :client, :string
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class ApplicationNotifier < Noticed::Event
2+
end

0 commit comments

Comments
 (0)