add28c5f16590d1cb714ff1e88da571a2c406d29
58
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
22c51b6af7 |
docs(db): plan the Kysely swap (#305)
Two tasks. The first is the whole swap in one commit — dependencies, generated types, db.ts, the reconverted file, the ported drift test and the new 409 assertion — because splitting it would put a commit on the branch where the build is broken or both builders are present, and neither is a state worth being able to bisect to. The second is the conventions document, which touches no code and is much shorter than the one it replaces. The plan carries the converted adminCategories.ts in full rather than describing it, and names the two places the conversion could silently change behaviour: the four selects must keep answering id, name, parent_id, sort_order and item_count, and the unique-violation catch must keep producing a 409. The existing category integration suite is the gate on the first, and a new test is the gate on the second. Three expected outputs are written down so a wrong one is caught at the step rather than three steps later. Codegen must report 18 tables, not 19 — 19 means pgmigrations leaked past the exclude flag. The generated Categories interface must spell parent_id and sort_order, because camelCase there means --camel-case got turned on and the mapping layer this swap removes has come straight back. And the integration count should rise by exactly one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f8b15cafa |
docs(db): design the Kysely swap (#305)
Carries out what #297 decided. Migrations are untouched — #219 stands, they are hand-written node-pg-migrate files, and Drizzle was never doing them. Both builders must not coexist at any commit. A main that carries Drizzle and Kysely together, even briefly, is one where the next person converting a query has to guess which to reach for and where two generated mirrors of one database can disagree. There is nothing to stage anyway: one file uses the builder. Three things the swap gets for free, recorded so they are not mistaken for scope creep. The worked example stops being a source file — itemFilters.drizzle.ts was never imported by anything, so it was dead code in src/ that only documentation justified, and its replacement belongs inside CONVENTIONS.md where a worked example goes. The drift test loses its library name, becoming schemaMirror.integration.test.ts, so the next such change renames nothing. And that test gets stricter rather than merely ported: the Drizzle version had to check every column two ways and its own comment calls that deliberately loose, where generated Kysely types emit the database's names verbatim and the check becomes one exact match. CATEGORY_COLUMNS disappears rather than being translated. It exists only because Drizzle's mirror is camelCase while the API answers snake_case, and its comment says selecting the table directly would silently change the JSON contract with no test noticing. With the generated types carrying parent_id and sort_order the mapping object has nothing left to do, which is the clearest single illustration of what the swap buys. isUniqueViolation keeps tolerating both error shapes and gains a test that proves which one actually arrives. Kysely uses the pg driver directly and is expected to leave the SQLSTATE on err.code, but "expected" is the word that turned two 409s into 500s last time. Closes #305 is deliberately not claimed here — this is the design, and the implementation follows on the same branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7ca55eaa1a |
docs(db): weigh Kysely against the Drizzle decision (#297)
The question is not whether Kysely is good, it is whether it is enough better for this codebase to reverse a decision already made in #216 and partly built in #217. That is a higher bar than being the nicer library, so this answers it against the same target #216 used: buildItemFilterSql, with six optional clauses composed at run time, a recursive CTE, an ANY(...::int[]) tag match with a count equality, and array parameters. Kysely compiles without a connection, so the document quotes the SQL it actually emitted rather than a reading of its documentation. Three of the four hazards that src/db-drizzle/CONVENTIONS.md exists to warn about turn out to be properties of Drizzle rather than of type-safe query building, and two of them are the silent kind. An array interpolates as one bind parameter with no sql.param() ceremony, so the trap that document calls "the rule that will bite you" does not exist. A column reference inside a raw fragment is the text you wrote, so the correlated-subquery rewrite that returned a quietly wrong count in #218 cannot happen. And the generated types carry the database's own snake_case names, so the explicit column mapping that exists to stop a select silently changing the JSON contract is not needed at all. The property that motivated the whole exercise is unchanged: a hostile value lands in the parameters either way, so #202's invariant becomes a type-system property and #180's hotspots retire either way. What decides it is how little is actually built. One file is converted — adminCategories.ts, three calls — against 238 raw query sites, and the generated mirror and its drift test are things any builder needs an equivalent of. The recommendation is to switch now, while the cost is reconverting one file and rewriting a conventions document that gets substantially shorter. The counter-argument is recorded rather than hidden: Drizzle is more widely used, and #219's migration reasoning was measured against drizzle-kit specifically. That reasoning survives, because losing the prose and being unable to express data migrations are true of any generator, and Kysely simply has nothing to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df508b3783 |
docs(admin): plan the photo rotation work (#301)
Three tasks, each with its own test cycle: the file operation, the endpoints and the module behind them, then the control in the review queue. Two things were probed rather than assumed while writing it, and both changed the design. sharp reads a positive angle as clockwise, so left is rotate(-90) and right is rotate(90) — and the direction test asserts a pixel rather than a dimension, because a rectangle's dimensions swap whichever way the turn goes and a reversed sign would pass every size assertion while shipping a control that does the opposite of its label. And a quarter turn of an animated WebP is refused by sharp itself, which is what makes passing the same animated flag reencodeInPlace passes the safe choice: omitting it would read the first frame alone and write a still back over someone's animation while reporting success. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1178129110 |
docs(admin): design rotating a photo from the admin (#301)
The remedy for what #300 could only stop. Fixing the EXIF strip means new uploads arrive the way the sender saw them; it cannot repair what is already stored, because that metadata is gone and the originals kept for #281's cut-outs were re-encoded on the way in too. Every portrait photo since #226 needs a person to look at it and turn it. Rotation rewrites the file rather than recording an angle. Storing an angle keeps the bytes pristine and makes undo exact, but it puts an obligation on every consumer — the storefront, both admin screens, the drafting worker's photo reader, and the rembg sidecar — and any one that forgets shows the photo sideways. The sidecar in particular is not ours to teach. Rewriting means nothing else in the system has to know rotation exists, and the cost is bounded: one rotation is a second generation at quality 82, which is why the control offers both directions rather than making somebody press one button three times to undo. Per photo, and that is deliberately the opposite of what #293 decided for background removal. The reason there does not carry: three photos of a vase can each be wrong in a different direction, so turning them together would fix one and break two. A cut-out and its original turn together. An image that has been through #281 has two files, and rotating only the displayed one would leave them disagreeing — Restore original would then silently un-rotate the photo, turning the undo of one feature into a regression of another. The endpoints go on the item rather than the draft, so the inventory editor needs no backend work at all when it follows: an image belongs to an item whether or not a draft row exists, and the second screen is then the same call from a different place. Two things pinned so they are not settled by a coin-flip while implementing. Left is anticlockwise and right is clockwise, which is sharp.rotate(-90) and sharp.rotate(90) — sharp reads a positive angle as clockwise, so the sign is the whole mapping and reversing it produces a control that works and does the opposite of its label. And the displayed image has to be forced to reload: rotation does not change image_path, so the img src is identical afterwards and the browser keeps what it has. express.static is mounted with no maxAge and would serve the new bytes on a page reload, but nothing in a session asks it to, so the src gets a cache-busting parameter after a successful turn. One honest asymmetry recorded rather than engineered around: rotation is not idempotent the way background removal is, so a retry after a half-done failure turns the displayed file twice. That needs the disk to break between two writes, and the remedy is one press in the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b70e4a68f0 |
docs(specs): match the design to the shipped behaviour (#293)
The spec asserted two things the implementation disproved. RestoreSummary was described as having no `failed` because "restoring cannot fail the way removing can" — true about the sidecar, wrong about the database, and rethrowing turned a partial success into an opaque 500. And the single-button-with-two-labels rule was described as deliberately covering the mixed case, when in fact it stranded it: a partly cut-out item offered only Remove, so its existing cut-outs had no way back. Both sections now describe what the code does and why, including why Restore is not gated on the feature being configured, and the outcome table's "feature not configured" row is corrected to say Restore is still offered and still works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f42ea70d88 |
docs(admin): plan background removal in the inventory editor (#293)
Three tasks: the two per-item functions, the endpoints plus a small admin config route, and the button.
The config route is the piece the spec did not anticipate. The inventory screen has no way to learn the feature is configured — GET /api/admin/item-drafts carries that flag for the review queue, but GET /api/admin/items answers a bare array with several consumers, and reshaping it for one boolean is the worse trade. routes/adminVersion.ts is the precedent for exactly this: a small admin-only GET, deliberately not folded into the public /api/config, with the reason written down beside it.
Two decisions the plan pins that the spec left as prose. The render condition is "configured OR every photo already cut out", not the flag alone, because gating on the flag would hide Restore originals the moment REMBG_URL is unset and strand cut-out photos with no way back — the same shape DraftQueue already uses for the same reason. And the button re-reads the item afterwards, because the editor is a modal changing files on the server while it is open, and without that the thumbnails keep showing the previous files and the button looks inert.
Self-review caught the mistake I have now made three times this session, which is naming something that does not exist. Task 3's end-to-end case called createItem(page, { withImage: true }); createItem actually takes an APIRequestContext rather than a page, and CreateItemOptions has no image field at all. Since the control only renders for an item that has photos, the item now gets seeded through the admin API with a real PNG attached, which is a thing that works rather than a thing that reads well.
It also records a spec requirement that is deliberately not implemented as written. The spec asks for an end-to-end assertion that the control is absent when the feature is unconfigured; that would mean restarting the backend mid-suite, which the run has no way to do and should not gain one. It is covered where it can be, in the integration test for GET /api/admin/config, and the plan says so rather than dropping it quietly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
43a1bfef69 |
docs(admin): design background removal in the inventory item editor (#293)
The design behind #293, with the three decisions the issue left open now answered. It applies to a live product image, and to every status including sold and reserved. The precedent in unpublish, which refuses both by name, does not carry: what that protects is a customer losing an item mid-checkout and a completed sale being quietly rewritten, and neither is at stake in a photograph's background. A sold item's photos are still the shop's photos. The action is per upload rather than per photo, and that is the decision shaping everything else. An upload is one item — the front, the back and the chipped base are three views of one vase, not three things to cut out separately. It also means DraftPhoto is not the component to lift, despite looking like it: the queue's control is per photo and this one is per item, so sharing it would force one to pretend to be the other. The real reuse is underneath, in removeImageBackground and restoreImageOriginal, which already exist and are already idempotent. removeBackgroundsForItem gains a summary return. It answers void today and throws on the first failure, which is enough for the worker — it catches and logs, and a draft is not worth failing over — and not enough for an admin standing in front of the screen. The one existing caller ignores the result, so widening it is additive, the same way sendMail was in #260. Writing it caught a contradiction in my own first draft worth recording. The failure table said a sidecar failure answers 502 while the screen section promised the admin sees "2 of 4 photos done", and both cannot be true, because a 502 throws away the count that makes the outcome actionable. Resolved by these two routes always answering 200 once the id is valid: they act on several images, so "did it work" has no single answer, and the summary is the result. Non-200 is reserved for not being able to try at all. That is a deliberate departure from #281's per-photo endpoints, which act on one image and can honestly say yes or no. Two ambiguities also fixed before they became implementation coin-flips: what the button says in a mixed state, which is exactly what a partial failure leaves behind and which reads Remove backgrounds because that is the action finishing the job; and that restore has no failure mode of its own, being a database swap with no sidecar in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a9e865b4bc |
docs(intake): plan emailing the upload link (#260)
Four tasks over the approved design: sendMail gains an outcome, the column and template land together, the route requires an address and reports what the send did, and the admin screen asks for it. sendMail goes first deliberately. Everything else depends on being able to tell a skipped send from a real one, and it is the only change touching a file seven other things already use — so if it is going to break anything, it should break before three tasks are stacked on top of it. The plan is explicit that no existing caller changes. Ignoring a returned value is legal, which is what makes widening the return type additive rather than breaking, and re-deriving "would this address be blocked?" in the route would have duplicated isAllowedRecipient and the SMTP check at a second site. Two specs in unrelated features create links with only a label, and the route will refuse that. They are fixed in the same task as the form rather than left for the suite to find, because the alternative is two unrelated features going red on somebody else's branch. That cost is named in the plan rather than discovered. One inaccuracy is left in deliberately and said out loud: an SMTP rejection is reported as skipped-unconfigured rather than a fourth outcome. The distinction is real, nothing consumes it, and the admin's next action is identical either way — copy the link and send it by hand. Self-review caught the failure mode from #281, where tasks referred to helpers that did not exist. Task 4 originally said the created-link state "may not be called created". Reading the component showed it is `issued` and holds a bare URL string with nowhere to put a delivery outcome, so the plan now adds a separate `mailed` state beside it rather than widening the one-time token display. Every name in that task is now one that exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9e1e650786 |
docs(intake): design emailing an upload link to its recipient (#260)
The issue asked for the security question to be settled before any code, and it is: the mail carries the working link. The issue framed that as a loosening comparable to a password reset, and on inspection that framing overstates it. A reset token takes over an account. An upload token grants exactly one capability — submit photos into a queue where a person must approve them before anything reaches the storefront. It reads nothing, it is revocable, max_submissions caps it, and #227 caps the whole intake surface regardless of any single link. The worst outcome of a leaked upload link is junk in the review queue, which is bounded and reversible. That is a reasonable thing to put in an inbox, and this project already makes the much larger bet with reset links. The address is required for new links while the column stays nullable, which is not a contradiction: links already exist in QA and a migration cannot invent addresses for them, so they are grandfathered, and the requirement belongs in the route where new links are actually made. It lives on the link rather than on a contributor entity, because a link already carries a label naming who it is for and nothing yet suggests the same people submit repeatedly. A failed send does not roll the link back. The token is shown exactly once, so a rollback would leave the admin retrying and holding a different link, discarding work that succeeded. The link is created, the send is attempted, and the response says which happened — which matters concretely because QA's MAIL_ALLOWLIST silently skips any address outside it and returns as though it sent. Without an explicit outcome, testing this in QA against a contributor's real address looks exactly like success, which is the afternoon the issue warned would otherwise be wasted. Writing it turned up one thing the design had assumed and the code does not support. sendMail returns Promise<void> and returns early both when SMTP is unconfigured and when the recipient is not allowlisted, so a caller cannot tell either from success. It gains a MailOutcome return value instead. No existing caller changes — there are seven and every one ignores the result — and the alternative would have duplicated isAllowedRecipient and the SMTP check at a second site, which is the drift the guard-in-one-place comment in mailer.ts exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
48b456d1b8 |
docs(storefront): plan the catalogue pagination (#269)
Three tasks over the decisions recorded on the issue: the pure paging rules with their tests, the wiring into the catalogue, and the end-to-end cases including the two assertions this finally lets say what they mean. Two decisions the issue left open are settled here. The page number goes in the URL beside the filters, because a numbered page being linkable is the whole reason numbered pages were chosen over infinite scroll, and the URL is already the single source of truth for what the storefront is showing. The page size deliberately does not, because it is a preference belonging to one person — putting it in the URL would mean sharing a link to an item also imposed your page size on whoever opened it. It lives in localStorage instead, which also keeps it inside the issue's own scope boundary of not touching the API. The third open question, whether page size changes with viewport, is answered no and written down as such rather than left silent. A size that moved on rotation would fight the preference the customer had just set. Everything testable is a pure function, because that is all this project can unit-test: vitest runs in a node environment with no jsdom and no testing-library, so a hook or a component is only reachable through Playwright. That is the same split filters.ts already uses, and it is why the React wrapper is thin enough to need no test of its own. Writing it turned up one piece of luck worth not breaking: filtersToSearchParams builds a fresh URLSearchParams, so applying a filter already drops the page parameter and returns to page one, while paging copies the existing params and keeps the filters. The plan says so explicitly so nobody 'fixes' it later. It also confirmed the issue's own warning. filters.spec.ts:165 asserts a named fixture is visible in the unfiltered grid, and favorites-filter.spec.ts:106 does the same — with ten items to a page over thousands, both would start failing the moment paging landed. They were always proxies for 'the result set got bigger', and a visible total lets them assert that directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09686d9f94 |
feat(admin): remove or restore a photo's background from the review queue (#281)
Adds the per-photo control that closes out the background-removal feature: each photo in the review queue now gets a "Remove background" or "Restore original" button, whichever matches its current state, and the button only appears when the server reports a sidecar is configured. The label is read from original_image_path alone rather than a second flag, so there is nothing that could disagree with what the button actually does.
draftsApi.ts's fetchDrafts now returns { drafts, backgroundRemoval } instead of a bare Draft[], matching the breaking change Task 6 made to GET /api/admin/item-drafts. DraftImage gains original_image_path, and a new setImageBackground(itemId, imageId, action) posts to the remove-background/restore-original endpoints, preferring the server's error message the same way publishDraft does.
Also updates docs/ops/image-background-removal-stack.md: the status line no longer says "evaluated, not adopted", since the feature is adopted here, and the closing "If this is adopted" section is replaced with "How the application uses it", describing the two real entry points (the drafting worker's default-on checkbox, and this per-photo control) and confirming that nothing in the feature deletes a file or a row.
Adds an e2e case asserting the button's label appears on a freshly submitted item's card, scoped to that card by the sender's note per #241. It is unrun in this environment — the local stack was not started, per standing instruction not to run start-local.ps1 or Playwright without the user's supervision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1c265840a9 |
docs(intake): plan the background-removal implementation (#281)
Eight tasks over the approved design, each ending in something independently testable: the two columns, the sidecar client, the shared swap-and-restore, the worker step, the intake route, the admin endpoints, the submitter's checkbox, and the review queue's per-photo control. Three things the plan pins down that the spec left to implementation. The `model=u2net` assertion lives in a unit test against a real stub HTTP server rather than a mocked fetch, because what has to be checked is the shape of the request that reaches the wire. Nothing in the returned image would reveal that the non-commercial default had been used, so that assertion is the only thing standing between this and a licensing problem that produces perfectly good pictures. Removal in the worker follows drafting rather than running on its own pass, which couples the two: an environment with no ANTHROPIC_API_KEY drafts nothing and so cuts out nothing. That is the deliberate trade — a separate pass would re-attempt an unreachable sidecar on every five-minute sweep for a row that is going to sit at `queued` indefinitely — and the plan says so in the worker's own header comment rather than leaving it to be rediscovered. `removeImageBackground` is idempotent through the `original_image_path IS NOT NULL` check rather than a separate flag, and that guard is load-bearing twice: it makes a repeat call a no-op, and it stops a second pass recording the cut-out as the original and losing the real one for good. Writing it turned up two things worth knowing about the existing tests. `drafting.integration.test.ts` has never produced a successful draft — every case in it either has no key or no readable photo — so the worker's new cases need their own file with `draftListing` mocked, rather than a mock added file-wide to a suite that deliberately never reaches the model. And `adminItemDrafts.integration.test.ts` calls `request(app)` directly with no helper, so the plan spells out the seed it needs instead of pointing at one that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
196d83a56e |
docs(intake): design background removal for submitted photos (#281)
The design behind #281, written before any code so the decisions can be argued with while they are still cheap to change. The implementation follows on this branch. Six decisions, each recorded with what it rests on rather than just what it is. The one that matters most is that `model=u2net` goes on every request: the sidecar's default is `bria-rmbg`, which is licensed non-commercial, and it is reached by simply not specifying a model — a silent licensing problem that produces a perfectly good image. A test asserts the parameter is present, because nothing in the output would reveal its absence. The other consequential one is that the submitter's tick records an intent rather than doing the work during their upload. Inline removal would make them wait, would put a CPU-heavy model run in a path anyone holding a link can trigger — the surface #227 exists to bound — and would force a choice, when the sidecar is unreachable, between failing their submission and silently ignoring what they asked for. Recording the intent means the submission always succeeds and keeps its original photo, and the cut-out arrives with the AI draft seconds later. Everything else follows the rule the pipeline already runs on: a submission is the only irreplaceable thing here. The original is never destroyed, every failure path leaves the photo exactly as it was, and an unset REMBG_URL means the feature simply does not exist rather than that the environment is broken. Documents what is not established too — quality on a real photograph is unknown, because the engine evaluation used a generated rectangle on a flat ground. The per-photo control and Restore original are what make a poor result survivable rather than something to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8cfd93b0c |
docs(ops): setup notes for the rembg background-removal sidecar
Measured rather than described. Everything in here was run against danielgatis/rembg:latest on 2026-09-02 and the numbers are from those runs, with the caveat recorded that a dev box under Docker Desktop is not the NAS. The finding that matters is the default model. This image downloads bria-rmbg on first use, and BRIA's RMBG models are licensed for non-commercial use — the same trap that ruled out @imgly/background-removal-node, reached silently by making one request. Sending model=u2net explicitly is both the licensing answer and ten times faster: 1.1-2.3s against 14-20s for a 2000x1500 image, and 168MB on disk against 977MB. That speed difference decides the interaction on its own, because a click can wait two seconds and cannot wait twenty. Also records the volume path, which most documentation online gets wrong for this image: models land in /root/.rembg, not /root/.u2net, and mounting the old path silently re-downloads 168MB on every start. The output was verified rather than assumed — PNG, four channels, alpha spanning 0 to 255, dimensions preserved, 1.7x the input JPEG's bytes. Marked as evaluated rather than adopted, since the engine choice is still open, and the three things not established are listed: quality on a real photograph, behaviour on the NAS, and concurrency. The test input was a generated rectangle on a flat ground, which says nothing about a chipped vase on a patterned rug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c6aaa46eb |
fix(intake): send the workspace id an identity-linked key requires (#271)
Every draft in QA failed with a 400: "anthropic-workspace-id is required when authenticating with an identity-linked API key". A key issued against a workspace, rather than standing alone, is refused unless the request names the workspace it acts in — and the client was constructed with an API key and nothing else. Nothing about a key's shape says which kind it is, so no amount of configuration checking would have caught this. Only a real call would, which is exactly what #223's task 8 existed to make. Sent only when ANTHROPIC_WORKSPACE_ID is set. Plenty of keys need no workspace, and sending an empty header would turn the ordinary case into a different error rather than leaving it working. Both compose files carry it with an empty default so an unset variable cannot fail a deploy, and the cutover doc goes from fifteen interpolated names to sixteen — checked against the file, and every name in the list now matches one in the compose. The failure handling needed no change and got none. The submission kept its photos, the draft recorded ai_error, and the review queue showed the reason. A model call failing must never lose somebody's consignment, and it did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
722ff91378 |
docs(intake): plan the global submission ceiling (#227)
Six tasks: the settings, stopping them leaking between test suites, the count, the alerts, the refusal, and the manual reset. The count is derived from item_drafts rather than a tally, as the issue asks. That forces a decision it left open: a reset cannot delete anything, because the rows are real submissions whose items are sitting in the review queue. So a reset stores a timestamp and the window becomes the later of that and 24 hours ago — one derived count, no second tally, and a reset that is an auditable fact rather than a deletion. The refusal is ordered ahead of uploadImages, for the same reason requireUsableLink is: a refused submission must write zero bytes. Ordering it after would accept the upload, store the files and throw them away, which is the expensive half of the work the ceiling exists to prevent. The alert throttle is in memory rather than in the database, so a restart during an incident can send one extra alert. That is a better trade than writing to admin_settings from the request path on every refused submission, and it is noted that a replicated deployment would have to move it. Self-review caught two things against the tree. POST /api/admin/items answers 200 rather than 201, so that assertion was wrong. And resetDb deliberately does not truncate admin_settings — it deletes only the email_ rows — so a ceiling of 1 left behind would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment in that file records the same failure happening once already with an email template. Task 1b widens the cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b4fbbb9e3 |
build(intake): give the containers an INTAKE_ACTION_SECRET (#224)
A gap in the plan rather than in the code: nothing wired the secret into either stack, so the feature would have shipped with its signed links permanently disabled and nothing saying why. Both compose files now interpolate it, with `:-` so an unset variable stays empty rather than failing the deploy. QA takes QA_INTAKE_ACTION_SECRET, its own value rather than production's, for the same reason as every other QA_ prefixed credential — and more sharply here, because a link signed with it acts on a draft without a login. Rotating the secret revokes every outstanding link, which is the intended answer to one leaking. The cutover doc counted fourteen interpolated names and now counts fifteen. That document says it is checked against the file rather than from memory, so it was: fifteen in the compose file, the same fifteen listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c88d1eee11 |
docs(intake): plan the submission notification (#224)
Five tasks: a pure HMAC signer, the editable template and its recipient setting, the send from the drafting worker, the public routes that act on a signed link, and the quiet paths. The plan makes one decision the issue does not, and it changes the shape of the feature. Mail scanners and link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — with a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. So the signed link is a safe GET that confirms and a POST that acts. It costs one extra click and is cheap to reverse if that is the wrong trade. Everything about the notification is best-effort. No recipient configured, no INTAKE_ACTION_SECRET, or SMTP down all end in a log line: the review queue is the source of truth, and a draft that was written correctly must never be marked failed because an email did not send. The email still cannot publish. The two signable actions are exactly the ones whose worst case is a wasted API call or a hide the queue can undo, which is what makes putting them in an inbox acceptable at all. Branched from feature/225-review-queue rather than main, because the review link has nowhere to land without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c119f79747 |
docs(intake): plan the review queue (#225)
Six tasks: the price provenance rule as a pure unit, the list endpoint, publish, the three state actions, the screen, and an end-to-end pass. The price field is the reason this screen exists. Items are priced on arrival, so the schema no longer prevents a number nobody chose from reaching the storefront — that protection moves here, into presentation, where it is weaker. So the rule is a pure tested function rather than a line inside a route: editing the number is the only thing that confirms it, publishing an untouched field deliberately does not, and publishing something still unconfirmed asks first rather than reporting afterwards. 80.00 was chosen because it reads as a decision rather than as an obvious sentinel, which is exactly why it has to be called out rather than left to be noticed. Discard deletes nothing. It is one click from an inbox, and the photos are often the only copy of an item no longer in the sender's hands, so it marks the draft and returns the item to pending. Restore brings it back at the state its own contents justify rather than unconditionally ready, because a submission discarded before it was ever drafted has no copy and must not return claiming otherwise. Regenerate clears attempts along with the state. The worker only picks up rows below the attempt cap, so re-queueing a draft that already failed three times would otherwise produce a button that appears to work, does nothing, and says nothing. The end-to-end test seeds through the intake route rather than POST /api/admin/items, which writes no item_drafts row and would never appear in a queue that joins it. One deviation from the issue is recorded in the plan rather than buried: it asks for the existing admin item components to be reused, and this builds a purpose-made card instead, because the fields differ in kind rather than arrangement. The cost — two places rendering a name, description and price — is named there too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d887cf1d15 |
docs(intake): mark the drafting worker plan complete through task 7 (#223)
Only the manual verification against a real photograph is left, and it needs an API key that does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96af5a571d |
docs(intake): make the drafting model an admin setting (#223)
Added mid-execution at Thom's request. Two consequences worth recording: it is a dropdown validated on the server rather than a free-text box, because a mistyped model name fails on every submission and surfaces only as drafts quietly not appearing; and the model list lives in one catalogue shared with the cost table, so the settings dropdown and the per-token rates cannot drift apart. Rates confirmed against the pricing page rather than recalled. Worth having checked: an increase to $3/$15 had been scheduled for tomorrow and was cancelled, with $2/$10 made permanent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
054a4b0cfd |
docs(intake): plan the AI drafting worker (#223)
Eight tasks: the SDK and its optional key, the Zod shape the model must answer in, the prompt, the call, writing a draft back, the worker that drives it, the wiring, and one real photograph to see whether any of it writes something worth reading. The prompt is the correctness surface and gets its own task with its own tests. On one-of-a-kind stock an invented "1930s hand-thrown stoneware" is not a cosmetic error but a false claim on a storefront, and nothing downstream can tell an invented detail from an observed one — the only place that distinction can be enforced is in the instruction, so the tests assert it is there. The other governing rule is that a submission is the only irreplaceable thing in the pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, a failed call, a malformed answer and three exhausted retries all end the same way: the item keeps its photos and waits undrafted. Nothing in the worker deletes anything, and an absent key does not spend an attempt. Only the suggested price reaches the item, per #220, with price_source recording that a model rather than a person chose it. The name and description stay on the draft row until the review queue in #225 exists. The monthly spend ceiling is deliberately left out. Task 8 measures what a real call costs first, because a budget set from a guessed number is one nobody trusts. Every test stubs the Anthropic client. A test that reaches the real API is a defect in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2424305d43 |
build(intake): give the container an ANTHROPIC_API_KEY (#223)
The drafting worker needs a credential, and a Portainer stack variable alone does not reach the container — stack variables are interpolated into the compose file as ${VAR}, and a service receives exactly what its own environment block lists. That is how UPLOADS_DIR went missing in #118, and both files say so; this adds the line that makes the variable actually arrive.
QA takes it from QA_ANTHROPIC_API_KEY, prefixed like the database and SMTP credentials so production's key cannot be pasted there and silently work. It is also worth a key of its own rather than sharing production's, because this is the only credential in either stack that spends money per call, on a path anybody holding an upload link can trigger.
Absent is a working configuration in both, deliberately, which is why prod's line carries `:-` and neither variable joins the always-required list. A submission still arrives, keeps its photos and waits undrafted. Losing somebody's consignment to an expired key would be far worse than an item arriving without its description written, and the photos may be the only copy of something no longer in the sender's hands. USPS is the existing precedent for a credential whose absence degrades rather than fails.
The comments say plainly that a spend limit belongs on the key in the Anthropic console, since nothing in this repository can enforce one and #227's submission ceiling bounds the volume rather than the bill.
docs/ops/production-stack-cutover.md said the compose file interpolates thirteen names and listed them. It now says fourteen, because that document stakes its usefulness on being checked against the file rather than written from memory — a cutover working from a stale list is how a variable gets left behind, which is the failure the document exists to prevent. Counted from the file: exactly fourteen.
composeEnvironment.test.ts passes, 24 tests. It checks that every deployment sets what the validator requires, so adding a variable ahead of a validator entry cannot break it — the entry itself comes with the worker.
Ref #223
|
||
|
|
7d8ac15ef8 |
docs(intake): refresh the extraction task for the changes #226 made (#222)
This plan was written on 2026-08-29, before #226 landed. Its Task 2 lists what to move out of routes/admin.ts into the shared image pipeline, and that list is now missing `stripUploadedImages` and the `reencodeInPlace` import it depends on, because neither existed when the list was written. Executing it as written would have left the re-encode behind in admin.ts, and the public intake route added in Task 5 would then have had an upload path that skips EXIF stripping entirely. That is precisely what #226 exists to prevent — a stranger photographing an item at home publishing the coordinates it was taken at — and nothing in the suite would have failed to say so, because the intake tests are written against a route that does not exist yet. The task now names the function, says why it matters, and adds a check with a definite answer: after the move, routes/admin.ts must no longer import imageProcessing. If it still does, something was left behind. Ref #222, #226 |
||
|
|
d16aba1647 |
docs(test): plan the e2e trustworthiness work (#241)
Four tasks. A pure findOrFail helper with Vitest coverage, the nine unchecked lookups converted to use it, the expect timeout raised from Playwright's unset default of 5s to 10s, and the test #245 skipped brought back. The helper deliberately imports nothing and lives apart from support/api.ts. api.ts imports @playwright/test, and vitest.config.ts runs with environment: 'node' over tests/unit only — putting the helper there would drag a browser harness into the unit suite to test six lines of pure logic. Two things the survey changed. filters.spec.ts:216 is excluded: its .find() searches CSS class names on a string array rather than test data, so it has no missing-row failure mode. And three `expect(row).toBeTruthy()` assertions are deleted rather than kept, because findOrFail has already thrown by then — leaving them would tell the next reader the value might be falsy, which is the confusion the change exists to remove. Worker-count reduction is explicitly not in this plan. It is a real lever and may still be needed, but applying it at the same time would make it impossible to tell which change fixed anything. The plan states the criterion it cannot check: CI's load is not reproducible here, so two consecutive green local runs mean the refactor is sound, not that the flakiness is gone. Several consecutive green CI runs are the real bar, and #241 stays open until then. Ref #241 |
||
|
|
f8b2b68f0d |
docs(test): design for making the e2e suite trustworthy (#241)
Five distinct specs failed across two runs of identical code with no overlap between the sets, so which test fails is decided by the scheduler. The cost is already being paid: #245 skipped the only end-to-end check that adding an item reaches the database, purely to get main green. The design separates two mechanisms that had been treated as one. Unchecked lookups into shared collections — `collection.find(...)` dereferenced immediately, found at eight or more sites — are a defect regardless of concurrency: when the row is missing the test dies with "Cannot read properties of undefined" naming test plumbing rather than failing an assertion that says what it wanted. Load-induced timing is the other, and is the larger share of what has actually been observed: three of four local failures and the CI one are assertions in a spec's own browser context that nothing else can touch. Two claims from earlier in this investigation are retracted in the document rather than quietly dropped. The verification-resend limiter is not a shared axis — it is keyed per customer and every test registers its own — and the suite contains no snapshot-style assertions, so there is nothing to convert to web-first. Both were stated as fact on the issue, and both would have justified work that was not needed. Per-worker databases are ruled out structurally: every worker talks to one backend on :3000, so isolation there means N backends, not N databases. Worker-count reduction is deliberately deferred rather than taken now, because applying it at the same time would mask whether fixing the defects worked. The honest limit is recorded too. CI's load cannot be reproduced here on demand, so the timing changes rest on reasoning rather than a red-to-green demonstration, and the success criterion is several consecutive green runs rather than one. Ref #241 |
||
|
|
65f9d00785 |
docs(uploads): plan the EXIF stripping and re-encode (#226)
Five tasks: prove sharp installs where it actually runs, the re-encode policy as a pure module, wiring it into the single middleware every upload path already passes through, the backfill over already-stored photos, and the deployment sequence. Re-encoding rather than deleting tags. Deleting requires knowing every tag that could carry something sensitive, across formats and camera makers, indefinitely; rebuilding the file from decoded pixels leaves nothing that could have been missed. Same reasoning that makes uploadTypes.ts an allowlist. Format is preserved rather than normalised to WebP. Converting would compress better but changes every stored extension, and therefore item_images.image_path, turning the backfill into a rename with a window where rows point at files that no longer exist. A privacy fix does not need that risk, and the backfill consequently touches no database rows at all. The backfill is lossy and irreversible, so it reports by default and needs --apply, writes to a temporary file and renames so an interruption cannot leave a half-written image being served, and is idempotent by construction: a file already stripped and already within bounds is skipped rather than put through a second lossy pass. That property is a pure function with its own unit test, because being wrong about it degrades every image a little more on every run. Two traps the plan handles that the issue only named. An animated WebP read without the animated flag decodes to a single frame and is silently written back as a still, so the flag is set for WebP and only WebP — it changes how resize reads height, which would be wrong for the other types. And sharp before 0.33 has no withExif, which the tests need to build their fixture; on an older version they fail as though the stripping were broken. Ref #226 |
||
|
|
c76828122d |
docs(intake): guard the uploads volume and bound a link by default (#220)
Two tasks added to the slice-1 plan, closing the half of the upload gap the middleware ordering does not. The ordering fix stops a caller with a bad token writing anything. A caller with a working one can still send six eight-megabyte files per request against a limiter that allows twenty requests a window, and nothing checks whether the volume can take it. That volume is shared with the admin upload path, so intake filling it is a shop outage rather than an intake outage. Task 8 refuses an upload when less than a gigabyte remains, on the admin item routes as well as intake, failing closed because a volume that cannot be measured is not one to assume is empty. Task 9 makes an absent cap mean the bounded default of twenty-five rather than unlimited: the router as written treated omission as "no limit", so the ordinary act of creating a link produced an unbounded one, and a cap that has to be remembered is not a control. Two larger findings are filed rather than folded in. Re-encoding uploads to strip EXIF and cut stored bytes (#226) touches the shared pipeline and adds a native dependency; a global ceiling with an abuse alert (#227) needs its own state and an email. The EXIF one is worth stating plainly: nothing strips metadata today, so an uploaded phone photo publishes the coordinates it was taken at, at a public URL. That is already true of the admin path and is not introduced here, but this slice widens who can put such a file there. Ref #220 |
||
|
|
783d10abc4 |
docs(intake): plan the upload-link and submission-page slice (#220)
The first of four slices from the intake design, and the only one that is worth planning in detail yet — the later slices' shape depends on what this one actually produces. Seven tasks: the schema, extracting the validated image-upload pipeline out of routes/admin.ts so the public endpoint reuses it rather than growing a near-copy of it, token generation and hashing, the admin API for issuing and revoking links, the public submission endpoint, the submission page, and the admin screen. Three things the plan settles that the design left open or got wrong. The image caps become the constants already in the codebase rather than the 10-photo and 10 MB figures the design invented, because two different caps on one pipeline is a defect waiting to happen. The feature flag is dropped from this slice: nothing is reachable until a link exists, and the flag earns its keep in slice 2 where a paid API call appears. And the link is resolved before multer runs, so a stranger holding a bad token cannot cause a byte to be written to the uploads volume — cleanup afterwards would leave an unauthenticated caller in control of disk churn, and leans on an unlink that a crash between write and delete would skip. That ordering is asserted by a test, so a later reordering fails loudly rather than silently. Ref #220 |
||
|
|
722bade383 |
docs(intake): price arriving items rather than leaving them unpriced (#220)
`price_cents` stays NOT NULL and gains a default of 80.00. Where the model suggests a price the worker writes it onto the item; where it does not, the default stands. This is cheaper to build than the nullable design it replaces — the column's type is unchanged, so the fifteen files that read `price_cents`, the cart and the checkout among them, keep working untouched, and the migration adds a default and nothing else. It also gives up a guarantee. The storefront can now be reached by a price the admin never chose, so the protection moves out of the schema and into the review queue, where it is weaker. 80.00 is a plausible number rather than an obvious sentinel, so a default left unnoticed sells the item instead of announcing itself the way "$0.00" would. `price_source` is added to make that legible: the queue labels a price as coming from the model, the default, or the admin, and marks anything unconfirmed as such at the point of publishing. Publishing unconfirmed remains allowed — that is the decision taken — but it is stated rather than silent. Publishing still happens only from the queue, and the notification email still carries no publish button. Ref #220 |
||
|
|
dbe6a0cf8f |
docs(intake): design the upload-link, AI-draft and review-queue pipeline (#220)
A named, revocable link lets someone without an account send in photos of one item plus a note; a background worker drafts the listing; the admin is emailed and publishes it deliberately from a review queue. Most of the lifecycle already exists and is reused rather than rebuilt: `pending` has been the unpublished state since #90 and is already excluded from every public query, the upload path already validates magic bytes against a three-type allowlist, mail already has editable templates and an allowlist guard, and node-cron is already the background-work pattern. What is new is a way in for someone with no admin account, the first LLM integration in this codebase, and somewhere to review a draft. The design turns on one invariant: nothing reaches the storefront at a price a model guessed. The suggested price lives on the draft and never on the item, the email carries no publish button, and the publish path refuses an item with no price. That is also why `price_cents` becomes nullable rather than defaulting to zero — a sentinel that formats as "$0.00" is the same class of quiet failure as `DEMO_MODE` once being "demo unless the value is exactly false", and nullability makes the compiler enumerate all fifteen call sites instead. Ref #220 |
||
|
|
70d04186b1 |
docs(ops): stop the compose header contradicting itself, and name the four variables step 2 dropped (#204)
Three corrections a review of #196 found, all in text #196 itself rewrote. **The compose header asserted something it falsified three lines later.** "All must be set in Portainer for this stack. All are secrets except DEMO_MODE" — but three PayPal entries are unused while `DEMO_MODE` is `true`, three more are marked Optional, and `SMTP_FROM` is not a secret. The runbook sends the operator to that exact block as authoritative, so an operator cutting over during the demo interim reads "all must be set", has no live PayPal credentials — which is the whole reason the interim exists — and either stops or invents a `BACKUP_PASSPHRASE`, which is how you get archives nobody can decrypt. The blanket claim is gone; each entry already says whether it is required and when. **Step 2 enumerated nine of the thirteen interpolated names.** `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL` and `BACKUP_PASSPHRASE` were missing. The USPS pair is the one that matters, and it now gets a sentence of its own: losing it is the only silent failure in this step. Address validation is skipped when those are empty rather than failing, so checkout keeps working and quietly stops validating addresses, with no crash loop and nothing in step 7 that would notice. The list also now says to take everything the stack holds rather than working from the list, because an enumeration reads as a checklist however it is introduced. **The `DB_PASSWORD` failure was described wrongly, and its error names a variable the operator never typed.** It does not fail to authenticate against its data directory — the app never reaches a connection attempt, `checkAlwaysRequired` refuses at boot, and the message says `PGPASSWORD` because the compose file injects it as `PGPASSWORD=${DB_PASSWORD}`. An operator grepping for `DB_PASSWORD` finds nothing. That is now stated. The crash-loop examples were reordered to match the case the section claims to be about. It headlines "the likeliest outcome of a missed step 2", but the only block shown was the PayPal triple, which cannot occur during the demo interim, and #196 replaced it with a `DEMO_MODE`-only block that is not what a missed step 2 produces either. A wholesale miss is two problems led by `PGPASSWORD`; that is now first, with the `DEMO_MODE`-only and mistyped-value forms after it. Every quoted line was verified by running the real validator against production's entry set rather than by reading the source — `2 problem(s)` and `1 problem(s)` counts included — and all four now match character for character. The mistyped-value message is quoted in full rather than truncated, which was the point of the complaint that produced it. `DEMO_MODE` is no longer called "the only one that is not a secret", which was false of `SMTP_FROM` and `UPLOADS_BASE_URL`. It is the only one that is a setting rather than a credential, which is true and a better hook. Verified: 278 backend unit tests pass, including the compose guard that parses this file. Closes #204 Refs #196 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2fe3aa055f |
docs(ops): make the cutover runbook agree with the compose file about DEMO_MODE (#196)
#190 turned `DEMO_MODE` from a hardcoded compose value into a Portainer stack variable with no default, and updated the compose header. The runbook that actually creates the stack was not updated with it, so the document and the file it deploys have been disagreeing since — in the three places most likely to be read under pressure. Step 2's list of stack variables to record did not include `DEMO_MODE`, and step 6 says to add the variables from steps 2 and 3. An operator following this literally creates a stack that cannot boot. It is now in the list, with a paragraph of its own: it is the only one of the nine that is not a secret, which is exactly why it is the easy one to skip past. The troubleshooting section said `DEMO_MODE` was hardcoded and therefore could not be missing, so its absence from the error list proved nothing. That was the most dangerous sentence in the file — an unset `DEMO_MODE` is now the *first* thing to check rather than something to rule out. The line now says it moved and why. The crash-loop example was the PayPal triple, which cannot occur while `DEMO_MODE` is `true`. The message an operator will actually see during the demo interim — `DEMO_MODE is required and must be exactly 'true' or 'false'` — appeared nowhere in the runbook. Both forms are shown now, in the order they are likely to be hit. Added what neither document said: Compose only *warns* about an unset variable and deploys anyway. In Portainer's stack UI that warning is easy to miss, and the container then crash-loops under `restart: unless-stopped` — loud in the log, invisible in a glance at the stack list. The container log is the signal, not the deploy output. The compose header carried two claims that #190 falsified and did not correct: that the PayPal secrets are required "because DEMO_MODE is false below", and that only secrets are interpolated. Both now describe the file as it is. This is the drift `composeEnvironment.test.ts` exists to prevent, surfacing in the one place no test can reach — the guard keeps the compose file honest about its own intent and cannot see the runbook beside it. Verified: 278 backend unit tests pass, including the compose guard that parses this file, and a sweep for the stale claims finds none left. Closes #196 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1a59edec18 |
docs(ops): prove the backups during the cutover rather than waiting for a schedule (#192)
Step 4b created the directories and step 7 checked the containers were Up. Neither established that a restorable file actually gets written, and those are not the same claim — the database backup does not run until 03:00 and the uploads archive not until Sunday 04:00, so a stack that looks correct at the end of a cutover can be four days from its first evidence. Both tools take a manual trigger, so the wait is unnecessary. The runbook now forces one run of each, checks the sizes are plausible, and greps the dump for `COPY` lines on the real tables — a dump of an empty database succeeds and looks fine, which is the one way this check could otherwise lie. It also confirms the healthchecks agree with where the files landed. A check whose `find` path disagrees with where the tool actually writes reports unhealthy forever while the backups are working perfectly, and that is a thing to discover on the day the stack is built rather than a year later. `starting` corrected to `unhealthy` in the surrounding text: during `start_period` Docker reports `starting`, which is what an operator actually sees and what the previous wording got wrong. Proven against production during the cutover on 2026-08-26 — a 41K dump and a 15M archive, both landing where the healthchecks look. |
||
|
|
724e9ce19d |
docs(ops): say that the backup directories have to be created (#192)
The stack gained two backup services in #147 and nothing has ever told anyone to create the directories they mount. `backup-and-restore.md` reads from both paths and the compose file mounts both, but no document creates them — while the README does exactly that for QA's data directories, ownership notes and all. Production's cutover runbook said nothing. Hit for real during the cutover: both backup containers sat in Created, never started, and `docker logs` on them reported only that nothing matched the filter, because a container that never ran has no output. Portainer showed them beside the healthy ones and the stack looked deployed. That silence is the reason this is worth a step of its own rather than a footnote. A backup regime that never started is indistinguishable from a working one until someone needs a restore, which is the failure mode the healthchecks in #147 exist to catch — and those healthchecks cannot fire on a container that is not running. The cutover runbook gains the directory creation before the stack is created, and step 7 now counts containers rather than only checking the app: four, all Up, with Created called out as the thing to look for. Counted from the compose file rather than from memory — the first draft said five. `backup-and-restore.md` gains the same note where it describes the destinations, since anyone reading that page is already thinking about paths. No `chown`, deliberately stated: both backup images run as root, unlike the Postgres image whose data directory needs uid 999, and an unnecessary chown instruction is how people learn to run them without thinking. |
||
|
|
0cec9b3c1f |
docs(ops): name the crash loop the cutover runbook was most likely to cause (#175)
Step 7 listed a missing `ADMIN_GATE_SECRET` — a warning the container starts through — and said nothing about the three PayPal secrets, which are a hard error that crash-loops it. That is backwards: the secrets are the likeliest thing to be missing after a stack replacement, because Portainer stack variables belong to the stack and are discarded with it, and `DEMO_MODE` is false in production so the app refuses to start without them. Hit for real following the runbook. The verify section now shows the actual log block, says what it means, and gives the way to tell a missing variable from a misnamed one: a hardcoded value cannot be missing, so its absence from the error list proves nothing, while an interpolated variable that stays quiet while others complain proves substitution works and those others are simply unset. That is the reading that turns the log into a diagnosis instead of a list. It also says the loop is harmless while the values are fetched — the container refuses before it serves anything or touches data — and where the values live if the old stack is already gone, since the webhook id in particular is readable rather than only recreatable. Step 2 now names every interpolated variable rather than describing them in general, and states the consequence of each class going missing. A general instruction to record the environment is easy to read as already done. |
||
|
|
60b76cae82 |
docs(plan): correct the cumulative unit test counts (#188)
Task 1 leaves 4 tests and Task 2 adds 7, so the running total is 11 rather than 12, and the same off-by-one carried into Tasks 3, 4 and the completion criteria. Caught by the Task 2 implementer, which flagged the mismatch rather than inventing a test to reach the stated number. |
||
|
|
e695d91670 |
docs(plan): implementation plan for filter dimensions (#188)
Nine tasks, each ending in something independently testable, in an order where every task leaves both screens working. The dimensions are built and unit-tested first, the shell after them, and the two screens are wired last — so the old components stay in place until the thing replacing them is proven. The two behaviour changes the design accepted are each covered twice: a unit test on the chips that produce them, and an end-to-end assertion on what a person sees. The admin tally reading three for three statuses, and a non-default availability producing a removable chip. Task 8 carries the deletions, deliberately last. Removing `activeFilterCount` and `hasActiveFilters` turns every remaining caller into a compile error, which is the cheapest way to find them. Two defects found reviewing the plan against the code rather than against itself: the test fixture omitted `Category.item_count` and would not have compiled, and Task 9 added a page object method that nothing used — `chooseAvailability` and `filterChip` already exist. The plan also records what must not be read as a regression. Two assertions in the storefront specs fail on every branch because the unpaginated grid cannot render 1,600+ development rows inside Playwright's default timeout, which is #186 and predates this work. Refs #188 |
||
|
|
20a38b66bd |
docs(design): filter dimensions, one composable component both screens extend (#188)
#169 made the filter drawer shared, which was the right first move and not the finish. Per-screen differences are booleans, the bar around the drawer was never shared at all, and the storefront's availability preset sits outside the system because the shared component cannot express "this belongs in the bar, not the drawer". The design replaces the flags with composition: a screen contributes a list of filter dimensions, each declaring where it renders, how to render it, and what chips it contributes. A screen-specific control becomes an ordinary dimension, appearing in the chip row and counting toward the tally without the shared code knowing what it is. Dimensions are plain data rather than components or context, for a concrete reason rather than a stylistic one. The drawer sets destroyOnHidden, so its sections are unmounted whenever it is closed — exactly when the chip row matters most. Anything that registers on mount would lose those chips the moment the drawer closed, which rules out the otherwise-idiomatic context-and-children approach. The tally becomes the number of chips, so the count and the chip row cannot disagree — today they are computed by two routes and agree by coincidence, which the admin already has to correct by hand. Three visible behaviours change as a result, recorded in the spec rather than left to be discovered. Adding vitest is in scope. The design's value rests on chips() being pure, and the frontend has no unit runner at all, so without one the core of it would ship covered only indirectly and expensively through Playwright. The spec also records what is deliberately untouched: the filter state, the URL serialisation, the backend, and the two e2e assertions already failing on #186 — which must not be read as regressions from this work. Refs #188 |
||
|
|
cf9003bc2d |
docs(ops): write down how to cut production over to the committed compose file (#175)
#118 put `docker-compose.prod.yml` in the repository and argued at length for why it belongs there. It never produced the procedure for getting production from where it is — a stack living in Portainer's web editor — to where that file expects it to be. What existed was scattered and none of it was a procedure. The compose header described the destination: name the stack this, deploy it as a git repository stack with these settings, rename any variables whose names differ. The README's deployment section is a good runbook for the routine redeploy, and assumes the stack is already in the right form. So the riskiest deployment operation this project has was the one with no steps written down. `docs/ops/production-stack-cutover.md` is those steps, in the order that matters, with the two facts that decide whether the operation is safe stated up front rather than left to be inferred from the volumes block. The first is that all persistent data is bind-mounted from the NAS filesystem rather than held in Docker-managed volumes, so deleting the stack cannot lose the database or the product images. That is what makes the cutover recoverable at all, and step 2 verifies it rather than trusting it — the `docker inspect` there discriminates `bind` from `volume` and says to stop on `volume`, because this runbook does not cover that case. The second is that both services set an explicit `container_name`, so the old containers must be gone before the new stack starts or the deploy fails on a collision that reads like a Portainer bug rather than a sequencing mistake. It also captures what is simply lost if not recorded first. Portainer stack variables belong to the stack and are discarded with it; they are all secrets, and a stack brought back up with a different `DB_PASSWORD` than the data directory was initialised with cannot authenticate against its own database. Recording them is step 2 and it is what makes the rollback credible. The verification section separates the boot warnings that are correct in production — no mail allowlist, no uploads origin yet — from the one that means a variable never arrived. That distinction is the whole failure mode #118 was about: a stack variable whose name matches nothing in the file is substituted nowhere and never reaches the container, and the failure reads as "I set it and it says it is not set". Every diagnostic command in it was run rather than written from memory. The mount inspection was checked against a container that genuinely uses a named volume, to confirm it reports the difference the step depends on. The compose header now points at the runbook for the procedure and keeps the rationale that belongs at the point of use — why there is no `build:`, why most values are hardcoded, why `NODE_ENV` is absent. The README says plainly that its section is the routine deploy and links to the other one. Closes #175 |
||
|
|
ae20568601 |
docs(ci): correct the #154 hypothesis — the database is wiped before the tests start (#154)
dmesg came back empty, and the evidence that actually settles it was in the original log the whole time. Step 7 `Run migrations` succeeded, then step 9's globalSetup applied all six migrations again from scratch. Both point at the same database — migrate.js reads PGHOST/PGDATABASE and the job sets those and TEST_PG* to the same service and the same redefined_test — so had step 7 migrated it, globalSetup would have printed "No migrations to run!", which is what a local run prints. It found an empty database. The wipe was already happening before the tests started, which makes this a database being reset repeatedly rather than a container dying partway through a heavy run, and accounts cleanly for the empty dmesg: a restart is not a kill. The suspect moves from memory pressure to the runner's handling of `services:`, where act_runner has been uneven across releases. The diagnostics change with it: A3 is dropped because it was designed to catch starvation, A2 is demoted to a fallback, and the new first check needs nothing but the Gitea UI — compare step 7 and step 9's migration output in any failing run. The superseded hypothesis is kept rather than deleted. A future reader finding memory ruled out is better served by seeing why it was suspected and what refuted it than by a document that never mentions it. Refs #154 |
||
|
|
ebefcbb76b |
feat(ops): schedule database and uploads backups, and document the restore (#147)
The only copy of every customer, order and one-of-a-kind item was the live Postgres data directory, plus whatever the deploy checklist's manual pg_dump happened to have caught. That dump is good and stays, but it only runs when someone deploys: a quiet week meant the newest copy of real customer data was a week old, and nothing bounded the gap. Two services rather than one, because they are different jobs. The database is small, changes constantly and wants a logical dump — daily, gzipped, 7/4/6 daily-weekly-monthly retention. Uploads are large and append-mostly and want an archive — weekly, 56 days, mounted read-only so a backup process cannot damage the thing it is backing up. Forcing both through one tool would serve one of them badly. The dumper is pinned to postgres-backup-local:16 to match the server. pg_dump refuses to dump a server newer than itself, so a floating tag is a backup that stops working the day Postgres is upgraded — silently, because nothing reads a dump until it is needed. It depends_on the database's existing pg_isready healthcheck, which is the constraint that shaped this: a dumper is a client, and without that the first run after a NAS reboot races Postgres coming up. Both carry a staleness healthcheck rather than trusting the schedule. A regime that stopped a month ago is indistinguishable from a working one until a restore is attempted, and `find -mmin` is the cheapest thing that tells them apart. It surfaces in Portainer beside the app rather than somewhere separate to remember to look. Windows are the interval plus grace — 26 hours daily, 9 days weekly — so a late run is not a failure, and start_period covers the first cycle when nothing has been written yet. Verified rather than assumed. Both images were pulled and checked to have a shell and `find`, since a CMD-SHELL healthcheck against an image without one reports unhealthy forever. postgres-backup-local:16 ships pg_dump 16.10 against the postgres:16 server. The healthcheck expression was exercised three ways in the image itself — empty directory, fresh artifact, and one aged three days — and returns unhealthy, healthy, unhealthy. The compose file parses and the #118 drift guard still passes over it. Three things these deliberately do not cover, written into the compose file and the doc rather than left to be discovered: They run while the stack runs, so they cannot protect the stack's own teardown. Deleting the Portainer stack deletes them too. That is why the deploy checklist's manual dump stays, and README now says so where the checklist is. They write to the same volume as the data they protect. That survives a bad migration, a dropped table, a bad deploy and a stack deletion, and not the disk. Getting a copy off /volume1 is a Synology-side job and is what turns this from a convenience into a guarantee. Daily database against weekly uploads leaves a window where a restore pairs the two from different moments. An orphaned image is harmless; a row without its image is a broken thumbnail on one recent item, usually still on the admin's machine. Neither is data loss, and a synchronised snapshot is not worth the complexity to avoid it. The restore procedure leads with practising on a throwaway database, because an untested backup is a file of unknown validity and a truncated dump looks exactly like a good one until it matters. It checks row counts and the pgmigrations head — the migration check being the one most easily skipped and most likely to bite, since a dump older than the code restores a schema the app will fail against. Both open items are listed as unticked in the doc: no off-volume copy exists yet, and no restore has been performed. Until the second is done this documents an untested procedure, and it says so. Refs #147 |
||
|
|
949734d1e1 |
docs(ci): add the working document for the #154 schema-loss investigation (#154)
The diagnosis so far, what has been ruled out, and the three read-only checks that would confirm or refute it — with slots to paste the output into and a note against each saying what the answer means. Written as a working document rather than a summary because the decisive check has a timing constraint that is easy to miss: the Postgres service container is deleted when the job finishes, so watching it has to happen during the run. Discovering that after the fact costs another full run. It also records the negative result deliberately. If the container is healthy throughout, the hypothesis is wrong and the document says which suspect is next, rather than leaving an abandoned theory for the next reader to re-derive. Refs #154 |
||
|
|
d65eb7b981 |
docs: design for moving Order History onto its own page (#121)
Records why /orders is a page rather than another modal route. The app has both precedents: /account, /login and /register are in MODAL_ROUTES and render over a backdrop, while /cart and /privacy are ordinary pages. Order history is closer to the cart, a list you read rather than a dialog you dismiss. A modal at /account/orders was the smaller change and was rejected: it inherits the same 700px width and 70vh scroll cap, so it moves the table without giving it anything. Tabs inside the modal were rejected for the same reason, since they fix the scrolling and leave the cramping. The doc also records the one part of this that is a fix rather than a move. A failed load currently shows a toast and leaves the table empty, and the toast goes away while the empty table does not, so a customer whose request failed sees exactly what a customer with no orders sees. Loading, failed and empty become three distinct states, with a retry on the failed one. Per-order detail and server-side paging are written down as deliberately out of scope, so that leaving them out reads as a decision rather than an oversight. Refs #121 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
71cbd142c3 |
fix: address the final review of the error boundaries (#62)
Five findings from a whole-branch review, two of which mattered. The catalogue boundary could not be recovered by the very controls it exists to keep alive. When the item grid threw, the header, filter chips and Clear filters stayed mounted — that was the point — but clicking Clear filters only changed the URL. A boundary does not reset on a client-side state change, so the fallback went on rendering over a catalogue that would by then have loaded perfectly well, and the only offered action reloaded the same failing URL. The shop read as permanently broken, which is the failure this whole change exists to prevent, reintroduced one level down. ErrorBoundary now takes an optional resetKey and clears itself when it changes; the catalogue boundary passes the filter key. The page and modal boundaries deliberately do not take one, because their escapes are hard navigations that remount the tree already — recorded on the prop so nobody completes the pattern by symmetry. The client-error endpoint could fill the disk. It is unauthenticated, each accepted report wrote about 8.7 KB, and Docker's default json-file driver has no size cap — so the rate limiter bounded a render loop, as its comment claims, but not a few hundred cheap source addresses. Stack and component stack now truncate at 1000 rather than 4000, which is still around fifteen frames and cuts the worst case to under 3 KB, and the QA compose file caps and rotates the log. Production is a Portainer stack outside this repository and needs the same option applied there; noted in the design doc rather than left implied. Three smaller things. A falsy thrown value defeated the boundary entirely: throw null is legal, and branching on the error object alone treated it as no error, re-rendered the children, threw again, and would have taken the root down — a blank page, the one outcome this is all here to avoid. The boundary now tracks hasError separately and synthesises a real Error for non-Error throws. The modal fallback announced its title twice to a screen reader, once as the dialog's name and once as the heading inside it, so the redundant dialog title is gone. And the design doc claimed the development-only detail shows the component stack when it only ever showed the message; corrected, with a note that the stack still reaches the server log, which is where it is useful. Verified after all five: backend lint 0 errors, 144 integration tests, frontend lint 0 errors and 31 warnings, 87 end-to-end tests, all against a freshly created database. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
557703f86d |
docs: mark the error-boundary design implemented (#62)
Records the two things the design got wrong. antd's Result renders its title as a plain div, so the design's Result usage and its getByRole('heading') assertions contradicted each other and the tests could never have passed as written — resolved by giving the title real heading semantics rather than by loosening the assertion, because an error page with no heading leaves a screen-reader user navigating by headings nothing to find. And import.meta.env had no ambient declaration anywhere in the app, so the DEV gate did not type-check until vite-env.d.ts was added.
The Vite error overlay risk the design flagged did not materialise.
Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
94e2267eaf |
docs: implementation plan for React error boundaries (#62)
Four tasks, each ending in an independently testable deliverable: the backend endpoint with its own rate limiter, the boundary and fallback components, the three mount points with end-to-end coverage, and the production-gate verification. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
683139b8d8 |
docs: correct the error-boundary design's fallback actions (#62)
Found while working the design into a plan: the obvious implementation of the fallback's escape actions is wrong, and the spec was recommending it. A React error boundary does not reset when the route changes. The original spec justified placing the root boundary inside BrowserRouter on the grounds that the fallback needed router context to offer a way back — but a fallback offering a Link would change the URL and go on rendering the fallback, which reads as the app being permanently broken rather than recovering. Every escape action is therefore a hard navigation: reload, or setting window.location.href. The placement is unchanged, but it is now justified by what the boundary guards rather than by reasoning that does not hold. Three consequences recorded while there. The three fallbacks get distinct titles rather than one shared string, so a customer learns which part failed and the tests get an unambiguous locator for which boundary caught. The modal throw trigger mounts as an unconditional sibling inside its boundary, so /?boom=modal exercises it with the storefront behind rather than depending on /account resolving a session first. And a fourth end-to-end test asserts the report actually reaches /api/client-errors by observing the request, rather than trusting the reporter was called. Also recorded: new files use antd/es deep imports, this project's documented convention — not antd/lib, which #65 notes loads a second React context and breaks ConfigProvider. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e8374e391a |
docs: design for React error boundaries (#62)
Settles the four questions #62 left open, and records why each alternative lost. Three mount points rather than one: root, the catalogue, and the modal block. The catalogue boundary is the one that earns its keep, because the likeliest throw in this app is a component rendering API data and the item grid renders the most of it per page — containing it there keeps the header, cart badge and filters alive instead of handing the customer one dead page. The modal boundary exists because the modal-route arrangement couples two independent trees: without a boundary between them a throw in Account blanks the storefront behind it, and a throw in the storefront takes the open modal with it. Errors get reported to a new POST /api/client-errors that logs and returns 204, with no storage. A boundary that only shows a message leaves nobody knowing it happened, which is the exact failure shape this project has designed against three times already. A persisted store with an admin screen was rejected as a subsystem larger than the rest of the issue. Rate limiting needs its own limiter rather than the existing one. rateLimit.ts already documents that passwordResetRequestLimiter is keyed on caller and email, and that reusing it where there is no email collapses every caller into one shared bucket — so this endpoint gets a separate limiter keyed on req.ip, which is the real client address because trust proxy is already set. Recorded as rejected: an outermost boundary around the providers, which would sit outside ConfigProvider and need a second hand-styled fallback for a case that is remote — their render bodies are state and JSX with no data mapping. Flagged for revisiting if that stops being true. Also recorded: the rate limiter is deliberately not asserted in the integration suite, because its store is process-wide and a test that exhausts the allowance leaks into every later test keyed on the same address. Refs #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |