feat(inventory): read-only detail view for firearm and magazine records (#19) - #38
Conversation
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…iew (#19) Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…nly (#19) Magazine editing is owner-only per the read-only-detail-view scope (R13): route updateMagazine through a new authorizeOwnerOnlyUpdate so an edit-grantee's save is rejected server-side, and stop offering an edit grant when sharing magazines. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Dedicated permission-aware detail page: all fields incl. serial for any viewer (R4), owner/edit-gated Edit and owner-only Delete/Share (R8), in-place edit form (R11), embedded read-only range-session history (R14), delete redirects to the list (R15), heading focus + back link (R16/R18). Shared app/(app)/not-found.tsx renders the accessible 404 for no-access/revoked records (R9). Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Owner-only actions (Edit/Delete/Share) per R13; view- and edit-grantees get a purely read-only page (R7/R8). In-place edit via MagazineForm with the owner's Magpul mode (R11), delete redirects to the list (R15), not-found for no-access records (R9), heading focus + back link (R16/R18). Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…ns (#19) Row names link to /firearms/[id] and /magazines/[id] for every viewer (R6), with a disambiguating accessible name when displayed names collide (R17). Inline Edit (both) and Sessions (firearm) move to the detail page; owner-only quick Delete and Share stay on the list (R8/R10). A view-only grantee's row shows only the name link (R7). Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
… flow (#19) New detail-view-sharing spec proves owner full actions, view-only read-only pages (serial + read-only sessions visible, no controls), magazine view-only sharing, not-found for no-access URLs, and delete-from-detail returning to the list (R19, AE1-AE7). Six existing specs move their edit/session interactions from the removed list-row buttons to the detail-page flow. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…column text (#19) Using the label/caliber as the accessible-name suffix collided with those columns' own cells in the accessibility tree (a getByRole cell lookup matched two elements). Switch to a non-sensitive id fragment (R52), which never duplicates visible column text. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…field (#19) Code review (correctness) found that a non-uuid path id (a typo'd or truncated URL) raised a Postgres uuid-cast error instead of a clean not-found, since there is no error boundary — validate the id shape at the request boundary and 404 early (R9), with e2e coverage. Also removes the now-dead FirearmListItem permission field (unused once Sessions moved to the detail page). Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
WalkthroughAdds firearm and magazine detail routes with permission-aware rendering, list-to-detail navigation, owner-only magazine updates, shared not-found handling, Playwright coverage, and local tooling updates. ChangesFirearm and Magazine Detail Views
Tooling and workflow config
Sequence Diagram(s)sequenceDiagram
participant Browser
participant FirearmDetailPage
participant Auth
participant FirearmsService
Browser->>FirearmDetailPage: request /firearms/[id]
FirearmDetailPage->>Auth: getCurrentUser()
Auth-->>FirearmDetailPage: user or none
FirearmDetailPage->>FirearmDetailPage: isUuid(id)
FirearmDetailPage->>FirearmsService: getFirearm(user.id, id)
FirearmsService-->>FirearmDetailPage: firearm + permission or NotFoundError
FirearmDetailPage->>FirearmsService: calibersForInput, magazineCountForFirearm, listFirearms
FirearmDetailPage-->>Browser: render FirearmDetailView
sequenceDiagram
participant Client
participant MagazinesService
participant AuthorizeOwnerOnlyUpdate
participant Database
Client->>MagazinesService: updateMagazine(...)
MagazinesService->>AuthorizeOwnerOnlyUpdate: check actor permission
AuthorizeOwnerOnlyUpdate-->>MagazinesService: owner / NotAuthorizedError / NotFoundError
MagazinesService->>Database: persist magazine changes
Database-->>MagazinesService: updated row
MagazinesService-->>Client: result
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
✨ Simplify code
Comment |
There was a problem hiding this comment.
Pull request overview
Adds dedicated, permission-aware read-only detail routes for firearms and magazines so view-only grantees have a correct destination and owners get a safe “inspect” surface, while relocating per-record actions off list rows. It also tightens magazine update authorization to be owner-only server-side and updates CI/dev tooling documentation/automation around the repo’s workflow.
Changes:
- Introduce
/firearms/[id]and/magazines/[id]detail pages with permission-gated actions, shared 404, and UUID boundary validation. - Enforce magazine updates as owner-only via new
authorizeOwnerOnlyUpdateand remove “edit” as a shareable permission for magazines in the Share UI. - Update Playwright E2E specs to navigate via the new detail routes; add new E2E/integration coverage; add/align local tooling (
justfile, taplo/mdformat config, SBOM ignore).
Reviewed changes
Copilot reviewed 28 out of 30 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/uuid.ts | Adds isUuid() route-param guard to avoid Postgres uuid cast errors and enable clean 404s. |
| src/domain/magazines/service.ts | Switches magazine update authorization to owner-only (authorizeOwnerOnlyUpdate). |
| src/domain/magazines/tests/authorize-owner-only.test.ts | Adds live-gated integration coverage for owner-only magazine updates. |
| src/auth/authorize.ts | Adds authorizeOwnerOnlyUpdate() helper to mirror owner-only delete semantics. |
| mise.toml | Formatting/alignment tweak (shellcheck entry). |
| justfile | Adds repo task runner recipes including ci-check gate, test/e2e helpers, SBOM, etc. |
| hooks/use-delete-confirmation.ts | Adds optional post-delete redirect for detail pages while reusing the shared delete flow. |
| e2e/range-sessions.spec.ts | Migrates navigation to firearm detail page for sessions workflow. |
| e2e/range-sessions-sharing.spec.ts | Updates sharing/session flow to use detail routes and asserts view-only gating. |
| e2e/magpul-settings.spec.ts | Updates edit flow to go through magazine detail route. |
| e2e/inventory-crud.spec.ts | Updates magazine edit flow to go through detail route and returns to list. |
| e2e/fixtures/user-pool.ts | Adds seeded users for the new detail-view sharing spec. |
| e2e/firearm-taxonomy.spec.ts | Updates firearm edit flow to go through detail route. |
| e2e/firearm-nickname.spec.ts | Updates firearm edit flow to go through detail route and returns to list. |
| e2e/detail-view-sharing.spec.ts | New end-to-end spec covering detail view permissions, not-found behavior, and delete redirect. |
| docs/plans/2026-07-04-001-feat-firearm-magazine-detail-view-plan.md | Adds implementation-ready plan and acceptance examples for the feature. |
| app/(app)/not-found.tsx | Adds shared accessible 404 page for the app segment. |
| app/(app)/magazines/magazines-view.tsx | Converts row names to links to detail route; removes inline edit form usage; keeps owner quick actions. |
| app/(app)/magazines/magazine-detail-view.tsx | New magazine detail client view with read-only layout and owner-only actions/edit-in-place. |
| app/(app)/magazines/[id]/page.tsx | New magazine detail server route (loads record + options, UUID guard, notFound on NotFoundError). |
| app/(app)/grants/share-control.tsx | Removes “edit” option for magazines (view-only sharing), preserving edit sharing for firearms. |
| app/(app)/firearms/range-session-history.tsx | Makes onClose optional so sessions history can be embedded on the detail page. |
| app/(app)/firearms/page.tsx | Removes now-dead per-row permission field from firearms list items. |
| app/(app)/firearms/firearms-view.tsx | Converts row names to links; removes inline edit + sessions controls from list rows. |
| app/(app)/firearms/firearm-detail-view.tsx | New firearm detail client view including serial display and embedded read-only session history with gated controls. |
| app/(app)/firearms/[id]/page.tsx | New firearm detail server route (UUID guard, notFound behavior, loads suggestions/summary). |
| AGENTS.md | Documents a strict pre-commit gate requiring just ci-check to pass before commits. |
| .taplo.toml | Adds taplo formatter config for TOML alignment/format consistency. |
| .mdformat.toml | Reorders/sets mdformat config fields. |
| .gitignore | Ignores generated SBOM output (sbom.cdx.json). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
app/(app)/magazines/[id]/page.tsx (1)
28-39: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winRedundant
resolvePermissioncall duplicatesgetMagazine's internal check.
getMagazinealready calls resolvePermission and throws NotFoundError when the magazine isn't owned/shared, then this page callsresolvePermissionagain just to get the display value passed topermission={permission ?? "view"}. This adds an extra DB round-trip per page load and opens a narrow window where a grant revoked between the two calls silently falls back to"view"instead of surfacing not-found.Consider having
getMagazine(or a variant) return the resolved permission alongside the row so the page doesn't re-query.Also applies to: 70-70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/`(app)/magazines/[id]/page.tsx around lines 28 - 39, The page is re-querying permission with resolvePermission even though getMagazine already performs the ownership/shared access check and throws NotFoundError. Update the magazine load flow so getMagazine (or a nearby helper) returns the resolved permission together with the magazine row, and have page.tsx use that value for permission={...} instead of calling resolvePermission again. Keep the existing NotFoundError handling in the getMagazine path and remove the redundant Promise.all permission fetch.app/(app)/firearms/[id]/page.tsx (1)
35-42: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winRedundant permission lookup —
getFirearmalready resolves it.Per
src/domain/firearms/service.ts:102-115,getFirearminternally callsresolvePermissionand throwsNotFoundErrorwhen it's null. Line 37 then callsresolvePermissionagain for the same(user.id, "firearm", id)tuple — a second DB round trip for data already computed. It also reopens a (very unlikely) TOCTOU window: if access were revoked between the two calls, this second lookup could returnnull, and thepermission ?? "view"fallback on line 65 would silently grant "view" instead of surfacing a 404.Consider having
getFirearmreturn the resolved permission alongside the record so callers don't refetch it.♻️ Sketch
-export async function getFirearm( - actorId: string, - id: string, -): Promise<Firearm> { +export async function getFirearm( + actorId: string, + id: string, +): Promise<{ firearm: Firearm; permission: Permission }> { const perm = await resolvePermission(db, actorId, "firearm", id); if (perm === null) throw new NotFoundError(); const [row] = await db.select().from(firearm).where(eq(firearm.id, id)).limit(1); if (!row) throw new NotFoundError(); - return row; + return { firearm: row, permission: perm }; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/`(app)/firearms/[id]/page.tsx around lines 35 - 42, `page.tsx` is doing a redundant permission lookup for the same firearm access check already handled by `getFirearm`. Update the firearm page flow so the permission comes from `getFirearm` in `src/domain/firearms/service.ts` (or have that method return the resolved permission alongside the record), and stop calling `resolvePermission(db, user.id, "firearm", id)` separately. Make sure the later `permission ?? "view"` fallback uses the permission returned from `getFirearm`, so unauthorized access still surfaces `NotFoundError` instead of silently defaulting to view.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/`(app)/magazines/magazine-detail-view.tsx:
- Around line 105-109: The magazine detail badge is showing the raw permission
value even for non-owners, which can misleadingly display an inert “edit” grant.
Update the shared-with-you display in magazine-detail-view.tsx so the Badge text
is normalized for magazines (for example, always show “view” for any non-owner)
while keeping the existing isOwner gating for Edit/Delete/Share actions. Use the
existing isOwner and permission logic in the magazine-detail-view component to
locate and adjust the badge rendering.
In `@src/auth/authorize.ts`:
- Around line 69-74: Update the docstring in authorize.ts so it no longer
references the nonexistent R70 requirement; replace it with the correct plan
reference, likely R9, in the comment for the owner-only update behavior. Keep
the wording aligned with the existing authorizeDelete-style explanation and
verify any other nearby comments or symbols in authorize/authorizeDelete use
only valid requirement IDs from the same plan.
- Around line 75-87: Extract the shared owner-only permission check used by
authorizeOwnerOnlyUpdate and authorizeDelete into a single helper so the gating
logic lives in one place. Update authorizeOwnerOnlyUpdate to delegate to that
shared function in src/auth/authorize.ts, preserving its NotAuthorizedError
message for edit/view and NotFoundError for everything else, while keeping the
owner-only success path unchanged. Use the existing symbols
authorizeOwnerOnlyUpdate, authorizeDelete, and resolvePermission to centralize
the permission resolution and avoid duplicated branching.
---
Nitpick comments:
In `@app/`(app)/firearms/[id]/page.tsx:
- Around line 35-42: `page.tsx` is doing a redundant permission lookup for the
same firearm access check already handled by `getFirearm`. Update the firearm
page flow so the permission comes from `getFirearm` in
`src/domain/firearms/service.ts` (or have that method return the resolved
permission alongside the record), and stop calling `resolvePermission(db,
user.id, "firearm", id)` separately. Make sure the later `permission ?? "view"`
fallback uses the permission returned from `getFirearm`, so unauthorized access
still surfaces `NotFoundError` instead of silently defaulting to view.
In `@app/`(app)/magazines/[id]/page.tsx:
- Around line 28-39: The page is re-querying permission with resolvePermission
even though getMagazine already performs the ownership/shared access check and
throws NotFoundError. Update the magazine load flow so getMagazine (or a nearby
helper) returns the resolved permission together with the magazine row, and have
page.tsx use that value for permission={...} instead of calling
resolvePermission again. Keep the existing NotFoundError handling in the
getMagazine path and remove the redundant Promise.all permission fetch.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 5faf2016-7d33-4143-a4e5-68dd7b43792a
📒 Files selected for processing (30)
.gitignore.mdformat.toml.taplo.tomlAGENTS.mdapp/(app)/firearms/[id]/page.tsxapp/(app)/firearms/firearm-detail-view.tsxapp/(app)/firearms/firearms-view.tsxapp/(app)/firearms/page.tsxapp/(app)/firearms/range-session-history.tsxapp/(app)/grants/share-control.tsxapp/(app)/magazines/[id]/page.tsxapp/(app)/magazines/magazine-detail-view.tsxapp/(app)/magazines/magazines-view.tsxapp/(app)/not-found.tsxdocs/plans/2026-07-04-001-feat-firearm-magazine-detail-view-plan.mde2e/detail-view-sharing.spec.tse2e/firearm-nickname.spec.tse2e/firearm-taxonomy.spec.tse2e/fixtures/user-pool.tse2e/inventory-crud.spec.tse2e/magpul-settings.spec.tse2e/range-sessions-sharing.spec.tse2e/range-sessions.spec.tshooks/use-delete-confirmation.tsjustfilemise.tomlsrc/auth/authorize.tssrc/domain/magazines/__tests__/authorize-owner-only.test.tssrc/domain/magazines/service.tssrc/lib/uuid.ts
💤 Files with no reviewable changes (1)
- app/(app)/firearms/page.tsx
…rot (#19) Addresses PR review findings: - Revoked-mid-request permission now resolves as not-found instead of silently coalescing to a read-only 'view' page (both detail routes). - Magazine detail 'Compatible firearms' now uses structurally-paired {id,name} data (stable React keys; no id/name array desync). - Correct comment rot: not-found.tsx R16 focus claim, useDeleteConfirmation refresh/redirect docstring, and a sharing-spec comment; add the matching Delete-absent + magazine delete-from-detail e2e assertions. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
#19) Fixes the remaining valid review findings: - getFirearm/getMagazine return the viewer's permission, so the detail pages no longer re-resolve it (one query; the read-vs-permission race is gone). - isOwner derives from that permission (single source of truth) — drops the redundant ownerId/currentUserId comparison the type reviewer flagged. - magazineCountForFirearm replaces the whole-inventory summary over-fetch on the firearm detail page (with a unit test). - e2e: assert every detail field renders (R2/R5) and cover the firearm edit-grantee tier (Edit shown, Delete/Share hidden). Not changed (invalid finding): rejecting magazine 'edit' grants in createGrant would break create-on-behalf, which legitimately uses them (magazines service AE10) — the grant is not inert. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
- Extract a shared authorizeOwnerOnly gate so the owner-only precedence lives in one place (authorizeOwnerOnlyUpdate + authorizeDelete delegate to it). - Magazine detail badge shows 'view' for any non-owner grantee, since magazine actions are owner-only and an 'edit' grant can't modify the magazine. Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Summary
Closes #19. Adds dedicated, permission-aware read-only detail routes for a single firearm (
/firearms/[id]) and magazine (/magazines/[id]), giving view-only grantees a correct destination and owners a clean "just look at it" surface. The detail page is the single home for one record: it hosts permission-gated Edit / Delete / Share, while view-only grantees reach a read-only page by clicking the record name.Plan:
docs/plans/2026-07-04-001-feat-firearm-magazine-detail-view-plan.md(R1–R19, AE1–AE7).What's implemented
/firearms/[id]): all fields read-only including the serial for any viewer (R4); owner/edit-gated Edit, owner-only Delete/Share (R8); in-place edit form (R11); embedded read-only range-session history (R14); delete redirects to the list (R15); heading focus + back link (R16/R18). Sharedapp/(app)/not-found.tsxrenders the accessible 404 (R9).updateMagazinenow routes through a newauthorizeOwnerOnlyUpdate, so anedit-grantee's save is rejected server-side (R13, AE6) — not just hidden in the UI. The Share control stops offeringeditfor magazines. Backed by alive-gated integration test./magazines/[id]): owner-only actions; view- and edit-grantees get a purely read-only page (R7/R8).detail-view-sharingspec proves owner full actions, view-only read-only pages (serial + read-only sessions visible, no controls), magazine view-only sharing, not-found for no-access/malformed URLs, and delete-from-detail returning to the list. Six existing specs migrated from the removed list-row buttons to the detail-page flow.Key decisions
allow_create_on_behalf) is a separate, pre-existing surface left intact and explicitly out of scope.showSerialtoggle.Testing
just ci-checkgreen: Biome lint + format,tsc --noEmit, pre-commit, 233 unit/integration tests, 17 Playwright e2e tests.detail-view-sharinge2e.Review notes
A code-review pass (correctness / security / maintainability) ran on the branch. Security found nothing. One real bug was found and fixed: a malformed (non-uuid) path id raised a Postgres cast error instead of a clean 404 — now validated at the request boundary with e2e coverage. The dead
FirearmListItem.permissionfield (unused after Sessions moved off the list) was removed. Remaining P3 findings (smallDetailRowduplication between the two detail views) are the deliberate per-entity-layout decision and were left as-is.Note: the branch also carries earlier repo-infra commits (justfile with the
ci-checkgate, TOML formatting config, SBOM gitignore) that predate this feature work.