Skip to content

feat(tags): let people suggest a tag, and admins review suggestions - #648

Draft
alukach wants to merge 1 commit into
feat/product-tagsfrom
feat/tag-suggestions
Draft

alukach wants to merge 1 commit into
feat/product-tagsfrom
feat/tag-suggestions

Conversation

@alukach

@alukach alukach commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

People can suggest a tag the list doesn't have, straight from the product form's tag picker, and admins review suggestions on a new admin page. Stacked on #647.

Suggesting a tag from the picker

A suggested tag on the product, pending review

The admin review queue

How it works

  1. Suggest. When the filter text isn't an existing tag, the popover offers Suggest "sea ice" as a new tag. The offer also appears when other tags only partly match, so someone after ocean acidification isn't stuck with ocean.
  2. It goes on the product straight away. suggestTag stores the tag in sc-{stage}-tags as a row with pending: true, suggested_by and suggested_at. The chip is amber and labelled pending, and saving the product accepts it, because parseTags now treats pending tags as known.
  3. An admin reviews it at /admin/tags, which lists each pending tag, who suggested it, and the products carrying it. Each decision is a plain form post to reviewTag:
    • Approve removes the pending flag, so the tag joins the list everyone picks from.
    • Merge into an approved tag replaces it on every product carrying it (without creating duplicates) and deletes the pending row.
    • Reject takes it off every product carrying it and deletes the pending row.

Guardrails

  • Suggestions are trimmed, lowercased and collapsed to single spaces, then must be at most 40 letters, digits, spaces or hyphens, starting and ending with a letter or digit.
  • Each account can have at most 5 pending suggestions.
  • Suggesting a tag that already exists, approved or pending, just returns it. Writes are conditional, so two people suggesting the same tag at once can't overwrite each other.
  • Only logged-in users can suggest. Only admins can review, which reviewTag checks itself rather than relying on the /admin layout.

Decisions worth a second look

  • Pending tags are public right away. They show on product cards and work in the ?tags= search filter before review. Hiding them would mean an extra read on every public page, and a pending tag is text the product's owner wrote, like its title and description, so it adds no new way to abuse the site.
  • One table, not a separate suggestions table. Approving a tag means the app writes to the tag list anyway, so a separate table wouldn't keep the list read-only. The Vercel role's grant on sc-{stage}-tags becomes read-write.
  • No notification. Admins find suggestions by visiting /admin/tags. Add a pending count to the admin nav, or a Slack message, if suggestions start sitting unreviewed.
  • A rejection doesn't tell the suggester why. The tag just disappears from their product. A rejection note could follow if people ask.
  • Merge and reject scan the products table once per decision, and the admin page scans it once per pending tag (marked with a ponytail: comment). That's fine while the queue is short and there are a few hundred products.
  • Running clean-tags.ts after this ships would drop pending tags from products, because they aren't in its corpus. It's meant to run once, at rollout, before any suggestions exist.

Stories

On this branch's Storybook deploy:

.storybook/preview.tsx mocks the new src/lib/actions/tags.ts module.

Docs

  • docs/using-source/create-a-data-product.md told people to email hello@source.coop for a missing tag. Updated in docs.source.coop#40, stacked on Repository settings #39.
  • public/llms.txt doesn't mention tags, and this adds no public endpoint. Tags have no bearing on the data-proxy ADRs (authorization, credentials, federation).

Testing

  • The full jest suite passes locally (885 tests), as does npm run type-check. npm run lint reports nothing in the changed files.
  • New src/lib/actions/tags.test.ts covers suggestTag normalizing and storing a tag, returning an existing tag unchanged, refusing logged-out visitors, refusing malformed tags, and enforcing the per-person cap. It covers reviewTag refusing non-admins, approve, merge (including deduplication and refusing a target that isn't approved), and reject.
  • products.test.ts adds a case where a product saves with a pending tag.
  • TagPicker.test.tsx adds suggesting a tag (it is added and marked pending), a refused suggestion showing its reason, and an existing tag not being offered for suggestion.

🤖 Generated with Claude Code

When no tag fits, the tag popover offers to suggest the filter text as a new tag. The suggestion is stored in the tags table as a pending row with who suggested it and when, and it goes on the product straight away: product saves accept pending tags, and its chip is marked pending. Suggestions are lowercased and limited to 40 letters, digits, spaces or hyphens, and each person may have at most five pending at once.

A new admin page, /admin/tags, lists pending tags with the products carrying them. Approve adds a tag to the list everyone picks from. Merge replaces it with an approved tag on every product that carries it. Reject takes it off those products. Both merge and reject delete the pending row.

The app now writes to the tags table, so the Vercel role's grant on it becomes read-write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Oct 9, 2026 12:41am UTC
source-cooperative Ready Ready Preview Oct 9, 2026 12:41am UTC

Request Review

@claude

claude Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 20s —— View job


✅ No blocking issues — safe to merge.

I read the diff for suggestTag, reviewTag, the tags and products table changes, TagPicker, and the CDK grant. I did not run the test suite.

Worth fixing (non-blocking)

  • src/lib/actions/tags.ts (reviewTag, merge/reject branch): it never checks that tag is still pending. The form only offers these buttons on pending rows. A stale or hand-crafted post with decision=reject, or decision=merge&into=<same tag>, would strip an approved tag from every product and delete it from the corpus. Admin-only, but cheap to guard:
    const row = (await tagsTable.listAll()).find((t) => t.tag_id === tag);
    if (!row?.pending) throw new Error(`"${tag}" is not a pending tag`);
    Put this once before the if (decision …) chain. It also covers approve on a tag another admin already deleted. approve does have an attribute_exists condition, but reject and merge don't.
  • src/lib/actions/tags.ts (suggestTag): the per-account cap is a read-then-write, so two concurrent suggestions can both pass at 4 pending. That is a soft limit, and I wouldn't fix it. Just be aware the "at most 5" in the description isn't strict.
  • TagPicker.tsx: canSuggest tests options.includes(query) against the trimmed, lowercased text. suggestTag also collapses inner whitespace. If someone types sea ice with two spaces, the button shows two spaces but the stored tag has one. This is cosmetic. Applying .replace(/\s+/g, " ") to query would make them agree.

Simplify (ponytail)

  • src/app/(app)/admin/tags/page.tsx: listByTag runs once per pending tag, and each call scans the whole products table. One scan that groups products by pending tag would cost one pass instead of N. You already marked this with ponytail:, so it's fine to leave until the queue grows.
  • src/lib/clients/database/tags.ts: the suggest/approve/delete try/catch/log/rethrow blocks are identical boilerplate. If BaseTable already has a wrapper for this, use it. Otherwise leave them.

Docs

The description names the docs change (docs.source.coop#40). It says why llms.txt and the data-proxy ADRs still hold. Nothing further needed.


💰 Estimated review cost: $0.17 · 0m20s · 6 turns

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — b50d7860 Deployed Oct 9, 2026 by vercel[bot]
Preview – source-coop-ui — b50d7860 Deployed Oct 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant