Skip to content

Add push notifications scaffolding via noticed v2#59

Merged
dadachi merged 8 commits intomainfrom
add-push-notifications-scaffolding
May 10, 2026
Merged

Add push notifications scaffolding via noticed v2#59
dadachi merged 8 commits intomainfrom
add-push-notifications-scaffolding

Conversation

@dadachi
Copy link
Copy Markdown
Contributor

@dadachi dadachi commented May 10, 2026

Summary

PR #1 of 5 in #58 — scaffolds the Rails-side groundwork for native push notifications.

This PR is provider-credential-agnostic: APNs .p8 + FCM service-account JSON aren't required to merge or run tests. Provider integration + ItemTag AASM wiring lands in PR #2 once credentials are provisioned. Client work (free + paid iOS/Android) follows in PRs #3#5.

What lands

  • noticed v2.x gem + the two engine migrations (Noticed::Event, Noticed::Notification, both UUID-keyed to match this substrate's primary_key_type :uuid).
  • 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 (Api::Shopkeeper::BasePolicy + JSONAPI::Serializer).
  • ApplicationNotifier base + example ItemTagCalledNotifier — title/body/url i18n-resolved via notification_methods. No delivery methods wired yet; PR test4 #2 adds deliver_by :ios + :android and triggers from ItemTag's AASM complete event.
  • Shopkeeper gains has_many :devices (dependent: :destroy) and has_many :notifications, as: :recipient, class_name: 'Noticed::Notification'.
  • Locale entries under notifiers.item_tag_called.

Test plan

  • bin/rails test — 419 runs, 868 assertions, 0 failures, 0 errors, 0 skips (was 398/815 before; +21 new runs / +53 assertions, all green).
  • bin/rubocop — 239 files inspected, no offenses.
  • bin/rails db:migrate — all 3 new migrations apply cleanly (UUID primary keys, indexes, FK).
  • Re-running POST /devices with same (platform, token) doesn't duplicate; touches last_active_at instead.
  • DELETE on another shopkeeper's device returns 404, not 403 (intentional — current scoping via current_shopkeeper.devices.find rather than pundit-then-find; matches RecordNotFound rescue contract elsewhere in the controllers).

Known follow-ups (not this PR)

  • PR test4 #2: noticed initializer + apnotic + googleauth deps + deliver_by :ios + :android config + AASM complete trigger on ItemTag + integration test (mocked APNs/FCM). Blocked on credentials.
  • PRs [render preview]test5 #3fix README #5: client-side push registration in free + paid iOS/Android.

🤖 Generated with Claude Code

dadachi and others added 8 commits May 10, 2026 09:29
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).
Install action_push_native 0.3.x and generate ApplicationPushNotification,
ApplicationPushDevice, ApplicationPushNotificationJob, and config/push.yml.
Add deliver_by :action_push_native to ItemTagCalledNotifier so push
notifications route through Rails 8.1's Action Push Native (single
abstraction over APNs + FCM) instead of the Noticed gem's per-platform
:ios / :fcm deliverers.

APNs/FCM credentials remain placeholders in config/push.yml — provision
via bin/rails credentials:edit before enabling delivery. Bridging the
existing Device registration API to ApplicationPushDevice (so registered
tokens actually flow into Action Push Native delivery) is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
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.
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.
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).
Three deployment-specific values were still hard-coded as placeholders
in config/push.yml:

- apple.team_id    (Apple Developer team identifier — per-deployer)
- apple.topic      (iOS bundle identifier — per-deployment)
- google.project_id (Firebase project identifier — per-deployment)

These don't belong in source. apple.topic in particular is a rename-
pipeline trap: the agent renames the iOS bundle id when generating a
domain-customized variant (com.nativeapptemplate.* → com.<spec>.*),
but the rename pipeline only operates on code/locales/OpenAPI — not
on push.yml strings. So a hard-coded `your.bundle.identifier` here
silently desyncs from the renamed app's actual bundle id and push
delivery breaks with a non-obvious error.

Move all three to Rails.application.credentials.dig(:action_push_native,
...) so they're deploy-time configuration, not source-controlled
state. Add the same fields to the credentials.yml.tt template so
`bin/rails credentials:edit` exposes the expected key paths.

Tests + rubocop clean.
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.
@dadachi dadachi merged commit d31f779 into main May 10, 2026
3 checks passed
@dadachi dadachi deleted the add-push-notifications-scaffolding branch May 10, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant