feat(intake): shared upload links, AI-drafted listings, and an admin review queue #220

Closed
opened 2026-08-29 07:21:19 -05:00 by bermudalamb · 4 comments
Owner

Someone who is not the admin should be able to send in photos of an item through a link, have a draft listing written for it automatically, and have the admin told about it by email — with the item sitting unpublished until the admin has read the copy, set a price and published it deliberately.

The shape

A named, revocable upload link points at a public page. Whoever holds it uploads the photos of one item plus a free-text note saying whatever they know about it — materials, dimensions, condition, where it came from. That submission immediately becomes an items row at status='pending', which is already invisible to every public and storefront query, so nothing is ever live by accident.

A background worker then asks Claude to read the photos and the note and draft a name, a marketing description, a category and tags from the existing taxonomy, and a suggested price. The admin gets an email with the photos and the draft, carrying one-click links to regenerate or discard it, and a link into a review queue in the admin where the copy is edited, a real price is typed, and the item is published through the existing mark-available endpoint.

What already exists and is being reused rather than rebuilt

  • items.status = 'pending' is already the unpublished state, already excluded from public queries by NON_PUBLIC_STATUSES, and publish/unpublish are already admin endpoints.
  • Image upload, magic-byte validation, the three-type allowlist and the hardened uploads serving path.
  • nodemailer, the admin-editable markdown email templates with placeholder validation, and the MAIL_ALLOWLIST guard.
  • node-cron as the established background-work pattern, and express-rate-limit for limiting.

What is new

  • An upload_links table with hashed tokens, per-link revoke and upload counters, so a leaked link is killed on its own rather than by disabling the feature, and every item records which link it came from.
  • A public, unauthenticated submission endpoint — rate limited, with photo count and size caps.
  • The first LLM integration in this codebase: @anthropic-ai/sdk, a vision request with structured output, run in a background worker rather than in the upload request, behind a spend ceiling.
  • An item_drafts table holding the AI output, the submitter's note, the provenance and the review state, kept separate from the item's real fields so nothing a model produced is ever mistaken for something the admin approved.
  • An admin review queue screen.

The invariant this feature must not break

Nothing publishes at a price a model guessed. items.price_cents becomes nullable so "no price yet" is expressible honestly instead of being encoded as zero, and the publish path refuses an item with no price. The AI's suggested price is stored on item_drafts, never on the item, and reaches the storefront only by the admin typing it.

Sub-issues

To be filed with the implementation plan. The intended split, each independently shippable in this order:

  1. Upload links and the public submission page — useful on its own, with no AI at all.
  2. The AI drafting worker.
  3. The review notification email and its signed action links.
  4. The admin review queue.

Design doc to follow on a branch off this issue.

Someone who is not the admin should be able to send in photos of an item through a link, have a draft listing written for it automatically, and have the admin told about it by email — with the item sitting unpublished until the admin has read the copy, set a price and published it deliberately. ## The shape A named, revocable upload link points at a public page. Whoever holds it uploads the photos of one item plus a free-text note saying whatever they know about it — materials, dimensions, condition, where it came from. That submission immediately becomes an `items` row at `status='pending'`, which is already invisible to every public and storefront query, so nothing is ever live by accident. A background worker then asks Claude to read the photos and the note and draft a name, a marketing description, a category and tags from the existing taxonomy, and a suggested price. The admin gets an email with the photos and the draft, carrying one-click links to regenerate or discard it, and a link into a review queue in the admin where the copy is edited, a real price is typed, and the item is published through the existing `mark-available` endpoint. ## What already exists and is being reused rather than rebuilt - `items.status = 'pending'` is already the unpublished state, already excluded from public queries by `NON_PUBLIC_STATUSES`, and publish/unpublish are already admin endpoints. - Image upload, magic-byte validation, the three-type allowlist and the hardened uploads serving path. - nodemailer, the admin-editable markdown email templates with placeholder validation, and the `MAIL_ALLOWLIST` guard. - `node-cron` as the established background-work pattern, and `express-rate-limit` for limiting. ## What is new - An `upload_links` table with hashed tokens, per-link revoke and upload counters, so a leaked link is killed on its own rather than by disabling the feature, and every item records which link it came from. - A public, unauthenticated submission endpoint — rate limited, with photo count and size caps. - The first LLM integration in this codebase: `@anthropic-ai/sdk`, a vision request with structured output, run in a background worker rather than in the upload request, behind a spend ceiling. - An `item_drafts` table holding the AI output, the submitter's note, the provenance and the review state, kept separate from the item's real fields so nothing a model produced is ever mistaken for something the admin approved. - An admin review queue screen. ## The invariant this feature must not break Nothing publishes at a price a model guessed. `items.price_cents` becomes nullable so "no price yet" is expressible honestly instead of being encoded as zero, and the publish path refuses an item with no price. The AI's suggested price is stored on `item_drafts`, never on the item, and reaches the storefront only by the admin typing it. ## Sub-issues To be filed with the implementation plan. The intended split, each independently shippable in this order: 1. Upload links and the public submission page — useful on its own, with no AI at all. 2. The AI drafting worker. 3. The review notification email and its signed action links. 4. The admin review queue. Design doc to follow on a branch off this issue.
Author
Owner

