Skip to content

Phase 1: Rails API substrate v2 refactor#45

Merged
dadachi merged 44 commits intomainfrom
phase1-rails-api
May 2, 2026
Merged

Phase 1: Rails API substrate v2 refactor#45
dadachi merged 44 commits intomainfrom
phase1-rails-api

Conversation

@dadachi
Copy link
Copy Markdown
Contributor

@dadachi dadachi commented Apr 24, 2026

Summary

Phase 1 of the substrate v2 transformation — turns this Rails API from a queue-specific template (NFC/QR/scan flows, A001-style queue numbers, 7-tier queue-operator roles) into a generic single-resource CRUD substrate (ShopItemTag with name/description/position and a binary state) that nativeapptemplate-agent can rename and re-skin for any domain.

Design context: docs/nativeapptemplate-substrate-v2-overview.md. Step-by-step plan: docs/phase1-rails-api.md.

Scope: 21 commits, 82 files, +397 / −1686 lines. No DB-data migration required — existing roles jsonb keys for removed roles are silently ignored.

What changed

ItemTag schema

  • Renamed queue_numbername (NOT NULL, no uniqueness constraint)
  • Added description (text, nullable), position (integer, nullable), composite index on (shop_id, position)
  • Removed scan_state, customer_read_at, already_completed, plus the (shop_id, queue_number) unique index

Models

  • ItemTag: dropped enum :scan_state + AASM block, scan_tag!, complete_tag!, reset!, queue-format/uniqueness validations, turbo_stream callbacks; kept AASM :state (complete/idle events) and the limit_count validation
  • Shop: dropped latest_completed_item_tag, create_default_item_tags! (A001-A010 generator), reset!, full_reload_entire_page. New after_create :create_sample_item_tag creates exactly one "Sample" ItemTag for first-run UX (rescue-logged so failure doesn't block Shop creation)
  • AccountsShopkeeper::ROLES: [:admin, :senior_manager, :junior_manager, :senior_member, :junior_member, :guest][:admin, :member]

API surface

  • Removed: POST /scan, GET /scan_customer, the entire display/ namespace, DELETE /shops/:id/reset, app root route + static controller
  • Renamed: PATCH /item_tags/:id/reset/idle (matches AASM event name; clearer separation from "delete")
  • Same shape, new fields: POST /shops/:id/item_tags accepts { "item_tag": { "name", "description", "position", "state" } }; name is the only required field
  • New behavior: creating a Shop auto-creates 1 "Sample" ItemTag (was 10 A00x queue numbers)

Roles & Permissions

admin member
create_shops
update_shops
delete_shops
update_organizations
invitation
read_data

Collaborative SaaS model (Notion/Linear/Trello-style): both tiers can freely CRUD resources; only admin can manage org and invite members. Rationale in overview §6.10–6.11.

ItemTagPolicy resolves to Shop permissions — no separate ItemTag permissions exist.

State machine

AASM :state with complete (idled → completed) and idle (idled/completed → idled) events. Controller actions inline the state transitions and completed_by/completed_at writes — no *_tag! wrapper methods on the model, so the agent's identifier rename (e.g. ItemTagTodo) doesn't have to rewrite method bodies.

Cascading cleanup

  • Removed lib/tasks/shop.rake (only task was create_default_item_tags)
  • Removed orphan app/views/layouts/display.html.erb
  • Removed stale docs/pagination-item-tags.md (pagy is wired in the controller, doc was pre-implementation notes)
  • Madmin resources updated to expose new ItemTag attributes and 2-tier role booleans
  • Locales: "number tag(s)" → "item tag(s)", attribute labels updated, removed-validation error keys dropped
  • OpenAPI spec (docs/openapi.yaml) regenerated to match (-90/+31)
  • Brakeman.ignore fingerprint refreshed (the dynamic permit(*ROLES) line gets a new fingerprint when the constant changes; same intentional rationale)

Test plan

  • bin/rails test → 358 runs, 705 assertions, 0 failures, 0 errors
  • bin/ci → all 6 steps green (Setup, Style: Ruby, Security: Gem audit, Security: Brakeman, Tests: Rails, Tests: Seeds)
  • Fresh DB rebuild verifies counts:
    bin/rails db:drop db:create db:migrate && bin/rails db:seed_fu
    bin/rails runner "puts [Permission.count, Role.count].inspect"  # => [6, 2]
  • Manual API smoke test:
    • Sign in, create a shop → response includes the 1 auto-created "Sample" item_tag
    • POST /api/v1/shopkeeper/shops/:id/item_tags with { "item_tag": { "name": "Buy milk" } } → 201, response uses name not queue_number
    • PATCH /api/v1/shopkeeper/item_tags/:id/complete → state becomes completed, completed_at set
    • PATCH /api/v1/shopkeeper/item_tags/:id/idle → state back to idled, completed_at cleared
    • DELETE /api/v1/shopkeeper/item_tags/:id → 200
  • iOS / Android client tracking issues opened for: queue_numbername rename, removed scan/QR screens, role UI collapse to 2 tiers, reset route → idle rename (these are Phase 2–5 of the substrate v2 plan)

Rollback

Pre-refactor state preserved at tag v1.0.0-with-nfc and branch v1-with-nfc. To revert:

git checkout v1-with-nfc

dadachi and others added 30 commits April 24, 2026 08:06
… item

Removes queue-specific Shop methods (latest_completed_item_tag,
create_default_item_tags!, reset!, full_reload_entire_page) and
replaces the after_create callback with create_sample_item_tag,
which generates one generic 'Sample' ItemTag for first-run UX.

Cascades caller cleanup:
- shops_controller#reset action removed
- delete :reset route removed
- ShopPolicy#reset? removed
- lib/tasks/shop.rake (only task was create_default_item_tags) deleted
- Sort by (position, name) instead of queue_number
- Strip already_completed cache-purge hack from #complete
- Rename #reset action to #idle (matches AASM event); route updated
- Strong params: permit name, description, position, state
Removes Shop#complete_tag! and ItemTag#reset! (both carried queue-era
'_tag' residue and 'reset' semantics). Controller actions now call
AASM's generated complete!/idle! events directly with completed_by /
completed_at set inline. This keeps method names domain-agnostic so
nativeapptemplate-agent's identifier rename (e.g. ItemTag to Todo)
doesn't need to rewrite method bodies.
ItemTagSerializer:
- Drop queue_number, scan_state, customer_read_at, already_completed
- Add name, description, position

