Skip to content

v5 data platform, Phase 5: inventory editing, the tool editor, and the three admin queues - #37

Merged
philosophercode merged 3 commits into
mainfrom
v5/data-platform-phase-5
Sep 24, 2026
Merged

philosophercode merged 3 commits into
mainfrom
v5/data-platform-phase-5

Conversation

@philosophercode

Copy link
Copy Markdown
Owner

Phase 5 of the spec (§5.3). Stacked on #35. 135 files, +19,227.

This is the phase that makes the lab run on this app instead of on Notion.

What it adds

  • /admin/inventory — every tool including drafts and archived, with filters for state, category, location, free text, and Needs attention (no photo, no manual, unlinked units, open tickets, never reviewed). Filters live in the URL. That filter is the inventory review.
  • The tool editor panel — tool fields, units, resources, photos, Looks good, and Publish / Unpublish / Archive, each writing an audit event.
  • Edit mode on /tools/<slug> — the same panel over the detail page, phone-first, because a SuperMaker marking a printer out of service is standing next to the machine.
  • Three queues/admin/maintenance, /admin/corrections, /admin/projects.
  • Drafts are reachable at their slug only with catalog.view_drafts; everyone else gets the real 404.

The bug the survey found before a line was written

The obvious optimistic-concurrency check is green on PGlite and broken on Neon.

updated_at is timestamptz, maintained by the set_updated_at() trigger using now(). Real Postgres now() has microsecond resolution; both drivers parse timestamptz into a JS Date, which truncates to milliseconds. So where updated_at = <Date read back earlier> matches zero rows on Neon — every single save would report a bogus "someone else changed this tool".

And PGlite's now() is millisecond-resolution, so a test written the obvious way passes forever and the bug only ever appears in production.

Phase 5 therefore carries an opaque string revision token (extract(epoch from updated_at)::text, minted and compared by the same expression), pinned by a test that stages a microsecond timestamp with the trigger disabled.

Four major findings, found by review and fixed

Each was confirmed red before its fix.

  1. A hidden resource was not hidden. The editor's "Hide" control only hid a manual from the assistant — a deliberately restricted SOP stayed world-readable on the public page.
  2. A save that showed stale data. invalidateCatalog used the minutes profile, making the catalog tag stale-while-revalidate rather than expired, so the first reader after a save still got the pre-save page. The catalogue reads did not use that profile at all.
  3. The panel lied after a state change. Publish, Unpublish, Archive, Restore and "Looks good" all committed while the panel kept rendering the pre-change tool under a "Saved" live region.
  4. Unit edits silently reverted each other. The unit row posted fields captured when the panel opened and was never rebased, so a second editor's unit edit was overwritten even though the tool's revision check passed.

Verification

Every command with every environment variable unset, on PGlite + MSW, no network:

  • vitest137 files / 1,720 tests (I re-ran this myself: 1,720 passed)
  • playwright67 passed, 0 flaky
  • lint 0 errors (3 pre-existing warnings) · typecheck clean · spec:coverage 73 · 0 undocumented · build green with no database

Teeth check on the new E2E: with invalidateCatalog() pointed at a misspelled tag and retries off, tool-editor.spec.ts:151 failed on exactly the silent-staleness assertion it exists for. The test has teeth.

Six minor findings left open, deliberately

Carried forward rather than rushed. None blocks the demo:

  • With no Blob token, picking a PDF once wedges the Add-resource form for links too
  • A save that committed reports failed if the follow-up load throws, then conflicts against the person's own write
  • The conflict view omits category and location, so those two overwrite without showing the other value
  • The orphan sweep deletes attachments by id without re-checking they are still unowned
  • deleteUnit's history check can race a ticket committing between the count and the DELETE
  • The add-resource form clears before the write answers, losing the typed resource on a refusal

Shared, not duplicated

record() and warn() moved to src/lib/admin/audit-warning.ts; /admin/users now uses the shared pair. A failed audit write stays a warning on a success across every admin surface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE

philosophercode and others added 2 commits September 22, 2026 19:40
/admin gains the surfaces the lab runs on (spec §5.3, §5.6): the review table
with its needs-attention flags, a tool editor offered both from a row and as a
full-screen sheet over a machine's own page, and the maintenance, corrections
and project-moderation queues.