Clarifying questions and answers

Settled in conversation before the design was written.

Q: The "shared repo where photos can be uploaded, in a folder per item" — what is it physically?
A: A web upload page. Not a watched NAS share, not an email-in mailbox. This drops the filesystem watcher entirely and reuses the existing multer upload path and its magic-byte validation.

Q: Who uses that page and how do they get in?
A: Anyone with a shared secret link. No accounts, no authentik gate on the upload side.

Q: How does the admin alter the draft, approve it and set the price?
A: Both signed one-click action links in the email and a dedicated review queue in the admin. The links handle the fast path without a login; the queue is where copy is edited and the price is typed.

Q: What should the AI produce from the photos?
A: All four — marketing description, item name, category and tags from the existing taxonomy, and a suggested price.

Q: How should the shared upload link work?
A: Named, revocable links issued from the admin, one per person or purpose, each with its own token, upload counter and revoke button, and provenance recorded on every item that arrives through it. A leak is contained by killing one link rather than the feature.

Q: What does the uploader type alongside the photos?
A: Photos plus one free-text note. This matters more than it looks: a model cannot see that a vase is hand-thrown stoneware or 1930s, and without those facts it will invent them confidently into storefront copy.

Q: Where does a submission live before approval?
A: Item-first, at status='pending', rather than a separate submissions table. Reuses item_images, the uploads serving path, the admin editor and the existing publish/unpublish endpoints, at the cost of making price_cents nullable and guarding the publish path.

Q: What do the one-click email links do?
A: Regenerate, discard, and a deep link into the review queue. Deliberately no one-click publish — the only price available at that moment is the model's guess, so a one-click publish would be publishing at a machine-guessed number.

Q: Which model?
A: Start on a cheaper one than the default. Specified as Sonnet 5 rather than Haiku 4.5, in an env var: reading fine detail in a photo of a one-of-a-kind object and holding a structured-output contract is exactly where Haiku weakens, and a poor draft is paid for in review time. Roughly a third of Opus 5's per-item cost, and switching either way is a config change.

Noted concerns

The upload endpoint is unauthenticated, accepts files, and triggers paid API calls and outbound mail. A leaked link otherwise means burned API credit and a filled disk with nobody logged in. Mitigations carried into the design: hashed tokens, per-link revoke and counters, photo count and size caps, rate limiting, and a monthly spend ceiling past which submissions still save but stop calling the API.

