Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

## 2026-05-10

- Add push notifications scaffolding via `noticed` v2 (#58)
- New `Device` model + migration (UUID primary key, unique on `[platform, token]`, `last_active_at` for staleness scope)
- New `Api::V1::Shopkeeper::DevicesController` — POST `/devices` is idempotent upsert (rebinds token to current shopkeeper); DELETE `/devices/:id` unregisters
- 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)
- 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)
- 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
- `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
- 21 new test runs (Device model + DevicesController + notifier); full suite still 0 failures

## 2026-05-02

- Phase 1: Rails API substrate v2 refactor (#45) — turn queue-specific template into generic single-resource CRUD substrate (Shop → ItemTag)
Expand Down
7 changes: 7 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ gem "importmap-rails"
gem "tailwindcss-rails", "~> 4.0"
gem "rack-attack"
gem "resend"

# Push notifications via APNs (iOS) and FCM (Android). Provider integration
# (apnotic + googleauth + initializer with credentials) lands in a follow-up
# PR; this PR scaffolds the model + controller + base notifier only.
gem "noticed", "~> 2.7"
# Fix LoadError: cannot load such file -- csv
gem "csv", "~> 3.3"

Expand Down Expand Up @@ -97,3 +102,5 @@ group :test do
gem "selenium-webdriver", ">= 4.20.1"
gem "webmock"
end

gem "action_push_native", "~> 0.3.1"
43 changes: 43 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ GEM
specs:
aasm (5.5.2)
concurrent-ruby (~> 1.0)
action_push_native (0.3.1)
activejob (>= 8.0)
activerecord (>= 8.0)
googleauth (~> 1.14)
httpx (~> 1.6)
jwt (>= 2)
railties (>= 8.0)
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
Expand Down Expand Up @@ -153,6 +160,12 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm64-darwin)
Expand All @@ -164,11 +177,26 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
http-2 (1.1.3)
httparty (0.24.2)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpx (1.7.6)
http-2 (>= 1.1.3)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
Expand All @@ -188,6 +216,8 @@ GEM
json (2.19.4)
jsonapi-serializer (2.2.0)
activesupport (>= 4.2)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
Expand Down Expand Up @@ -232,8 +262,11 @@ GEM
stimulus-rails
turbo-rails
msgpack (1.8.0)
multi_json (1.21.1)
multi_xml (0.8.1)
bigdecimal (>= 3.1, < 5)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
date
net-protocol
Expand All @@ -257,7 +290,10 @@ GEM
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
noticed (2.9.3)
rails (>= 6.1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
overcommit (0.69.0)
childprocess (>= 0.6.3, < 6)
Expand Down Expand Up @@ -393,6 +429,11 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
smart_properties (1.17.0)
solid_cable (3.0.12)
actioncable (>= 7.2)
Expand Down Expand Up @@ -469,6 +510,7 @@ PLATFORMS

DEPENDENCIES
aasm
action_push_native (~> 0.3.1)
acts_as_tenant
after_commit_everywhere (~> 1.6)
bootsnap (>= 1.4.2)
Expand All @@ -488,6 +530,7 @@ DEPENDENCIES
minitest-mock
mission_control-jobs
nokogiri (>= 1.12.5)
noticed (~> 2.7)
overcommit
pagy (~> 43)
pg
Expand Down
45 changes: 45 additions & 0 deletions app/controllers/api/v1/shopkeeper/devices_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
class Api::V1::Shopkeeper::DevicesController < Api::V1::Shopkeeper::BaseController
before_action :set_device, only: %i[destroy]

# POST /api/v1/shopkeeper/devices
#
# Idempotent registration. Same (platform, token) tuple on a re-POST
# updates last_active_at instead of creating a duplicate. Re-binding a
# token to a different shopkeeper (e.g. user signed out + new user
# signed in on same device) reassigns the device row.
def create
authorize Device

device = Device.find_or_initialize_by(
platform: device_params[:platform],
token: device_params[:token]
)
device.shopkeeper = current_shopkeeper
device.bundle_id = device_params[:bundle_id]
device.last_active_at = Time.current

if device.save
render json: DeviceSerializer.new(device).serializable_hash, status: device.previously_new_record? ? :created : :ok
else
render_validation_error(device)
end
end

# DELETE /api/v1/shopkeeper/devices/:id
def destroy
authorize @device

@device.destroy
head :no_content
end

private

def set_device
@device = current_shopkeeper.devices.find(params[:id])
end

def device_params
params.require(:device).permit(:token, :platform, :bundle_id)
end
end
7 changes: 7 additions & 0 deletions app/jobs/application_push_notification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
# Enable logging job arguments (default: false)
# self.log_arguments = true

# Report job retries via the `Rails.error` reporter (default: false)
# self.report_job_retries = true
end
4 changes: 4 additions & 0 deletions app/models/application_push_device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class ApplicationPushDevice < ActionPushNative::Device
# Customize TokenError handling (default: destroy!)
# rescue_from (ActionPushNative::TokenError) { Rails.logger.error("Device #{id} token is invalid") }
end
12 changes: 12 additions & 0 deletions app/models/application_push_notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class ApplicationPushNotification < ActionPushNative::Notification
# Set a custom job queue_name
# queue_as :realtime

# Controls whether push notifications are enabled (default: !Rails.env.test?)
# self.enabled = Rails.env.production?

# Define a custom callback to modify or abort the notification before it is sent
# before_delivery do |notification|
# throw :abort if Notification.find(notification.context[:notification_id]).expired?
# end
end
18 changes: 18 additions & 0 deletions app/models/device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class Device < ApplicationRecord
belongs_to :shopkeeper

enum :platform, {ios: "ios", android: "android"}

validates :token, presence: true, uniqueness: {scope: :platform}
validates :platform, presence: true

scope :active, -> { where("last_active_at > ?", 90.days.ago) }

before_validation :touch_last_active_at, on: :create

private

def touch_last_active_at
self.last_active_at ||= Time.current
end
end
2 changes: 2 additions & 0 deletions app/models/shopkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class Shopkeeper < ApplicationRecord
has_many :created_shops, class_name: "Shop", foreign_key: :created_by_id, inverse_of: :created_by
has_many :created_item_tags, class_name: "ItemTag", foreign_key: :created_by_id, inverse_of: :created_by, dependent: :nullify
has_many :completed_item_tags, class_name: "ItemTag", foreign_key: :completed_by_id, inverse_of: :completed_by, dependent: :nullify
has_many :devices, dependent: :destroy
has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"

attribute :token, :string
attribute :client, :string
Expand Down
2 changes: 2 additions & 0 deletions app/notifiers/application_notifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ApplicationNotifier < Noticed::Event
end
29 changes: 29 additions & 0 deletions app/notifiers/item_tag_notifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class ItemTagNotifier < ApplicationNotifier
deliver_by :action_push_native do |config|
config.devices = -> { ApplicationPushDevice.where(owner: recipient) }
config.format = -> {
{
title: title,
body: body,
data: {url: url}
}
}
end

notification_methods do
def title
I18n.t("notifiers.item_tag.title", shop: record.shop.name)
end

def body
I18n.t("notifiers.item_tag.body", name: record.name)
end

def url
Rails.application.routes.url_helpers.api_v1_shopkeeper_shop_item_tag_path(
shop_id: record.shop_id,
id: record.id
)
end
end
end
9 changes: 9 additions & 0 deletions app/policies/api/shopkeeper/device_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Api::Shopkeeper::DevicePolicy < Api::Shopkeeper::BasePolicy
def create?
true
end

def destroy?
record.shopkeeper_id == accounts_shopkeeper.shopkeeper_id
end
end
10 changes: 10 additions & 0 deletions app/serializers/device_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class DeviceSerializer
include JSONAPI::Serializer

attributes :token,
:platform,
:bundle_id,
:last_active_at,
:created_at,
:updated_at
end
5 changes: 5 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ en:
unauthorized: "You are not authorized to perform this action."
default_shop_description: "This is a sample shop.\nYou can update or remove this shop."

notifiers:
item_tag:
title: "%{shop}"
body: "%{name}"

api:
shopkeeper:
accounts:
Expand Down
46 changes: 46 additions & 0 deletions config/push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
shared:
# All deployment-specific values live in credentials. Use
# `bin/rails credentials:edit` to set them under
# action_push_native:apns:{key_id, encryption_key, team_id, topic}.
# `topic` is your iOS bundle identifier — the agent's rename pipeline
# changes the bundle id at generation time, so keeping `topic` here
# in source would silently desync after a rename.
apple:
# Token auth params
# See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns
key_id: <%= Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %>
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :apns, :encryption_key)&.dump %>

team_id: <%= Rails.application.credentials.dig(:action_push_native, :apns, :team_id) %>
# Your identifier found on https://developer.apple.com/account/resources/identifiers/list
topic: <%= Rails.application.credentials.dig(:action_push_native, :apns, :topic) %>

# Set this to the number of threads used to process notifications (default: 5).
# When the pool size is too small a ConnectionPoolTimeoutError will be raised.
# connection_pool_size: 5

# Change the request timeout (default: 30).
# request_timeout: 60

# Decide when to connect to APNs development server.
# Please note that anything built directly from Xcode and loaded on your phone will have
# the app generate DEVELOPMENT tokens, while everything else (TestFlight, Apple Store, ...)
# will be considered as PRODUCTION environment.
# connect_to_development_server: <%= Rails.env.development? %>

# Use bin/rails credentials:edit to set the fcm secrets under
# action_push_native:fcm:{project_id, encryption_key}.
google:
# Your Firebase project service account credentials
# See https://firebase.google.com/docs/cloud-messaging/auth-server
encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %>

# Firebase project_id
project_id: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :project_id) %>

# Set this to the number of threads used to process notifications (default: 5).
# When the pool size is too small a ConnectionPoolTimeoutError will be raised.
# connection_pool_size: 5

# Change the request timeout (default: 15).
# request_timeout: 30
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
namespace :api, format: "json" do
namespace :v1 do
namespace :shopkeeper do
resources :devices, only: %i[create destroy]
resources :permissions, only: %i[index]
resource :me, only: [], controller: :me do
member do
Expand Down
Loading