ShopSerializer:
- Drop scanned_item_tags_count (referenced removed scan_state)
- Drop display_shop_server_path (display route removed)
- Remove references to senior_manager?/junior_manager?/senior_member?/junior_member? predicates
- Both admin and member can perform all ItemTag operations (collaborative model)
- Rename reset? to idle? to match controller action

Note: member? predicate doesn't yet exist; defined when AccountsShopkeeper::ROLES is updated in Step 13.
Permissions: 6 generic CRUD primitives — create_shops, update_shops,
delete_shops, update_organizations, invitation, read_data.
Removes 5 queue-specific perms (manage_tags, write_info_to_tags,
reset_all_tags, complete_or_reset_tags, show_tag_info).

Roles: 2 tiers — admin, member. Removes 5 queue-operator tiers
(senior_manager, junior_manager, senior_member, junior_member, guest).

Mappings (collaborative SaaS / Notion-style):
- admin: all 6 permissions
- member: create_shops, update_shops, delete_shops, read_data
  (no update_organizations or invitation)

Applied identically to all 4 envs (development, test, staging, production).
A fresh db rebuild + db:seed_fu is required to drop orphan rows from
prior fixture loads.
AccountsShopkeeper::ROLES = [:admin, :member]. AccountsInvitation
inherits via constant aliasing, so both models drop the 4 queue-era
intermediate roles (senior_manager, junior_manager, senior_member,
junior_member) and 'guest'.

Madmin resources (AccountsShopkeeperResource, AccountsInvitationResource)
updated to expose only :admin and :member attributes.

Rolified concern is generic (iterates ROLES) so no change needed there.
Existing rows' 'roles' jsonb column may carry orphan keys for removed
roles — these are silently ignored since the model only reads ROLES.
- Rename 'number tag(s)' → 'item tag(s)' in user-visible strings
  (aligns with substrate v2 UI label rename in overview §2.4)
- Replace activerecord.attributes.item_tag.queue_number with
  name / description / position
- Drop queue_number format/uniqueness error keys (validations gone)

No removed permission/role tag refs existed in en.yml; no ja.yml present.
- ItemTagTest: rewrite around name/description/position; drop
  scan_tag!/complete_tag!/reset!/scan!/unscan! tests and queue_number
  format/uniqueness coverage; assert names may duplicate across and
  within shops