## Clarifying questions and answers Settled in conversation before the design was written. **Q: The "shared repo where photos can be uploaded, in a folder per item" — what is it physically?** A: A web upload page. Not a watched NAS share, not an email-in mailbox. This drops the filesystem watcher entirely and reuses the existing multer upload path and its magic-byte validation. **Q: Who uses that page and how do they get in?** A: Anyone with a shared secret link. No accounts, no authentik gate on the upload side. **Q: How does the admin alter the draft, approve it and set the price?** A: Both signed one-click action links in the email and a dedicated review queue in the admin. The links handle the fast path without a login; the queue is where copy is edited and the price is typed. **Q: What should the AI produce from the photos?** A: All four — marketing description, item name, category and tags from the existing taxonomy, and a suggested price. **Q: How should the shared upload link work?** A: Named, revocable links issued from the admin, one per person or purpose, each with its own token, upload counter and revoke button, and provenance recorded on every item that arrives through it. A leak is contained by killing one link rather than the feature. **Q: What does the uploader type alongside the photos?** A: Photos plus one free-text note. This matters more than it looks: a model cannot see that a vase is hand-thrown stoneware or 1930s, and without those facts it will invent them confidently into storefront copy. **Q: Where does a submission live before approval?** A: Item-first, at `status='pending'`, rather than a separate submissions table. Reuses `item_images`, the uploads serving path, the admin editor and the existing publish/unpublish endpoints, at the cost of making `price_cents` nullable and guarding the publish path. **Q: What do the one-click email links do?** A: Regenerate, discard, and a deep link into the review queue. Deliberately no one-click publish — the only price available at that moment is the model's guess, so a one-click publish would be publishing at a machine-guessed number. **Q: Which model?** A: Start on a cheaper one than the default. Specified as Sonnet 5 rather than Haiku 4.5, in an env var: reading fine detail in a photo of a one-of-a-kind object and holding a structured-output contract is exactly where Haiku weakens, and a poor draft is paid for in review time. Roughly a third of Opus 5's per-item cost, and switching either way is a config change. ## Noted concerns The upload endpoint is unauthenticated, accepts files, and triggers paid API calls and outbound mail. A leaked link otherwise means burned API credit and a filled disk with nobody logged in. Mitigations carried into the design: hashed tokens, per-link revoke and counters, photo count and size caps, rate limiting, and a monthly spend ceiling past which submissions still save but stop calling the API.
Author
Owner

Sub-issues filed

  • #222 — issue named upload links and accept photo submissions (slice 1, useful alone with no AI or email)
  • #223 — draft a listing from the submitted photos (slice 2)
  • #224 — notify the admin with signed action links (slice 3)
  • #225 — review queue for drafted items (slice 4)

#223 depends on #222, #224 on #223, and #225 on #223 but not on #224.

Implementation plan

docs/superpowers/plans/2026-08-29-intake-upload-links.md covers slice 1 only, in seven tasks each carrying its own test cycle. Slices 2 to 4 are deliberately unplanned: their detail depends on what slice 1 actually produces, and a plan written now would be partly invented.

Three things the plan settles that the design left open or got wrong

Image caps become the constants already in the codebaseMAX_IMAGES_PER_REQUEST = 6 and MAX_IMAGE_BYTES = 8_000_000 — rather than the 10 photos and 10 MB the design proposed. Those figures were invented during design and flagged as such; the codebase already defines caps for this exact pipeline, and two sets on one path is a defect waiting to happen.

The feature flag is dropped from slice 1. The design had intake flagged off by default, but nothing is reachable until a link exists and an absent, revoked or exhausted token 404s, so the flag protects nothing here. It earns its keep in #223, where a paid API call appears.

The link is resolved before multer runs. This one is a gap in the design rather than a simplification of it: as written, a stranger holding a bad token would still cause bytes to be written to the uploads volume and then deleted by the existing cleanup. "Written then deleted" is materially worse than "never written" on an endpoint the whole internet can reach — it is disk churn an unauthenticated caller controls, and it leans on an unlink that a crash between the write and the delete would skip. The middleware ordering is the whole mitigation, so it is asserted by a test that fails if anyone reorders it.

The plan also folds in a refactor the design implied but did not name: the validated image pipeline moves out of routes/admin.ts into a shared module. Every property that makes uploads safe there is one a second implementation would have to reproduce exactly, and a near-copy that drifted is precisely the gap #95 and #103 exist to close.

