Skip to content

Commit db188c7

Browse files
committed
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).
1 parent 6288521 commit db188c7

19 files changed

Lines changed: 419 additions & 1 deletion

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
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 `ItemTagCalledNotifier` (no provider config yet — APNs / FCM delivery + ItemTag AASM wiring land in PR #2 once credentials are provisioned)
11+
- `Shopkeeper has_many :devices, :notifications`; new locale entries under `notifiers.item_tag_called`
12+
- 21 new test runs (Device model + DevicesController + notifier); full suite still 0 failures
13+
514
## 2026-05-02
615

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

Gemfile

Lines changed: 5 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

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ GEM
257257
racc (~> 1.4)
258258
nokogiri (1.19.3-x86_64-linux-gnu)
259259
racc (~> 1.4)
260+
noticed (2.9.3)
261+
rails (>= 6.1.0)
260262
orm_adapter (0.5.0)
261263
ostruct (0.6.3)
262264
overcommit (0.69.0)
@@ -488,6 +490,7 @@ DEPENDENCIES
488490
minitest-mock
489491
mission_control-jobs
490492
nokogiri (>= 1.12.5)
493+
noticed (~> 2.7)
491494
overcommit
492495
pagy (~> 43)
493496
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

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
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
class ItemTagCalledNotifier < ApplicationNotifier
2+
notification_methods do
3+
def title
4+
I18n.t("notifiers.item_tag_called.title", number: record.name)
5+
end
6+
7+
def body
8+
I18n.t("notifiers.item_tag_called.body", shop: record.shop.name)
9+
end
10+
11+
def url
12+
Rails.application.routes.url_helpers.api_v1_shopkeeper_shop_item_tag_path(
13+
shop_id: record.shop_id,
14+
id: record.id
15+
)
16+
end
17+
end
18+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Api::Shopkeeper::DevicePolicy < Api::Shopkeeper::BasePolicy
2+
def create?
3+
true
4+
end
5+
6+
def destroy?
7+
record.shopkeeper_id == accounts_shopkeeper.shopkeeper_id
8+
end
9+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class DeviceSerializer
2+
include JSONAPI::Serializer
3+
4+
attributes :token,
5+
:platform,
6+
:bundle_id,
7+
:last_active_at,
8+
:created_at,
9+
:updated_at
10+
end

0 commit comments

Comments
 (0)