The editor's concurrency token is a string, not a Date — `extract(epoch from
updated_at)::text`, minted and compared by the same expression. Postgres has
microseconds and a JS Date has milliseconds, so a Date comparison matches
nothing on Neon while matching forever on PGlite: every save a bogus conflict
in production and no test anywhere to say so. A unit, resource or photo write
touches its tool in the same transaction, so the tool is the token for the
whole panel, and a refused child write rolls that touch back rather than
spending every open panel's token over a write that never happened.

Every server action checks its own permission through one shared gate — a
server action is a POST endpoint with a generated name, reachable without the
page that offers the control — and each queue's test proves it is its own
permission by granting only the adjacent ones. An audit write that fails after
its change committed stays a warning on a success, through the channel
/admin/users already had rather than a second one.

Closing the seams between the four parts:

- Migration 0004 writes the three `user.id` foreign keys the plan asked for and
  no part wrote — tools.last_reviewed_by, maintenance_logs.assigned_to_user_id,
  projects.published_by — all `on delete set null`, sharing one helper with
  actorColumns(). Phase 5 is the first code to write two of them, so until now
  an id naming no row went in without complaint.
- .admin-shell now supplies the --td-* tokens its .td-* utilities read. Those
  tokens were scoped to .tool-detail, and an unresolvable var() computes to
  `unset` rather than falling back: the amber "saved, but the audit log did not
  record it" line was rendering the colour of body text.
- A resource's lost PDF raises files_not_attached instead of reporting itself
  as a lost photo; the queues' refusal codes are declared once rather than
  twice; markToolReviewed names its permission the same way its siblings do.

E2E covers the flow the phase exists for: a SuperMaker at a 390px viewport
marks a unit out of service from the machine's own page and the public tool
page stops saying it is available. That last step is the cache-tag seam nothing
smaller could reach — the test was confirmed red by pointing invalidateCatalog()
at the wrong tag, where it failed with the page still reading "Available".

Gate from v5/ with every environment variable unset: lint 0 errors (3
pre-existing warnings), typecheck clean, 137 files / 1711 tests, 67 Playwright
tests, spec:coverage 73 items / 0 undocumented, and a build in which /tools/[id]
and all five /admin routes are still Partial Prerender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
… the truth

Every one of the four reviewers' blockers held against the code.

**Hide did not hide.** The editor's Hide control flips `resources.published`,
the panel tags the row "Hidden" and the assistant stops reading it — but the
public tool page listed every resource of a visible tool, with a comment saying
so deliberately. An internal SOP somebody restricted stayed world-readable. The
comment's premise expired when `resources.published` gained its `default true`:
a hidden resource is now always one a person chose to hide. `loadTools` filters
on it, like `listResourcesForTool` already did. (The blob itself is still
public at its random pathname — hiding removes the link, not the document.)

**A unit row posted its whole ten-minute-old copy.** The rows are keyed by unit
id and never remount, so after a conflict reload the row still held what it
opened with, and Save sent all five text fields — reverting a second editor's
serial number against a fresh token, with no conflict and no audit event.
`notes`, which has no box at all, was rewritten on every save. Now each field
the person has not touched rebases onto the newer server value, each field they
have is theirs, and Save posts only the second kind.

**`refreshChildren` left the tool alone.** Publish, Unpublish, Archive, Restore
and "Looks good" all commit through it, so the panel said "Saved" over a badge
still reading Published and a button still offering to unpublish — the page
asserting what the database no longer holds, and a second click writing a second
audit event. It now takes the tool's *state* too, and still not its text.

**`revalidateTag(tag, "minutes")` was stale-while-revalidate.** Next 16 reads
the named profile, sets `expired = now + 3600s` and serves the old entry to the
next reader; it also skips marking the path revalidated. A student scanning a
QR label seconds after a publish got the pre-publish miss. `{ expire: 0 }`
expires it where it stands. The profile was wrong twice over: the catalogue
reads use `CATALOG_CACHE`, not anything named in `next.config`.

Each fix has a regression test, and each was watched failing without it.
lint, typecheck, vitest (1720), playwright (67), spec:coverage and build all run
with every environment variable unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
Copilot AI lite review requested due to automatic review settings September 23, 2026 00:02
@vercel

vercel Bot commented Sep 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
makerlab-tools Ready Ready Preview Sep 24, 2026 1:01am UTC
makerlab-tools-v5 Ready Ready Preview Sep 24, 2026 1:01am UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 927bbec27d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

attachments,
and(eq(attachments.ownerType, "resource"), eq(attachments.ownerId, resources.id))
)
.where(inArray(resources.toolId, toolIds))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude hidden resources from the manual flag

When a tool's only manual is hidden, this query still counts it because it does not filter on resources.published; the public catalog now omits that resource, but the inventory row reports noManual: false, so the no_manual and “Needs attention” filters miss a tool whose visitors have no accessible manual. Restrict this aggregation to published resources.

AGENTS.md reference: v5/AGENTS.md:L238-L243

Useful? React with 👍 / 👎.

Comment on lines +75 to +78
setTitle("");
setType("");
setUrl("");
setFile(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the resource draft until the add succeeds

If addResource is refused—for example because another edit caused a revision conflict or the entered URL fails server validation—onAdd returns immediately while these setters erase the title, type, URL, and selected file before the result is known. The user must reconstruct the form, and an already-uploaded PDF is left orphaned for the sweep; clear this state only after a confirmed successful action.

AGENTS.md reference: v5/AGENTS.md:L219-L226

Useful? React with 👍 / 👎.

Comment on lines +155 to +156
</select>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render conflict choices for category and location

After a conflict reload, the category and location selects are the only editable tool fields that never render theirValue(...). If both editors changed either select, the current editor receives a fresh revision and can retry their value without ever seeing or being able to choose the other editor's value, contrary to the conflict workflow implemented for the other fields; add the conflict comparison/control beside both selects.

AGENTS.md reference: v5/AGENTS.md:L219-L226

Useful? React with 👍 / 👎.

const tool = await findToolByIdOrSlug(idOrSlug, { includeDrafts: true });
if (!tool) notFound();

return <DetailShell tool={tool} />;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose the editor on draft tool pages

For an authorized user opening a draft slug, this branch returns only DetailShell; the EditToolControl in page.tsx is rendered exclusively on the published-tool branch. Consequently drafts are viewable at their own page but cannot be edited or published through the specified phone-first overlay, forcing staff back to the inventory table. Render the same editor control for this authorized draft view.

AGENTS.md reference: v5/AGENTS.md:L212-L218

Useful? React with 👍 / 👎.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNV63U6ERy45Gj2TThBfyC

This branch was successfully deployed

2 active deployments
Preview – makerlab-tools-v5 4084b9b2 Deployed Sep 24, 2026 by vercel[bot]
Preview – makerlab-tools 4084b9b2 Deployed Sep 24, 2026 by vercel[bot]
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.

2 participants