## Sub-issues filed - #222 — issue named upload links and accept photo submissions (slice 1, useful alone with no AI or email) - #223 — draft a listing from the submitted photos (slice 2) - #224 — notify the admin with signed action links (slice 3) - #225 — review queue for drafted items (slice 4) #223 depends on #222, #224 on #223, and #225 on #223 but not on #224. ## Implementation plan `docs/superpowers/plans/2026-08-29-intake-upload-links.md` covers slice 1 only, in seven tasks each carrying its own test cycle. Slices 2 to 4 are deliberately unplanned: their detail depends on what slice 1 actually produces, and a plan written now would be partly invented. ## Three things the plan settles that the design left open or got wrong **Image caps become the constants already in the codebase** — `MAX_IMAGES_PER_REQUEST = 6` and `MAX_IMAGE_BYTES = 8_000_000` — rather than the 10 photos and 10 MB the design proposed. Those figures were invented during design and flagged as such; the codebase already defines caps for this exact pipeline, and two sets on one path is a defect waiting to happen. **The feature flag is dropped from slice 1.** The design had intake flagged off by default, but nothing is reachable until a link exists and an absent, revoked or exhausted token 404s, so the flag protects nothing here. It earns its keep in #223, where a paid API call appears. **The link is resolved before multer runs.** This one is a gap in the design rather than a simplification of it: as written, a stranger holding a bad token would still cause bytes to be written to the uploads volume and then deleted by the existing cleanup. "Written then deleted" is materially worse than "never written" on an endpoint the whole internet can reach — it is disk churn an unauthenticated caller controls, and it leans on an unlink that a crash between the write and the delete would skip. The middleware ordering is the whole mitigation, so it is asserted by a test that fails if anyone reorders it. The plan also folds in a refactor the design implied but did not name: the validated image pipeline moves out of `routes/admin.ts` into a shared module. Every property that makes uploads safe there is one a second implementation would have to reproduce exactly, and a near-copy that drifted is precisely the gap #95 and #103 exist to close.
Author
Owner

Approved sequence

Reviewed and approved 2026-08-29.

  1. #226 — re-encode uploads, strip EXIF, backfill existing photos. No dependency on this epic. It is first because it is live on production now: every stock photo on the storefront carries the coordinates it was taken at, and that is true whether or not intake is ever built.
  2. #222 — upload links and the public submission page. The foundation, and useful on its own with no AI and no email. Planned in full at docs/superpowers/plans/2026-08-29-intake-upload-links.md.
  3. #223 — the drafting worker.
  4. #225 — the review queue.
  5. #224 — the notification email and its signed action links.
  6. #227 — the global submission ceiling and abuse alert.

This epic closes when 2 to 6 are done. #226 stands on its own.

Why #225 comes before #224

Both depend on #223 and neither depends on the other, so the order is a choice. The queue is where an item is actually made publishable and where the price-provenance display lives — which is load-bearing now that items arrive pre-priced rather than unpriced. The email is a convenience on top of a queue that already works. A queue with no email is a working system; emails pointing at a queue that does not exist are not.

Why #227 comes last

The per-link cap and the volume guard folded into #222 bound the damage while links are few and known. A global ceiling earns its keep once links are genuinely out in the world, which is not true on day one.

Change made to #226 on approval

Its dependency on #222 was dropped — it was written in by mistake, and it had a live privacy fix waiting on a feature that had not started. Its scope also grew a second part: a backfill over the existing item_images, without which the current catalogue keeps its EXIF regardless of what new uploads do. The backfill is lossy and irreversible, so it carries a backup-first requirement and a QA-first run.