- ShopTest: replace default-item-tags / queue_number /
  latest_completed_item_tag / Shop#reset! tests with create_sample_item_tag
  success path and a stubbed-failure path covering the rescue branch
- AccountsShopkeeperTest: collapse per-tier permission/role-helper tests
  to admin and member only
- AccountsInvitationTest, AccountTest, RoleTest, ShopkeeperTest:
  rename junior_member/guest references to member
- ItemTagSerializerTest: drop scan_state/customer_read_at/already_completed
  assertions; switch the complete-flow test to the inlined controller
  pattern (set completed_by/at then call AASM complete!)
- ShopSerializerTest: drop scanned_item_tags_count and
  display_shop_server_path tests (attributes removed)
- AccountSerializerTest, AccountsShopkeeperSerializerTest,
  AccountsInvitationSerializerTest: rename old-tier role refs to member
- ItemTagPolicyTest: collapse per-tier tests to admin+member; rename
  reset? cases to idle?
- ShopPolicyTest: drop reset? tests (action removed in Step 7)
- PermissionPolicyTest: replace guest with admin/member coverage
- AccountPolicyTest, AccountsShopkeeperPolicyTest,
  AccountsInvitationPolicyTest: rename old-tier role refs to member
- ItemTagsControllerTest: switch payloads from queue_number to name;
  drop invalid-format and already_completed cases; rename reset to idle;
  cover the duplicate-name-allowed case
- ShopsControllerTest: drop the reset action test (action removed)
- BaseControllerTest: rewire the validation-error case to use blank name
  instead of duplicate queue_number
- Accounts/AccountsInvitations/AccountsShopkeepers/Permissions/
  RegistrationsControllerTest: rename old-tier role refs to member
- Drop the extra blank line at end of shops_controller_test.rb class body
  (Layout/EmptyLinesAroundClassBody, leftover from Step 7 reset action removal)
- Refresh brakeman.ignore: AccountsShopkeeper::ROLES collapse changed the
  expanded permit() literal, so the warning fingerprint shifted. Same
  intentional rationale (admin-gated role assignment); only the resolved
  role list and fingerprint differ.
README.md:
- Drop the 'real-time page updates for Number Tags Webpage' qualifier on Turbo
- Rename 'Number Tags (ItemTags)' to 'Item Tags' in the features list

CLAUDE.md:
- Correct the ItemTag description: name has no uniqueness constraint;
  describe the columns (name/description/position) and binary state instead

docs/openapi.yaml (-90/+31):
- ItemTag schema: drop queue_number/scan_state/customer_read_at/already_completed,
  add name (required)/description/position
- Shop schema: drop scanned_item_tags_count and display_shop_server_path
- /permissions meta: drop maximum_queue_number_length
- Roles: drop senior_manager/junior_manager/senior_member/junior_member/guest,
  add member (5 sites: AccountsShopkeeper attrs/update, AccountsInvitation
  attrs/create/update)
- Endpoints: remove DELETE /shops/{shopId}/reset; rename
  PATCH /item_tags/{itemTagId}/reset → /idle (resetItemTag → idleItemTag)
- ItemTag create/update request bodies switched to name+description+position+state

Also delete:
- docs/pagination-item-tags.md (pre-implementation notes; pagy is now wired in
  the controller, doc is stale)
- app/views/layouts/display.html.erb (orphaned after Step 3 display
  namespace removal — no controllers reference this layout)
Step 17 residual sweep caught references the per-step grep missed:
- config/settings.yml: drop maximum_queue_number_length and the
  item_tag.default_count / default_queue_number_length keys (only
  consumed by the removed format validator and create_default_item_tags!)
- PermissionsController#index meta: drop maximum_queue_number_length
  (clients no longer need to enforce the queue-format length cap)
- permissions_controller_test: drop the matching assertion
Without an explicit position, the sample row gets NULL, which Postgres
sorts last by default — surprising for a single-row first-run state and
fragile once a client adds more items without managing position
themselves. Setting position 1 gives the sample a definite slot and
keeps the (:position, :name) sort meaningful for clients that don't
implement reordering.
Both auto-clicker controllers were used only by the display namespace
queue-board views (now deleted in Step 3). The eager loader in
controllers/index.js needs no change — it just won't find them.
Stimulus itself is now essentially dead weight; defer the wider
JS-stack removal to a follow-up sweep alongside Turbo.
Without this, clients that don't manage position end up with NULL
positions on every item, the (shop_id, position) index goes unused,
and ordering between user-added items falls to the :name tiebreaker.

