From db188c7dd7afc85758145e94a71a5ef3ac0953f7 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 09:28:56 +0900 Subject: [PATCH 1/8] Add push notifications scaffolding via noticed v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 9 ++ Gemfile | 5 + Gemfile.lock | 3 + .../api/v1/shopkeeper/devices_controller.rb | 45 +++++++++ app/models/device.rb | 18 ++++ app/models/shopkeeper.rb | 2 + app/notifiers/application_notifier.rb | 2 + app/notifiers/item_tag_called_notifier.rb | 18 ++++ app/policies/api/shopkeeper/device_policy.rb | 9 ++ app/serializers/device_serializer.rb | 10 ++ config/locales/en.yml | 5 + config/routes.rb | 1 + ...510001647_create_noticed_tables.noticed.rb | 37 ++++++++ ...ications_count_to_noticed_event.noticed.rb | 6 ++ db/migrate/20260510001856_create_devices.rb | 15 +++ db/schema.rb | 39 +++++++- .../v1/shopkeeper/devices_controller_test.rb | 92 +++++++++++++++++++ test/models/device_test.rb | 71 ++++++++++++++ .../item_tag_called_notifier_test.rb | 33 +++++++ 19 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/shopkeeper/devices_controller.rb create mode 100644 app/models/device.rb create mode 100644 app/notifiers/application_notifier.rb create mode 100644 app/notifiers/item_tag_called_notifier.rb create mode 100644 app/policies/api/shopkeeper/device_policy.rb create mode 100644 app/serializers/device_serializer.rb create mode 100644 db/migrate/20260510001647_create_noticed_tables.noticed.rb create mode 100644 db/migrate/20260510001648_add_notifications_count_to_noticed_event.noticed.rb create mode 100644 db/migrate/20260510001856_create_devices.rb create mode 100644 test/controllers/api/v1/shopkeeper/devices_controller_test.rb create mode 100644 test/models/device_test.rb create mode 100644 test/notifiers/item_tag_called_notifier_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f20f1dd..7177051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [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 `ItemTagCalledNotifier` (no provider config yet — APNs / FCM delivery + ItemTag AASM wiring land in PR #2 once credentials are provisioned) +- `Shopkeeper has_many :devices, :notifications`; new locale entries under `notifiers.item_tag_called` +- 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) diff --git a/Gemfile b/Gemfile index 006e682..9bb36c9 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index b6e9b7f..637d4f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -257,6 +257,8 @@ 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) ostruct (0.6.3) overcommit (0.69.0) @@ -488,6 +490,7 @@ DEPENDENCIES minitest-mock mission_control-jobs nokogiri (>= 1.12.5) + noticed (~> 2.7) overcommit pagy (~> 43) pg diff --git a/app/controllers/api/v1/shopkeeper/devices_controller.rb b/app/controllers/api/v1/shopkeeper/devices_controller.rb new file mode 100644 index 0000000..e11a249 --- /dev/null +++ b/app/controllers/api/v1/shopkeeper/devices_controller.rb @@ -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 diff --git a/app/models/device.rb b/app/models/device.rb new file mode 100644 index 0000000..4be785c --- /dev/null +++ b/app/models/device.rb @@ -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 diff --git a/app/models/shopkeeper.rb b/app/models/shopkeeper.rb index 59e049d..9889c65 100644 --- a/app/models/shopkeeper.rb +++ b/app/models/shopkeeper.rb @@ -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 diff --git a/app/notifiers/application_notifier.rb b/app/notifiers/application_notifier.rb new file mode 100644 index 0000000..90c8fd4 --- /dev/null +++ b/app/notifiers/application_notifier.rb @@ -0,0 +1,2 @@ +class ApplicationNotifier < Noticed::Event +end diff --git a/app/notifiers/item_tag_called_notifier.rb b/app/notifiers/item_tag_called_notifier.rb new file mode 100644 index 0000000..634d992 --- /dev/null +++ b/app/notifiers/item_tag_called_notifier.rb @@ -0,0 +1,18 @@ +class ItemTagCalledNotifier < ApplicationNotifier + notification_methods do + def title + I18n.t("notifiers.item_tag_called.title", number: record.name) + end + + def body + I18n.t("notifiers.item_tag_called.body", shop: record.shop.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 diff --git a/app/policies/api/shopkeeper/device_policy.rb b/app/policies/api/shopkeeper/device_policy.rb new file mode 100644 index 0000000..4b43b51 --- /dev/null +++ b/app/policies/api/shopkeeper/device_policy.rb @@ -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 diff --git a/app/serializers/device_serializer.rb b/app/serializers/device_serializer.rb new file mode 100644 index 0000000..477c5d7 --- /dev/null +++ b/app/serializers/device_serializer.rb @@ -0,0 +1,10 @@ +class DeviceSerializer + include JSONAPI::Serializer + + attributes :token, + :platform, + :bundle_id, + :last_active_at, + :created_at, + :updated_at +end diff --git a/config/locales/en.yml b/config/locales/en.yml index cbb192f..1227707 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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_called: + title: "Number %{number} is up" + body: "Please proceed to %{shop}." + api: shopkeeper: accounts: diff --git a/config/routes.rb b/config/routes.rb index 873e503..f443a72 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260510001647_create_noticed_tables.noticed.rb b/db/migrate/20260510001647_create_noticed_tables.noticed.rb new file mode 100644 index 0000000..cf7b236 --- /dev/null +++ b/db/migrate/20260510001647_create_noticed_tables.noticed.rb @@ -0,0 +1,37 @@ +# This migration comes from noticed (originally 20231215190233) +class CreateNoticedTables < ActiveRecord::Migration[6.1] + def change + primary_key_type, foreign_key_type = primary_and_foreign_key_types + create_table :noticed_events, id: primary_key_type do |t| + t.string :type + t.belongs_to :record, polymorphic: true, type: foreign_key_type + if t.respond_to?(:jsonb) + t.jsonb :params + else + t.json :params + end + + t.timestamps + end + + create_table :noticed_notifications, id: primary_key_type do |t| + t.string :type + t.belongs_to :event, null: false, type: foreign_key_type + t.belongs_to :recipient, polymorphic: true, null: false, type: foreign_key_type + t.datetime :read_at + t.datetime :seen_at + + t.timestamps + end + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end +end diff --git a/db/migrate/20260510001648_add_notifications_count_to_noticed_event.noticed.rb b/db/migrate/20260510001648_add_notifications_count_to_noticed_event.noticed.rb new file mode 100644 index 0000000..cf4f56f --- /dev/null +++ b/db/migrate/20260510001648_add_notifications_count_to_noticed_event.noticed.rb @@ -0,0 +1,6 @@ +# This migration comes from noticed (originally 20240129184740) +class AddNotificationsCountToNoticedEvent < ActiveRecord::Migration[6.1] + def change + add_column :noticed_events, :notifications_count, :integer + end +end diff --git a/db/migrate/20260510001856_create_devices.rb b/db/migrate/20260510001856_create_devices.rb new file mode 100644 index 0000000..a7a6c0d --- /dev/null +++ b/db/migrate/20260510001856_create_devices.rb @@ -0,0 +1,15 @@ +class CreateDevices < ActiveRecord::Migration[8.1] + def change + create_table :devices, id: :uuid, default: -> { "gen_random_uuid()" } do |t| + t.references :shopkeeper, type: :uuid, null: false, foreign_key: true + t.string :token, null: false + t.string :platform, null: false + t.string :bundle_id + t.datetime :last_active_at + + t.timestamps + end + + add_index :devices, [:platform, :token], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index dc162e4..a687f6b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_23_232155) do +ActiveRecord::Schema[8.1].define(version: 2026_05_10_001856) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -97,6 +97,18 @@ t.index ["platform", "version"], name: "index_app_versions_on_platform_and_version", unique: true end + create_table "devices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "bundle_id" + t.datetime "created_at", null: false + t.datetime "last_active_at" + t.string "platform", null: false + t.uuid "shopkeeper_id", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.index ["platform", "token"], name: "index_devices_on_platform_and_token", unique: true + t.index ["shopkeeper_id"], name: "index_devices_on_shopkeeper_id" + end + create_table "item_tags", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at", precision: nil @@ -117,6 +129,30 @@ t.index ["state"], name: "index_item_tags_on_state" end + create_table "noticed_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "notifications_count" + t.jsonb "params" + t.uuid "record_id" + t.string "record_type" + t.string "type" + t.datetime "updated_at", null: false + t.index ["record_type", "record_id"], name: "index_noticed_events_on_record" + end + + create_table "noticed_notifications", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.uuid "event_id", null: false + t.datetime "read_at", precision: nil + t.uuid "recipient_id", null: false + t.string "recipient_type", null: false + t.datetime "seen_at", precision: nil + t.string "type" + t.datetime "updated_at", null: false + t.index ["event_id"], name: "index_noticed_notifications_on_event_id" + t.index ["recipient_type", "recipient_id"], name: "index_noticed_notifications_on_recipient" + end + create_table "permissions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.string "name", null: false @@ -221,6 +257,7 @@ add_foreign_key "accounts_shopkeepers", "shopkeepers" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "devices", "shopkeepers" add_foreign_key "item_tags", "accounts" add_foreign_key "item_tags", "shopkeepers", column: "completed_by_id", on_delete: :nullify add_foreign_key "item_tags", "shopkeepers", column: "created_by_id", on_delete: :nullify diff --git a/test/controllers/api/v1/shopkeeper/devices_controller_test.rb b/test/controllers/api/v1/shopkeeper/devices_controller_test.rb new file mode 100644 index 0000000..a97a74c --- /dev/null +++ b/test/controllers/api/v1/shopkeeper/devices_controller_test.rb @@ -0,0 +1,92 @@ +require "test_helper" + +class Api::V1::Shopkeeper::DevicesControllerTest < ActionDispatch::IntegrationTest + setup do + @shopkeeper = shopkeepers(:one) + @shopkeeper.create_default_account + end + + test "create requires authentication" do + post api_v1_shopkeeper_devices_url, + params: {device: {token: "abc123", platform: "ios"}} + assert_response :unauthorized + end + + test "create registers a new device and returns 201" do + assert_difference -> { Device.count }, 1 do + post api_v1_shopkeeper_devices_url, + params: {device: {token: "abc123", platform: "ios", bundle_id: "com.nativeapptemplate.example"}}, + headers: @shopkeeper.create_new_auth_token + end + assert_response :created + attrs = response.parsed_body["data"]["attributes"] + assert_equal "abc123", attrs["token"] + assert_equal "ios", attrs["platform"] + assert_equal "com.nativeapptemplate.example", attrs["bundle_id"] + end + + test "create with same (platform, token) does not duplicate and returns 200" do + Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios", last_active_at: 1.day.ago) + + assert_no_difference -> { Device.count } do + post api_v1_shopkeeper_devices_url, + params: {device: {token: "abc123", platform: "ios"}}, + headers: @shopkeeper.create_new_auth_token + end + assert_response :ok + end + + test "create touches last_active_at on re-register" do + device = Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios", last_active_at: 1.day.ago) + original = device.last_active_at + + post api_v1_shopkeeper_devices_url, + params: {device: {token: "abc123", platform: "ios"}}, + headers: @shopkeeper.create_new_auth_token + + assert_response :ok + assert_operator device.reload.last_active_at, :>, original + end + + test "create rebinds device to current_shopkeeper if token previously belonged to someone else" do + other_shopkeeper = shopkeepers(:two) + Device.create!(shopkeeper: other_shopkeeper, token: "shared-token", platform: "ios") + + assert_no_difference -> { Device.count } do + post api_v1_shopkeeper_devices_url, + params: {device: {token: "shared-token", platform: "ios"}}, + headers: @shopkeeper.create_new_auth_token + end + assert_response :ok + assert_equal @shopkeeper, Device.find_by(platform: "ios", token: "shared-token").shopkeeper + end + + test "create returns 422 with missing platform" do + post api_v1_shopkeeper_devices_url, + params: {device: {token: "abc123"}}, + headers: @shopkeeper.create_new_auth_token + assert_response :unprocessable_entity + assert_equal 422, response.parsed_body["code"] + end + + test "destroy removes the device" do + device = Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + + assert_difference -> { Device.count }, -1 do + delete api_v1_shopkeeper_device_url(device), + headers: @shopkeeper.create_new_auth_token + end + assert_response :no_content + end + + test "destroy of another shopkeeper's device returns 404" do + other = shopkeepers(:two) + other_device = Device.create!(shopkeeper: other, token: "other-token", platform: "ios") + + assert_no_difference -> { Device.count } do + delete api_v1_shopkeeper_device_url(other_device), + headers: @shopkeeper.create_new_auth_token + end + assert_response :not_found + end +end diff --git a/test/models/device_test.rb b/test/models/device_test.rb new file mode 100644 index 0000000..0df1e7c --- /dev/null +++ b/test/models/device_test.rb @@ -0,0 +1,71 @@ +require "test_helper" + +class DeviceTest < ActiveSupport::TestCase + setup do + @shopkeeper = shopkeepers(:one) + end + + test "is valid with token + platform + shopkeeper" do + device = Device.new(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + assert device.valid? + end + + test "requires token" do + device = Device.new(shopkeeper: @shopkeeper, platform: "ios") + assert_not device.valid? + assert_includes device.errors[:token], "can't be blank" + end + + test "requires platform" do + device = Device.new(shopkeeper: @shopkeeper, token: "abc123") + assert_not device.valid? + assert_includes device.errors[:platform], "can't be blank" + end + + test "rejects unknown platform" do + assert_raises(ArgumentError) do + Device.new(shopkeeper: @shopkeeper, token: "abc123", platform: "blackberry") + end + end + + test "enforces uniqueness scoped to platform" do + Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + duplicate = Device.new(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + assert_not duplicate.valid? + assert_includes duplicate.errors[:token], "has already been taken" + end + + test "allows same token across different platforms" do + Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + other = Device.new(shopkeeper: @shopkeeper, token: "abc123", platform: "android") + assert other.valid? + end + + test "sets last_active_at on create when not provided" do + freeze_time do + device = Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + assert_equal Time.current, device.last_active_at + end + end + + test "preserves explicit last_active_at on create" do + explicit = 1.day.ago.beginning_of_minute + device = Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios", last_active_at: explicit) + assert_equal explicit, device.last_active_at + end + + test "active scope excludes devices stale > 90 days" do + fresh = Device.create!(shopkeeper: @shopkeeper, token: "fresh", platform: "ios", last_active_at: 1.day.ago) + stale = Device.create!(shopkeeper: @shopkeeper, token: "stale", platform: "ios", last_active_at: 100.days.ago) + active = Device.active + assert_includes active, fresh + assert_not_includes active, stale + end + + test "is destroyed when shopkeeper is destroyed" do + Device.create!(shopkeeper: @shopkeeper, token: "abc123", platform: "ios") + assert_difference -> { Device.count }, -1 do + @shopkeeper.destroy + end + end +end diff --git a/test/notifiers/item_tag_called_notifier_test.rb b/test/notifiers/item_tag_called_notifier_test.rb new file mode 100644 index 0000000..afbdf7d --- /dev/null +++ b/test/notifiers/item_tag_called_notifier_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class ItemTagCalledNotifierTest < ActiveSupport::TestCase + setup do + @shopkeeper = shopkeepers(:one) + @shopkeeper.create_default_account + @shop = @shopkeeper.created_shops.first + @item_tag = @shop.item_tags.first + end + + test "delivering creates a Noticed::Event" do + assert_difference -> { Noticed::Event.count }, 1 do + ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + end + end + + test "delivering creates a Noticed::Notification for the recipient" do + assert_difference -> { Noticed::Notification.count }, 1 do + ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + end + notification = @shopkeeper.notifications.last + assert_not_nil notification + assert_equal @item_tag, notification.record + end + + test "title and body resolve from i18n on the notification" do + ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + notification = @shopkeeper.notifications.last + + assert_equal I18n.t("notifiers.item_tag_called.title", number: @item_tag.name), notification.title + assert_equal I18n.t("notifiers.item_tag_called.body", shop: @shop.name), notification.body + end +end From c5046ab196e8bba537b172455d9d59c3bf5e5226 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 10:53:16 +0900 Subject: [PATCH 2/8] Wire deliver_by :action_push_native via Rails-native action_push_native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 +- Gemfile | 2 + Gemfile.lock | 40 +++++++++++++++++++ app/jobs/application_push_notification_job.rb | 7 ++++ app/models/application_push_device.rb | 4 ++ app/models/application_push_notification.rb | 12 ++++++ app/notifiers/item_tag_called_notifier.rb | 11 +++++ config/push.yml | 40 +++++++++++++++++++ ...n_push_native_device.action_push_native.rb | 13 ++++++ db/schema.rb | 13 +++++- 10 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 app/jobs/application_push_notification_job.rb create mode 100644 app/models/application_push_device.rb create mode 100644 app/models/application_push_notification.rb create mode 100644 config/push.yml create mode 100644 db/migrate/20260510015009_create_action_push_native_device.action_push_native.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 7177051..6bb43fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ - 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 `ItemTagCalledNotifier` (no provider config yet — APNs / FCM delivery + ItemTag AASM wiring land in PR #2 once credentials are provisioned) +- Add `ApplicationNotifier` base class + example `ItemTagCalledNotifier` 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_called` - 21 new test runs (Device model + DevicesController + notifier); full suite still 0 failures diff --git a/Gemfile b/Gemfile index 9bb36c9..9af9bdd 100644 --- a/Gemfile +++ b/Gemfile @@ -102,3 +102,5 @@ group :test do gem "selenium-webdriver", ">= 4.20.1" gem "webmock" end + +gem "action_push_native", "~> 0.3.1" diff --git a/Gemfile.lock b/Gemfile.lock index 637d4f8..472f6ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -260,6 +293,7 @@ GEM 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) @@ -395,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) @@ -471,6 +510,7 @@ PLATFORMS DEPENDENCIES aasm + action_push_native (~> 0.3.1) acts_as_tenant after_commit_everywhere (~> 1.6) bootsnap (>= 1.4.2) diff --git a/app/jobs/application_push_notification_job.rb b/app/jobs/application_push_notification_job.rb new file mode 100644 index 0000000..67dc1bb --- /dev/null +++ b/app/jobs/application_push_notification_job.rb @@ -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 diff --git a/app/models/application_push_device.rb b/app/models/application_push_device.rb new file mode 100644 index 0000000..a58f8f6 --- /dev/null +++ b/app/models/application_push_device.rb @@ -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 diff --git a/app/models/application_push_notification.rb b/app/models/application_push_notification.rb new file mode 100644 index 0000000..e3b4bd7 --- /dev/null +++ b/app/models/application_push_notification.rb @@ -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 diff --git a/app/notifiers/item_tag_called_notifier.rb b/app/notifiers/item_tag_called_notifier.rb index 634d992..04910fe 100644 --- a/app/notifiers/item_tag_called_notifier.rb +++ b/app/notifiers/item_tag_called_notifier.rb @@ -1,4 +1,15 @@ class ItemTagCalledNotifier < 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_called.title", number: record.name) diff --git a/config/push.yml b/config/push.yml new file mode 100644 index 0000000..db2bf5a --- /dev/null +++ b/config/push.yml @@ -0,0 +1,40 @@ +shared: + # Use bin/rails credentials:edit to set the apns secrets (as action_push_native:apns:key_id|encryption_key) + 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: your_apple_team_id + # Your identifier found on https://developer.apple.com/account/resources/identifiers/list + topic: your.bundle.identifier + + # 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 (as action_push_native:fcm: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: your_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 diff --git a/db/migrate/20260510015009_create_action_push_native_device.action_push_native.rb b/db/migrate/20260510015009_create_action_push_native_device.action_push_native.rb new file mode 100644 index 0000000..9f87d43 --- /dev/null +++ b/db/migrate/20260510015009_create_action_push_native_device.action_push_native.rb @@ -0,0 +1,13 @@ +# This migration comes from action_push_native (originally 20250610075650) +class CreateActionPushNativeDevice < ActiveRecord::Migration[8.0] + def change + create_table :action_push_native_devices do |t| + t.string :name + t.string :platform, null: false + t.string :token, null: false + t.belongs_to :owner, polymorphic: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index a687f6b..e790ab9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_05_10_001856) do +ActiveRecord::Schema[8.1].define(version: 2026_05_10_015009) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -48,6 +48,17 @@ t.index ["shopkeeper_id"], name: "index_accounts_shopkeepers_on_shopkeeper_id" end + create_table "action_push_native_devices", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name" + t.bigint "owner_id" + t.string "owner_type" + t.string "platform", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner" + end + create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "blob_id", null: false t.datetime "created_at", null: false From 49075523abc041646d070a53ca6e39d307512063 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 11:37:46 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Generalize=20notifier=20copy:=20%{number}?= =?UTF-8?q?=20=E2=86=92=20%{name},=20drop=20"Number"=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/notifiers/item_tag_called_notifier.rb | 2 +- config/locales/en.yml | 2 +- test/notifiers/item_tag_called_notifier_test.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/notifiers/item_tag_called_notifier.rb b/app/notifiers/item_tag_called_notifier.rb index 04910fe..0c72f53 100644 --- a/app/notifiers/item_tag_called_notifier.rb +++ b/app/notifiers/item_tag_called_notifier.rb @@ -12,7 +12,7 @@ class ItemTagCalledNotifier < ApplicationNotifier notification_methods do def title - I18n.t("notifiers.item_tag_called.title", number: record.name) + I18n.t("notifiers.item_tag_called.title", name: record.name) end def body diff --git a/config/locales/en.yml b/config/locales/en.yml index 1227707..216dfb9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -42,7 +42,7 @@ en: notifiers: item_tag_called: - title: "Number %{number} is up" + title: "%{name} is ready" body: "Please proceed to %{shop}." api: diff --git a/test/notifiers/item_tag_called_notifier_test.rb b/test/notifiers/item_tag_called_notifier_test.rb index afbdf7d..c5e6c6d 100644 --- a/test/notifiers/item_tag_called_notifier_test.rb +++ b/test/notifiers/item_tag_called_notifier_test.rb @@ -27,7 +27,7 @@ class ItemTagCalledNotifierTest < ActiveSupport::TestCase ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) notification = @shopkeeper.notifications.last - assert_equal I18n.t("notifiers.item_tag_called.title", number: @item_tag.name), notification.title + assert_equal I18n.t("notifiers.item_tag_called.title", name: @item_tag.name), notification.title assert_equal I18n.t("notifiers.item_tag_called.body", shop: @shop.name), notification.body end end From 0894cd2b7cc27321aca08c684e65f063bd3331f9 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 12:14:58 +0900 Subject: [PATCH 4/8] Generalize notifier: drop "Called" + state-verb copy (rename-resistant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 4 ++-- ...m_tag_called_notifier.rb => item_tag_notifier.rb} | 6 +++--- config/locales/en.yml | 6 +++--- ...ed_notifier_test.rb => item_tag_notifier_test.rb} | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) rename app/notifiers/{item_tag_called_notifier.rb => item_tag_notifier.rb} (72%) rename test/notifiers/{item_tag_called_notifier_test.rb => item_tag_notifier_test.rb} (60%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb43fc..50ab8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,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 `ItemTagCalledNotifier` with `deliver_by :action_push_native` wiring (Apple + Google push via Rails-native `action_push_native` 0.3.x) +- 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_called` +- `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 diff --git a/app/notifiers/item_tag_called_notifier.rb b/app/notifiers/item_tag_notifier.rb similarity index 72% rename from app/notifiers/item_tag_called_notifier.rb rename to app/notifiers/item_tag_notifier.rb index 0c72f53..cbc3593 100644 --- a/app/notifiers/item_tag_called_notifier.rb +++ b/app/notifiers/item_tag_notifier.rb @@ -1,4 +1,4 @@ -class ItemTagCalledNotifier < ApplicationNotifier +class ItemTagNotifier < ApplicationNotifier deliver_by :action_push_native do |config| config.devices = -> { ApplicationPushDevice.where(owner: recipient) } config.format = -> { @@ -12,11 +12,11 @@ class ItemTagCalledNotifier < ApplicationNotifier notification_methods do def title - I18n.t("notifiers.item_tag_called.title", name: record.name) + I18n.t("notifiers.item_tag.title", name: record.name) end def body - I18n.t("notifiers.item_tag_called.body", shop: record.shop.name) + I18n.t("notifiers.item_tag.body", shop: record.shop.name) end def url diff --git a/config/locales/en.yml b/config/locales/en.yml index 216dfb9..b792b5c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,9 +41,9 @@ en: default_shop_description: "This is a sample shop.\nYou can update or remove this shop." notifiers: - item_tag_called: - title: "%{name} is ready" - body: "Please proceed to %{shop}." + item_tag: + title: "%{name}" + body: "%{shop}" api: shopkeeper: diff --git a/test/notifiers/item_tag_called_notifier_test.rb b/test/notifiers/item_tag_notifier_test.rb similarity index 60% rename from test/notifiers/item_tag_called_notifier_test.rb rename to test/notifiers/item_tag_notifier_test.rb index c5e6c6d..b314a1d 100644 --- a/test/notifiers/item_tag_called_notifier_test.rb +++ b/test/notifiers/item_tag_notifier_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class ItemTagCalledNotifierTest < ActiveSupport::TestCase +class ItemTagNotifierTest < ActiveSupport::TestCase setup do @shopkeeper = shopkeepers(:one) @shopkeeper.create_default_account @@ -10,13 +10,13 @@ class ItemTagCalledNotifierTest < ActiveSupport::TestCase test "delivering creates a Noticed::Event" do assert_difference -> { Noticed::Event.count }, 1 do - ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + ItemTagNotifier.with(record: @item_tag).deliver(@shopkeeper) end end test "delivering creates a Noticed::Notification for the recipient" do assert_difference -> { Noticed::Notification.count }, 1 do - ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + ItemTagNotifier.with(record: @item_tag).deliver(@shopkeeper) end notification = @shopkeeper.notifications.last assert_not_nil notification @@ -24,10 +24,10 @@ class ItemTagCalledNotifierTest < ActiveSupport::TestCase end test "title and body resolve from i18n on the notification" do - ItemTagCalledNotifier.with(record: @item_tag).deliver(@shopkeeper) + ItemTagNotifier.with(record: @item_tag).deliver(@shopkeeper) notification = @shopkeeper.notifications.last - assert_equal I18n.t("notifiers.item_tag_called.title", name: @item_tag.name), notification.title - assert_equal I18n.t("notifiers.item_tag_called.body", shop: @shop.name), notification.body + assert_equal I18n.t("notifiers.item_tag.title", name: @item_tag.name), notification.title + assert_equal I18n.t("notifiers.item_tag.body", shop: @shop.name), notification.body end end From 98a6ae88976b8722b0fb6802ef163f9fc2d94c1c Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 12:18:17 +0900 Subject: [PATCH 5/8] Swap notifier title/body: shop in title, item name in body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/notifiers/item_tag_notifier.rb | 4 ++-- config/locales/en.yml | 4 ++-- test/notifiers/item_tag_notifier_test.rb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/notifiers/item_tag_notifier.rb b/app/notifiers/item_tag_notifier.rb index cbc3593..e1bbf09 100644 --- a/app/notifiers/item_tag_notifier.rb +++ b/app/notifiers/item_tag_notifier.rb @@ -12,11 +12,11 @@ class ItemTagNotifier < ApplicationNotifier notification_methods do def title - I18n.t("notifiers.item_tag.title", name: record.name) + I18n.t("notifiers.item_tag.title", shop: record.shop.name) end def body - I18n.t("notifiers.item_tag.body", shop: record.shop.name) + I18n.t("notifiers.item_tag.body", name: record.name) end def url diff --git a/config/locales/en.yml b/config/locales/en.yml index b792b5c..d107092 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -42,8 +42,8 @@ en: notifiers: item_tag: - title: "%{name}" - body: "%{shop}" + title: "%{shop}" + body: "%{name}" api: shopkeeper: diff --git a/test/notifiers/item_tag_notifier_test.rb b/test/notifiers/item_tag_notifier_test.rb index b314a1d..9b051ce 100644 --- a/test/notifiers/item_tag_notifier_test.rb +++ b/test/notifiers/item_tag_notifier_test.rb @@ -27,7 +27,7 @@ class ItemTagNotifierTest < ActiveSupport::TestCase ItemTagNotifier.with(record: @item_tag).deliver(@shopkeeper) notification = @shopkeeper.notifications.last - assert_equal I18n.t("notifiers.item_tag.title", name: @item_tag.name), notification.title - assert_equal I18n.t("notifiers.item_tag.body", shop: @shop.name), notification.body + assert_equal I18n.t("notifiers.item_tag.title", shop: @shop.name), notification.title + assert_equal I18n.t("notifiers.item_tag.body", name: @item_tag.name), notification.body end end From 58d3438bb5332b750c1ccda3f4a8db8614dc53df Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 12:30:45 +0900 Subject: [PATCH 6/8] 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). --- lib/templates/rails/credentials/credentials.yml.tt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/templates/rails/credentials/credentials.yml.tt b/lib/templates/rails/credentials/credentials.yml.tt index 19930e9..a64f8f1 100644 --- a/lib/templates/rails/credentials/credentials.yml.tt +++ b/lib/templates/rails/credentials/credentials.yml.tt @@ -10,3 +10,16 @@ active_record_encryption: # Resend resend: api_key: + +# Action Push Native — APNs (Apple) and FCM (Google) credentials. Read by +# config/push.yml. APNs needs the key_id (10-char identifier from the Apple +# Developer portal) and the .p8 contents pasted under encryption_key. FCM +# needs the Firebase service-account JSON pasted under encryption_key. +action_push_native: + apns: + key_id: + encryption_key: | + + fcm: + encryption_key: | + From bf7a601f5bec561ab1742cda2c1008420c9e73a3 Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 12:42:56 +0900 Subject: [PATCH 7/8] push.yml: move team_id/topic/project_id to credentials too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..*), 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. --- config/push.yml | 16 +++++++++++----- .../rails/credentials/credentials.yml.tt | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/config/push.yml b/config/push.yml index db2bf5a..b45f233 100644 --- a/config/push.yml +++ b/config/push.yml @@ -1,14 +1,19 @@ shared: - # Use bin/rails credentials:edit to set the apns secrets (as action_push_native:apns:key_id|encryption_key) + # 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: your_apple_team_id + 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: your.bundle.identifier + 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. @@ -23,14 +28,15 @@ shared: # will be considered as PRODUCTION environment. # connect_to_development_server: <%= Rails.env.development? %> - # Use bin/rails credentials:edit to set the fcm secrets (as action_push_native:fcm:encryption_key) + # 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: your_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. diff --git a/lib/templates/rails/credentials/credentials.yml.tt b/lib/templates/rails/credentials/credentials.yml.tt index a64f8f1..a020cf3 100644 --- a/lib/templates/rails/credentials/credentials.yml.tt +++ b/lib/templates/rails/credentials/credentials.yml.tt @@ -12,14 +12,26 @@ resend: api_key: # Action Push Native — APNs (Apple) and FCM (Google) credentials. Read by -# config/push.yml. APNs needs the key_id (10-char identifier from the Apple -# Developer portal) and the .p8 contents pasted under encryption_key. FCM -# needs the Firebase service-account JSON pasted under encryption_key. +# config/push.yml. All deployment-specific values live here, not in source +# (the agent's rename pipeline doesn't touch credentials, so values survive +# domain renames cleanly). +# +# APNs: +# key_id: 10-char identifier from the Apple Developer portal +# team_id: 10-char Apple Developer team identifier +# topic: your iOS app's bundle identifier +# encryption_key: contents of the .p8 file you downloaded from Apple +# FCM: +# project_id: Firebase project_id +# encryption_key: Firebase service-account JSON action_push_native: apns: key_id: + team_id: + topic: encryption_key: | fcm: + project_id: encryption_key: | From 4189fbc9a97d1f95b9919a5074aa8c5be3aecd3f Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 10 May 2026 13:06:12 +0900 Subject: [PATCH 8/8] openapi.yaml: document Device endpoints + schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/openapi.yaml | 111 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 63fcbc5..3631de5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -369,6 +369,58 @@ components: attributes: $ref: '#/components/schemas/PermissionAttributes' + DeviceAttributes: + type: object + properties: + token: + type: string + description: Push token (APNs device token for ios, FCM registration token for android) + platform: + type: string + enum: [ios, android] + bundle_id: + type: string + nullable: true + description: Bundle identifier of the registering app (helps disambiguate when one shopkeeper uses multiple variants generated by the agent) + last_active_at: + type: string + format: date-time + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + Device: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: [device] + attributes: + $ref: '#/components/schemas/DeviceAttributes' + + DeviceCreateRequest: + type: object + required: [device] + properties: + device: + type: object + required: [token, platform] + properties: + token: + type: string + platform: + type: string + enum: [ios, android] + bundle_id: + type: string + nullable: true + # --- Auth Resources --- ShopkeeperSignIn: @@ -708,6 +760,8 @@ tags: description: Email confirmation for shopkeeper accounts - name: Auth - Password Reset description: Forgot password and reset password flow + - name: Devices + description: Register and unregister mobile devices for push notifications - name: Permissions description: Retrieve permissions and app configuration metadata - name: Me @@ -992,6 +1046,63 @@ paths: $ref: '#/components/responses/Unauthorized' # ──────────────── Permissions ──────────────── + # ──────────────── Devices ──────────────── + /devices: + post: + operationId: registerDevice + summary: Register a mobile device for push notifications (idempotent upsert) + tags: [Devices] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceCreateRequest' + responses: + '201': + description: Device created + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Device' + '200': + description: Device already registered, last_active_at touched + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Device' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /devices/{deviceId}: + parameters: + - name: deviceId + in: path + required: true + schema: + type: string + format: uuid + + delete: + operationId: unregisterDevice + summary: Unregister a mobile device (sign-out, app uninstall, etc.) + tags: [Devices] + responses: + '204': + description: Device deleted + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + /permissions: get: operationId: listPermissions