diff --git a/CHANGELOG.md b/CHANGELOG.md index f20f1dd..50ab8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Gemfile b/Gemfile index 006e682..9af9bdd 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" @@ -97,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 b6e9b7f..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 @@ -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) @@ -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) @@ -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) @@ -488,6 +530,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/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/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_notifier.rb b/app/notifiers/item_tag_notifier.rb new file mode 100644 index 0000000..e1bbf09 --- /dev/null +++ b/app/notifiers/item_tag_notifier.rb @@ -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 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..d107092 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: + title: "%{shop}" + body: "%{name}" + api: shopkeeper: accounts: diff --git a/config/push.yml b/config/push.yml new file mode 100644 index 0000000..b45f233 --- /dev/null +++ b/config/push.yml @@ -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 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/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 dc162e4..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_04_23_232155) 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 @@ -97,6 +108,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 +140,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 +268,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/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 diff --git a/lib/templates/rails/credentials/credentials.yml.tt b/lib/templates/rails/credentials/credentials.yml.tt index 19930e9..a020cf3 100644 --- a/lib/templates/rails/credentials/credentials.yml.tt +++ b/lib/templates/rails/credentials/credentials.yml.tt @@ -10,3 +10,28 @@ active_record_encryption: # Resend resend: api_key: + +# Action Push Native — APNs (Apple) and FCM (Google) credentials. Read by +# 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: | + 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_notifier_test.rb b/test/notifiers/item_tag_notifier_test.rb new file mode 100644 index 0000000..9b051ce --- /dev/null +++ b/test/notifiers/item_tag_notifier_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class ItemTagNotifierTest < 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 + 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 + ItemTagNotifier.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 + ItemTagNotifier.with(record: @item_tag).deliver(@shopkeeper) + notification = @shopkeeper.notifications.last + + 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