before_create callback no-ops when position was explicitly supplied,
so Shop's create_sample_item_tag (position: 1) and clients that send
their own positions are unaffected. Lighter than acts_as_list (no
gem, no decrement-on-delete machinery — that stays out per phase 1
pitfall #7); this is just a sensible append-to-end default.
The substrate v2 design (overview §2.1 table) grants create_shops,
update_shops, and delete_shops to both admin and member tiers.
Pre-refactor policy was stricter than the design called for:
- create? required owner? (so even non-owner admins couldn't create)
- update? required admin? (so members couldn't update)
- destroy? delegated to create? (so only owner could destroy)

Aligned with the design and with ItemTagPolicy's pattern. Tests
rewritten — two old assertions inverted (non-owner admin can now
create; member can now update), and two read-only true-for-all tests
specialised into admin and member variants for symmetry.
Assigns sequential position values per shop, ordered by created_at,
starting from current max+1, skipping callbacks/validations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it, deliver_later jobs (e.g. invitation emails) sit in the
queue unprocessed since no worker runs in development. The Puma
plugin (config/puma.rb) already activates when this env var is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dadachi and others added 14 commits April 27, 2026 10:32
bin/setup covers the dev-environment refresh flow; bin/update was a
legacy Rails generator artifact and was no longer referenced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixture-only role redesign (c42249e) left production with stale data:
- accounts_shopkeepers / accounts_invitations jsonb roles still keyed
  on senior_manager, junior_manager, senior_member, junior_member, guest
- orphan rows in roles, permissions, roles_permissions for the dropped
  tiers and queue-specific perms (manage_tags, write_info_to_tags,
  reset_all_tags, complete_or_reset_tags, show_tag_info)

The task folds non-admin keys to member, preserves admin, and deletes
orphan role/permission/roles_permission rows in a single transaction.
Idempotent — safe to re-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Substrate v2 drops POST /scan and GET /scan_customer; the only
Universal Link rule in the manifest was /scan/*?item_tag_id=…, which
no longer resolves. No remaining iOS Universal Link routes in v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
API-only Rails app — no Turbo runtime in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the "switch organization" hint — Free client v2 has no
Organizations tab, so the guidance is misleading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers ErrorsController, AdminUser, the static
ShopkeeperAuth::{ConfirmationResults,ResetPasswords} pages,
ShopkeeperMailer, and Shopkeeper::NotificationMailer (invited,
confirmation_instructions, reset_password_instructions).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ErrorsController: extract shared render_error helper for the
  duplicated JSON/HTML branching
- RegistrationsController: render_*_error hooks now delegate to a
  single private render_resource_error to remove the triple copy
- Account/Shop/ItemTag/AccountsShopkeeper: rename `the_limit_count`
  local to plain `limit`
- AccountsInvitation#set_token: collapse the unique-token loop to
  use `break` as the assignment value
- Shopkeeper: drop two commented-out send_devise_notification lines
  superseded by the custom mailer

Behavior preserved; full test suite passes (393 runs, 792 assertions).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Rate-limit shopkeeper sign-up to 5/IP/hour

Mass account creation is currently only constrained by the broad
300/IP/5min rack-attack req/ip cap. Add an endpoint-specific limit
on POST /shopkeeper_auth using Rails 8's built-in
ActionController::RateLimiting: 5 requests per IP per hour. When
exceeded, render 429 with a localized JSON error.

Switch the test cache from :null_store to :memory_store so the
limiter's counters can persist within a request sequence
(:null_store no-ops increments). Clear Rails.cache in the standard
test setup so throttle state doesn't leak between tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tune sign-up rate limit to 10/3min (match jumpstart-pro reference)

Aligns with the reference codebase (jumpstart-pro-rails uses
`to: 10, within: 3.minutes` on user sign-up). The earlier 5/1.hour
was overly strict for legit users — a confused user with bad
password rules or autofill misfires could exhaust the quota and
get locked out for an hour. 10/3min absorbs realistic retry flows
while still constraining bots; per-IP limits are not the primary
defense against rotating-IP attackers anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#49)

* Fix two latent bugs in permissions and sign-in flows

PermissionsController#index compared `confirmed_*_version <
current_*_version`, where `current_version` returns nil if no row is
flagged current. `string < nil` raises ArgumentError → 500. Add a
`version_outdated?` helper that treats a missing current version as
"nothing to update".

ShopkeeperAuth::SessionsController#create unconditionally assigned
`request.headers["source"]` to current_platform and called
`save!(validate: false)`. Sign-ins without the source header
overwrote the user's stored platform with nil, bypassing the
presence/inclusion validation. Now skip the assignment when the
header is blank.

Adds regression tests for both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert PermissionsController nil-version guard

Per review: a missing current PrivacyVersion/TermsVersion is a
server-side data integrity problem (no row published as current).
Crashing loud is preferable to silently telling the client "you're up
to date" — the latter would mask the data issue and mislead clients.

Keeps the SessionsController fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Require 'source' header on shopkeeper sign-in

Per direction: the source header (ios/android) is mandatory. Reject
the request with 401 when missing instead of skipping the
current_platform update — silently signing the user in without the
header would let API tools accumulate sessions that lack platform
attribution and bypass the presence/inclusion validation on Shopkeeper.

Adds the locale key and updates the regression test for the
blank-header path. Updates the existing "no params" test to send the
header so the bad_credentials assertion remains the path under test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make 'source' header optional on shopkeeper sign-in

Reverts the earlier "require source header" form. Anti-mass-signup
is now handled at the right layer by the sign-up rate_limit
introduced in PR #50, so the sign-in header has no security job
left. current_platform is informational metadata; rejecting
sign-ins on missing metadata is too aggressive — it breaks
non-mobile callers (curl, CI, integration tools, future web client)
without a real benefit.

Skip the current_platform update when the header is blank: the
existing stored value is preserved (instead of being nuked to nil
by the original buggy code path). Drop the missing_source locale
key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidates the rack-attack `logins/ip` and `logins/email`
throttles onto the controller using Rails 8's
ActionController::RateLimiting. Rules now sit next to the action
they guard, mirroring the sign-up `rate_limit` shipped earlier.
The general `req/ip` cap stays in rack-attack since `rate_limit`
is per-controller and middleware is the right layer for an
app-wide flood cap.

The email throttle uses `if: -> { params[:email].present? }` to
preserve the original "skip when no email" behavior — without
this, all email-less requests would share a single counter.

Adds an integration test covering both the IP-keyed throttle
(same IP, different emails) and the email-keyed throttle (same
email, rotating IPs).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verified-unreferenced removals (every grep below ran across
app/, lib/, config/ and matched only the definition site):

- gem "jbuilder" — no .jbuilder views, no JBuilder usage; all
  responses go through jsonapi-serializer
- gem "inline_svg" — no inline_svg helper calls anywhere
- locale `forgot_your_password` — no callers
- locale `send_me_reset_password_instructions` — no callers
- locale `api.shopkeeper.accounts.owner_required` — no callers
- locale `api.shopkeeper.accounts.admin_required` — no callers

Kept `validate_sign_up_params` / `validate_account_update_params`
overrides in RegistrationsController despite looking unused — the
parent class wires them up via `before_action` (verified in the
gem source).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…54)

Per docs-private/SUBSTRATE-NAMING-NORMALIZATION.md, all three substrates
should use the canonical NATIVEAPPTEMPLATE_ stem so the agent's existing
NativeAppTemplate -> <slug-pascal> rename pair handles every product-name
occurrence. The Rails repo has no boot/config references to these env
vars - only doc references that point users at the iOS/Android values -
so update them for parity.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routine bundle update: bigdecimal, bootsnap, erb, ffi, irb, json,
minitest, net-imap, nokogiri, pagy, parallel, parser, propshaft,
puma, regexp_parser, rubocop, rubocop-ast, tailwindcss-ruby.
Tests, rubocop, brakeman, bundler-audit all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dadachi dadachi changed the title Phase 1: Rails API substrate v2 refactor Phase 1: Rails API substrate v2 refactor [render preview] May 2, 2026
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-cable PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-cache PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi-queue PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi deployed to phase1-rails-api - nativeapptemplateapi PR #45 May 2, 2026 05:29 — with Render Active
@dadachi dadachi temporarily deployed to phase1-rails-api - nativeapptemplateapi PR #45 May 2, 2026 05:30 — with Render Destroyed
@dadachi dadachi changed the title Phase 1: Rails API substrate v2 refactor [render preview] Phase 1: Rails API substrate v2 refactor May 2, 2026
@dadachi dadachi merged commit 45a0674 into main May 2, 2026
3 checks passed
@dadachi dadachi deleted the phase1-rails-api branch May 2, 2026 05:45
dadachi added a commit that referenced this pull request May 2, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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