## Approved sequence Reviewed and approved 2026-08-29. 1. **#226** — re-encode uploads, strip EXIF, backfill existing photos. **No dependency on this epic.** It is first because it is live on production now: every stock photo on the storefront carries the coordinates it was taken at, and that is true whether or not intake is ever built. 2. **#222** — upload links and the public submission page. The foundation, and useful on its own with no AI and no email. Planned in full at `docs/superpowers/plans/2026-08-29-intake-upload-links.md`. 3. **#223** — the drafting worker. 4. **#225** — the review queue. 5. **#224** — the notification email and its signed action links. 6. **#227** — the global submission ceiling and abuse alert. This epic closes when 2 to 6 are done. #226 stands on its own. ## Why #225 comes before #224 Both depend on #223 and neither depends on the other, so the order is a choice. The queue is where an item is actually made publishable and where the price-provenance display lives — which is load-bearing now that items arrive pre-priced rather than unpriced. The email is a convenience on top of a queue that already works. A queue with no email is a working system; emails pointing at a queue that does not exist are not. ## Why #227 comes last The per-link cap and the volume guard folded into #222 bound the damage while links are few and known. A global ceiling earns its keep once links are genuinely out in the world, which is not true on day one. ## Change made to #226 on approval Its dependency on #222 was dropped — it was written in by mistake, and it had a live privacy fix waiting on a feature that had not started. Its scope also grew a second part: a backfill over the existing `item_images`, without which the current catalogue keeps its EXIF regardless of what new uploads do. The backfill is lossy and irreversible, so it carries a backup-first requirement and a QA-first run.
Author
Owner

All four slices are merged and deployed to QA.

  • #222 — named, revocable upload links, and a public submission page
  • #223 — the drafting worker: photos and the sender's note to Claude, a Zod-constrained answer back
  • #224 — the notification, with signed regenerate and discard links
  • #225 — the review queue, where publishing happens

The pipeline runs end to end: a link is issued, a stranger submits photos and a note, an item arrives pending and priced, the worker drafts it, the admin is emailed, and nothing reaches the storefront until a person publishes it from the queue.

The invariant this design was built around held. Nothing publishes except a deliberate act on a screen showing the price. The email carries no publish button, and a test asserts that — on the routes and on the template copy both. The two signable actions are exactly the ones whose worst case is a wasted API call or a recoverable hide.

The one thing that was traded is worth restating, because it is now live. Items are priced on arrival — the model's suggestion, or the 80.00 default — so the schema no longer prevents a price nobody chose from reaching the storefront. That protection moved into the review queue's presentation, where it is weaker. price_source is what makes it visible: editing the number is the only thing that confirms it, publishing an untouched field deliberately leaves it unconfirmed, and publishing at an unconfirmed price names the figure and its origin before it happens rather than after.

Still outstanding, and the reason this is closing rather than complete: nobody has yet confirmed a real Anthropic call produces a draft worth reading. #223's task 8 — one real photograph, judged for whether it claims a material, age, maker or provenance it could not know — has never been done. That is now easy to check, because the review queue renders the draft and the email announces it. It is tracked on #223 rather than here.

#227 (a global submission ceiling with an abuse alert) remains open as follow-on work rather than a slice.

Closes #220

All four slices are merged and deployed to QA. - **#222** — named, revocable upload links, and a public submission page - **#223** — the drafting worker: photos and the sender's note to Claude, a Zod-constrained answer back - **#224** — the notification, with signed regenerate and discard links - **#225** — the review queue, where publishing happens The pipeline runs end to end: a link is issued, a stranger submits photos and a note, an item arrives `pending` and priced, the worker drafts it, the admin is emailed, and nothing reaches the storefront until a person publishes it from the queue. **The invariant this design was built around held.** Nothing publishes except a deliberate act on a screen showing the price. The email carries no publish button, and a test asserts that — on the routes and on the template copy both. The two signable actions are exactly the ones whose worst case is a wasted API call or a recoverable hide. **The one thing that was traded is worth restating, because it is now live.** Items are priced on arrival — the model's suggestion, or the 80.00 default — so the schema no longer prevents a price nobody chose from reaching the storefront. That protection moved into the review queue's presentation, where it is weaker. `price_source` is what makes it visible: editing the number is the only thing that confirms it, publishing an untouched field deliberately leaves it unconfirmed, and publishing at an unconfirmed price names the figure and its origin before it happens rather than after. **Still outstanding, and the reason this is closing rather than complete:** nobody has yet confirmed a real Anthropic call produces a draft worth reading. #223's task 8 — one real photograph, judged for whether it claims a material, age, maker or provenance it could not know — has never been done. That is now easy to check, because the review queue renders the draft and the email announces it. It is tracked on #223 rather than here. #227 (a global submission ceiling with an abuse alert) remains open as follow-on work rather than a slice. Closes #220
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#220