diff --git a/CONCEPTS.md b/CONCEPTS.md index 08d78283..cabbaed1 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -29,11 +29,11 @@ A single logged range trip for one Firearm — the date and the rounds fired tha ### Inventory Log -An append-only history of physical-handling events on a single Firearm or Magazine — each **Log Entry** records an **Event Type**, the acting user, when it happened, and optional notes. A **child record** family: entries inherit their parent's owner and grants, cannot be shared on their own, and are removed with the parent. Entries are created and listed but not edited or deleted. +An append-only history of physical-handling events on a single Firearm or Magazine — each **Log Entry** records an **Event Type**, the acting user, when it happened, and optional notes. A **child record** family: entries inherit their parent's owner and grants, cannot be shared on their own, and are removed with the parent. Entries are created and listed but not edited or deleted. `cleaned` and `lubed` were retired as Firearm Event Types once **Service Event** shipped (service-intervals plan, U5) — logging service against a **Service Rule** is now the single way to record either act; every prior `cleaned`/`lubed` entry converted to a Service Event and the Inventory Log now carries only `inventoried` for both parent families. ### Event Type -The controlled kind of a **Log Entry** (*inventoried* for any item; *cleaned*, *lubed* for Firearms), drawn from a fixed value set whose valid members depend on the parent family. Deliberately not called an "action" — that name already means a Firearm's operating mechanism (see **Firearm Action**). +The controlled kind of a **Log Entry** — currently *inventoried*, the only member for either parent family — drawn from a fixed value set whose valid members depend on the parent family. Deliberately not called an "action" — that name already means a Firearm's operating mechanism (see **Firearm Action**). ### Child record @@ -95,6 +95,16 @@ A short string an owner has used to start Magazine labels. Recorded per owner an An owner setting that, when on, constrains Magazine labels to what can physically be written in the dot cells of a Magpul magazine floorplate — a limited character set and length. When off, labels are free text. +## Service intervals + +### Service Rule + +A named maintenance concern tracked against a Firearm or Accessory — Cleaning, Barrel, Recoil spring, and so on — that sets at least one of three thresholds: elapsed days, range sessions, or rounds fired. An item's Service Rules come from its owner's category defaults, live: each is *inherited*, *overridden* with the item's own thresholds, or *suppressed* (removed from the item entirely); an item may also carry *item-only* rules no default defines. A Service Rule belongs to its item's Owner, so a shared item's rules come from the owner's defaults, never the viewer's. + +### Service Event + +A single logged act of service against one Service Rule — the date it happened, the acting user, and optional notes. Logging a Service Event sets that rule's measurement point: elapsed days, sessions, and rounds all start counting fresh from it. A Firearm or Accessory's service history is every Service Event against it, newest first. + ## Derived values ### Total Capacity @@ -113,6 +123,10 @@ The derived state of an **Ammo** lot whose quantity in rounds is at or under its A Magazine's most recent physical-count date: the `occurredAt` of the latest **Inventory Log** entry with **Event Type** `inventoried`. Derived, not stored — and blank (a first-class state) when the Magazine has never been inventoried. Respects owner-scoping: derived only from entries visible through the parent Magazine. +### Due + +The derived state of a **Service Rule** whose elapsed days, sessions, or rounds fired — measured since its last **Service Event**, or the item's origin date when none exists — meets or exceeds any threshold it sets. Binary, never a severity tier: distance past a threshold is shown as the raw counts, not a "due soon" gradation. Never stored — computed from the resolved rule and its elapsed counts wherever it is shown (item detail panel, `/summary` roll-up, list indicators). + ## Design identity ### Machined Console diff --git a/app/(app)/accessories/[id]/__tests__/service-props.test.ts b/app/(app)/accessories/[id]/__tests__/service-props.test.ts new file mode 100644 index 00000000..7caa4999 --- /dev/null +++ b/app/(app)/accessories/[id]/__tests__/service-props.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { NotFoundError } from "@/src/auth/errors"; +import * as dueService from "@/src/domain/service-intervals/due-service"; +import * as eventsService from "@/src/domain/service-intervals/events-service"; +import * as rulesService from "@/src/domain/service-intervals/rules-service"; +import { loadAccessoryServiceProps } from "../page"; + +/** + * Unit tests for `loadAccessoryServiceProps` (the accessory detail page's + * owner-only service-data loader) — specifically the 404 guard added + * alongside the firearm detail page's `asNotFound` (mirrored via + * `@/src/lib/as-not-found`). + * + * Uses `spyOn` on the real `due-service`/`events-service`/`rules-service` + * module namespaces rather than `mock.module(...)` on those specifiers: + * `rules-service` is ALSO `mock.module`-replaced, with a differently-shaped + * export set, by `settings/service/__tests__/actions.test.ts` — a second, + * incompatible `mock.module` on the same path from this file would collide + * when `bun test app` runs every file in one process (mirrors the same + * tradeoff documented in `firearms/__tests__/service-actions.test.ts`). + * `spyOn` overrides only the named export on the already-loaded module + * object, so it doesn't touch that shared registry. + */ + +const USER_ID = "user-1"; +const ACCESSORY_ID = "accessory-1"; + +describe("loadAccessoryServiceProps", () => { + let getItemDueStateSpy: ReturnType; + let listItemRulesSpy: ReturnType; + let listServiceHistorySpy: ReturnType; + + beforeEach(() => { + getItemDueStateSpy = spyOn(dueService, "getItemDueState").mockResolvedValue( + [], + ); + listItemRulesSpy = spyOn(rulesService, "listItemRules").mockResolvedValue( + [], + ); + listServiceHistorySpy = spyOn( + eventsService, + "listServiceHistory", + ).mockResolvedValue([]); + }); + + // `bun test app` runs every file in one process and Bun's `spyOn` + // replacements persist until restored, so an unrestored spy here would leak + // into whichever file runs next. + afterEach(() => { + getItemDueStateSpy.mockRestore(); + listItemRulesSpy.mockRestore(); + listServiceHistorySpy.mockRestore(); + }); + + test("a non-owner viewer gets null in every field, without calling any loader", async () => { + const result = await loadAccessoryServiceProps( + USER_ID, + ACCESSORY_ID, + false, + ); + + expect(result).toEqual({ + serviceRules: null, + suppressedServiceRuleNames: null, + serviceHistory: null, + }); + expect(getItemDueStateSpy).not.toHaveBeenCalled(); + expect(listItemRulesSpy).not.toHaveBeenCalled(); + expect(listServiceHistorySpy).not.toHaveBeenCalled(); + }); + + test("an owner viewer gets the resolved rules, suppressed names, and history on the happy path", async () => { + const dueRuleStub = { + name: "Cleaning", + } as unknown as dueService.RuleDueState; + const keptRuleStub = { + name: "Cleaning", + suppressed: false, + } as unknown as rulesService.ServiceRuleRow; + const suppressedRuleStub = { + name: "Lube", + suppressed: true, + } as unknown as rulesService.ServiceRuleRow; + getItemDueStateSpy.mockResolvedValue([dueRuleStub]); + listItemRulesSpy.mockResolvedValue([keptRuleStub, suppressedRuleStub]); + listServiceHistorySpy.mockResolvedValue([]); + + const result = await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); + + expect(result.serviceRules).toEqual([dueRuleStub]); + expect(result.suppressedServiceRuleNames).toEqual(["Lube"]); + expect(result.serviceHistory).toEqual([]); + }); + + // `getItemDueState`/`listItemRules`/`listServiceHistory` route through + // `requireAccessoryOwner`, which authorizes internally and can throw + // `NotFoundError` if the accessory was deleted or reassigned between the + // page's earlier `getAccessory` check and this call. Each loader is + // independently exercised so the fix applied to all three isn't only + // proven for whichever settles first in the `Promise.all`. + const guardedLoaders: Array<{ + name: string; + reject: () => void; + }> = [ + { + name: "getItemDueState", + reject: () => { + getItemDueStateSpy.mockRejectedValue(new NotFoundError()); + }, + }, + { + name: "listItemRules", + reject: () => { + listItemRulesSpy.mockRejectedValue(new NotFoundError()); + }, + }, + { + name: "listServiceHistory", + reject: () => { + listServiceHistorySpy.mockRejectedValue(new NotFoundError()); + }, + }, + ]; + + for (const loader of guardedLoaders) { + test(`a NotFoundError from ${loader.name} surfaces as Next's clean 404, not an unhandled rejection`, async () => { + loader.reject(); + + let caught: unknown; + try { + await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); + } catch (error: unknown) { + caught = error; + } + + // `notFound()` throws a real Next.js error carrying this digest, which + // is what the framework's router boundary keys off of to render the + // clean 404 UI instead of the generic error boundary. + expect(caught).toBeInstanceOf(Error); + expect((caught as { digest?: string }).digest).toBe( + "NEXT_HTTP_ERROR_FALLBACK;404", + ); + }); + } + + test("a non-NotFoundError from a loader propagates unchanged, not converted to a 404", async () => { + const boom = new Error("boom"); + getItemDueStateSpy.mockRejectedValue(boom); + + let caught: unknown; + try { + await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); + } catch (error: unknown) { + caught = error; + } + + expect(caught).toBe(boom); + }); +}); diff --git a/app/(app)/accessories/[id]/page.tsx b/app/(app)/accessories/[id]/page.tsx index 3bcc5ea3..5ef20ee2 100644 --- a/app/(app)/accessories/[id]/page.tsx +++ b/app/(app)/accessories/[id]/page.tsx @@ -7,13 +7,75 @@ import { costCentsToInputValue } from "@/src/domain/accessories/display"; import { getAccessory } from "@/src/domain/accessories/service"; import { buildFirearmMountContext } from "@/src/domain/firearms/mount-options"; import { listFirearms } from "@/src/domain/firearms/service"; +import { withActorNames } from "@/src/domain/service-intervals/actor-names"; +import { + getItemDueState, + type RuleDueState, +} from "@/src/domain/service-intervals/due-service"; +import { listServiceHistory } from "@/src/domain/service-intervals/events-service"; +import { + listItemRules, + listOwnerAccessoryCategories, +} from "@/src/domain/service-intervals/rules-service"; +import { asNotFound } from "@/src/lib/as-not-found"; import { isUuid } from "@/src/lib/uuid"; +import type { ServiceHistoryEntry } from "../../firearms/service-history"; import { AccessoryDetailView } from "../accessory-detail-view"; interface PageProps { params: Promise<{ id: string }>; } +export interface AccessoryServiceProps { + serviceRules: RuleDueState[] | null; + suppressedServiceRuleNames: string[] | null; + serviceHistory: ServiceHistoryEntry[] | null; +} + +/** + * Loads service data (U8) for the OWNER only — accessories are owner-only + * throughout for service (KTD3), so a non-owner viewer gets `null` in every + * field rather than empty arrays, and the detail view doesn't render the + * section at all for them (matching `requireAccessoryOwner` throwing were we + * to call it for a non-owner anyway). + * + * Exported so `__tests__/service-props.test.ts` can exercise the 404-guard + * race (a loader throwing `NotFoundError` between the page's earlier + * `getAccessory` check and this call) directly, without standing up the rest + * of the page's dependency graph (`listFirearms`, `visibleFirearmPermissions`, + * `AccessoryDetailView`, ...) just to reach it. + */ +export async function loadAccessoryServiceProps( + userId: string, + accessoryId: string, + isOwner: boolean, +): Promise { + if (!isOwner) { + return { + serviceRules: null, + suppressedServiceRuleNames: null, + serviceHistory: null, + }; + } + // These loaders route through `requireAccessoryOwner`, which authorizes + // internally and throws `NotFoundError` if the row is deleted or ownership + // changes between the page's earlier `getAccessory` check and this call (a + // narrow race, mirrors the equivalent guard on the firearm detail page) — + // that must surface as the page's clean 404, not an unhandled 500. + const [dueRules, itemRules, history] = await Promise.all([ + getItemDueState(userId, "accessory", accessoryId).catch(asNotFound), + listItemRules(userId, "accessory", accessoryId).catch(asNotFound), + listServiceHistory(userId, "accessory", accessoryId).catch(asNotFound), + ]); + return { + serviceRules: dueRules, + suppressedServiceRuleNames: itemRules + .filter((rule) => rule.suppressed) + .map((rule) => rule.name), + serviceHistory: await withActorNames(history), + }; +} + export default async function AccessoryDetailPage({ params }: PageProps) { const { id } = await params; const user = await getCurrentUser(); @@ -33,15 +95,32 @@ export default async function AccessoryDetailPage({ params }: PageProps) { }, ); - const [firearms, permissions] = await Promise.all([ - listFirearms(user.id), - visibleFirearmPermissions(db, user.id), - ]); + const isOwner = permission === "owner"; + + const [firearms, permissions, serviceProps, ownerCategories] = + await Promise.all([ + listFirearms(user.id), + visibleFirearmPermissions(db, user.id), + loadAccessoryServiceProps(user.id, id, isOwner), + // The ACCESSORY'S OWNER's categories (row.ownerId, not the actor) — + // suggestions should reflect the owner whose category defaults (KD10) + // this accessory actually inherits from, even when an edit-grantee is + // the one editing a shared mount. But `listOwnerAccessoryCategories` + // returns EVERY category across ALL of that owner's accessories, not + // just the ones visible to this viewer — for a non-owner grantee that + // would leak the owner's unrelated-accessory category vocabulary, so + // fall back to the actor's own categories instead (still useful + // autocomplete, no cross-tenant leak). + isOwner + ? listOwnerAccessoryCategories(row.ownerId) + : listOwnerAccessoryCategories(user.id), + ]); // The reassign-mount picker must offer only firearms owned by the // ACCESSORY's owner (`row.ownerId`, not the actor — an edit-grantee acting // on someone else's mounted accessory must still only relocate it among - // that owner's own guns, KTD5's cross-tenant guard) AND editable by the + // that owner's own guns, accessories-tracker plan KTD5's cross-tenant + // guard) AND editable by the // acting user. const { firearmNames, editableFirearms } = buildFirearmMountContext( firearms, @@ -58,6 +137,7 @@ export default async function AccessoryDetailPage({ params }: PageProps) { model: row.model, serialNumber: row.serialNumber, installedDate: row.installedDate ?? "", + acquiredDate: row.acquiredDate ?? "", cost: costCentsToInputValue(row.costCents), notes: row.notes, isNfa: row.isNfa, @@ -66,6 +146,10 @@ export default async function AccessoryDetailPage({ params }: PageProps) { permission={permission} editableFirearms={editableFirearms} firearmNames={firearmNames} + serviceRules={serviceProps.serviceRules} + suppressedServiceRuleNames={serviceProps.suppressedServiceRuleNames} + serviceHistory={serviceProps.serviceHistory} + ownerCategories={ownerCategories} /> ); } diff --git a/app/(app)/accessories/accessories-view.tsx b/app/(app)/accessories/accessories-view.tsx index 0a191f35..7a9fcf7c 100644 --- a/app/(app)/accessories/accessories-view.tsx +++ b/app/(app)/accessories/accessories-view.tsx @@ -39,6 +39,12 @@ export interface AccessoryListItem { notes: string; isNfa: boolean; currentFirearmId: string | null; + /** + * True when this accessory itself has at least one due service rule (U9, + * R20) — never true merely because the firearm it's mounted to is due. + * Advisory only (R21): a marker, not a gate. + */ + serviceDue: boolean; } interface AccessoriesViewProps { @@ -52,6 +58,9 @@ interface AccessoriesViewProps { /** Firearm to pre-select as the mount target, from a firearm detail page's * "Add accessory" link (F1). Auto-opens the create form when present. */ initialMountFirearmId?: string; + /** The actor's own previously-typed categories (KTD8-reuse), merged into + * the create form's category suggestions — see `AccessoryForm`'s doc. */ + ownerCategories: string[]; } type FormState = { open: false } | { open: true }; @@ -62,6 +71,7 @@ export function AccessoriesView({ editableFirearms, firearmNames, initialMountFirearmId, + ownerCategories, }: AccessoriesViewProps) { const router = useRouter(); // Auto-open the create form pre-mounted to a firearm when arriving from that @@ -157,6 +167,16 @@ export function AccessoriesView({ cell: ({ row }) => row.original.isNfa ? NFA : null, }, + { + id: "serviceDue", + header: "Service", + meta: { label: "Service" }, + enableSorting: false, + cell: ({ row }) => + row.original.serviceDue ? ( + Service due + ) : null, + }, { id: "cost", header: "Cost", @@ -251,6 +271,7 @@ export function AccessoriesView({ setForm({ open: false })} /> diff --git a/app/(app)/accessories/accessory-detail-view.tsx b/app/(app)/accessories/accessory-detail-view.tsx index 4ca2e7d7..21dfe244 100644 --- a/app/(app)/accessories/accessory-detail-view.tsx +++ b/app/(app)/accessories/accessory-detail-view.tsx @@ -17,6 +17,12 @@ import { formatCostCents, parseCostInputToCents, } from "@/src/domain/accessories/display"; +import type { RuleDueState } from "@/src/domain/service-intervals/due-service"; +import { + ServiceHistory, + type ServiceHistoryEntry, +} from "../firearms/service-history"; +import { ServiceRulesPanel } from "../firearms/service-rules-panel"; import type { EditableFirearmOption } from "./accessory-form"; import { AccessoryForm, type AccessoryFormValues } from "./accessory-form"; import { deleteAccessoryAction, mountAccessoryAction } from "./actions"; @@ -34,6 +40,20 @@ interface AccessoryDetailViewProps { /** Display names for every firearm visible to the actor, for the read-only * "current firearm" link even when it falls outside `editableFirearms`. */ firearmNames: Record; + /** + * Service data (U8) — owner-only throughout for accessories (KTD3), so + * these are `null` for a non-owner viewer rather than empty: the page + * never even loads them in that case (`requireAccessoryOwner` would throw + * for a non-owner), and `null` is what tells this view not to render the + * section at all, rather than rendering an always-empty panel. + */ + serviceRules: RuleDueState[] | null; + suppressedServiceRuleNames: string[] | null; + serviceHistory: ServiceHistoryEntry[] | null; + /** The ACCESSORY'S OWNER's previously-typed categories (not necessarily + * the viewer's, when an edit-grantee is editing a shared mount) — merged + * into the edit form's category suggestions, see `AccessoryForm`'s doc. */ + ownerCategories: string[]; } /** @@ -100,6 +120,10 @@ export function AccessoryDetailView({ permission, editableFirearms, firearmNames, + serviceRules, + suppressedServiceRuleNames, + serviceHistory, + ownerCategories, }: AccessoryDetailViewProps) { const router = useRouter(); const [editing, setEditing] = useState(false); @@ -179,6 +203,7 @@ export function AccessoryDetailView({ initial={accessory} editableFirearms={editableFirearms} currentFirearmId={accessory.currentFirearmId} + ownerCategories={ownerCategories} onDone={() => { setEditing(false); router.refresh(); @@ -228,6 +253,10 @@ export function AccessoryDetailView({ label="Installed date" value={orDash(accessory.installedDate)} /> + )} + {isOwner && + serviceRules !== null && + suppressedServiceRuleNames !== null ? ( + router.refresh()} + /> + ) : null} + + {isOwner && serviceHistory !== null ? ( + router.refresh()} + /> + ) : null} +