Skip to content

feat(product): back-merge upstream jobs, merge, import, tag visibility and email branding (#506, #507, #518, #520, #445) - #29

Merged
theRealBithive merged 7 commits into
mainfrom
feat/upstream-product-jobs
Sep 9, 2026
Merged

feat(product): back-merge upstream jobs, merge, import, tag visibility and email branding (#506, #507, #518, #520, #445)#29
theRealBithive merged 7 commits into
mainfrom
feat/upstream-product-jobs

Conversation

@theRealBithive

Copy link
Copy Markdown
Owner

Summary

Third and last of the selective back-merges from upstream (quackbackio/quackback): product fixes and the job queue. Four merged upstream commits plus one open upstream pull request, cherry-picked with -x so each keeps its upstream author:

Billing, workspace dormancy and the Slack assistant stay out, as in #27 and #28.

What changes for an operator

Verification

Two fork commits sit on top of the picks: the tests (test(portal)) and a SELF-IMPROVE note.

  • bun run typecheck: 0 errors. bun run lint: clean. bun run db:check-drift against a database migrated through 0277: no drift. bun scripts/i18n-check.ts: PASS — the translated portal components feat(tags): per-tag portal visibility ("Show on portal") QuackbackIO/quackback#520 touches receive no new English text; the tag settings and CSV import pages were English-only before and stay so.
  • Mutation gate: DIFF_BASE=main bun scripts/mutation-check.tsPASS: this change touched no file the manifest declares. The touched files are printed as not mutation-graded by name, as before this change.

Tests

Upstream's own tests leave the QuackbackIO#520 cache-reset paths and two QuackbackIO#518 details unexecuted, so the branch adds four suites and extends three: the comment form, the feedback header, the portal header (cross-tab sign-in) and the access gate (sign-out) each seed a real QueryClient and prove the viewer-scoped keys are gone while an unrelated key survives; the tag export writes is_public; the row schema keeps a parseable created_at and falls back for a blank or unparseable one (with a fast-check round-trip over ISO timestamps).

The sample-CSV generator lost its anonymous-author branch in QuackbackIO#518. It is a process (argv and a file write at module scope), so it joins the CLI entry points in the coverage exclusion — same shape as scripts/*-check.ts, pinned by coverage-scope.test.ts — and a new suite spawns it and feeds every generated row through the importer's own parser and csvRowSchema. That is the contract the generator has to keep: the sample file must import without silently losing posts.

Local run over every affected directory with REQUIRE_TEST_DB=1:

Test files 350 (349 passed, 1 load failure, 1 skipped)
Tests 3,612 passed
Added lines judged by the diff gate 91 executable
Executed 91
Never executed 0

The one failure is jobs/__tests__/runner.test.ts seeing a prune count of 0 for a row it had just aged, which passes three times in a row alone: neighbouring suites prune the same shared job_queue table, so under parallel load whichever process prunes first takes the other's row. The skipped file is api-integration-advanced.test.ts, which needs a running server. Both are recorded in SELF-IMPROVE.

The e2e spec apps/web/e2e/tests/admin/settings-tags.spec.ts from QuackbackIO#520 is out of the unit gate's scope by design and runs in the e2e-smoke job.

Security note

EMAIL_BRAND_NAME (QuackbackIO#445) is operator-controlled configuration read through getEnv, rendered by React inside the email templates (escaped) and interpolated into plain-text subjects; it never reaches a query or a shell. OWASP A05 (security misconfiguration) is the relevant category and the default keeps the previous behaviour. post_tags.is_public (QuackbackIO#520) is enforced server-side in the public post, roadmap and API paths for non-team actors, so an internal tag id supplied in a URL is inert for them — OWASP A01 (broken access control), and the reason QuackbackIO#520's review follow-ups are included in full.

Not in this pull request

🤖 Generated with Claude Code

mortondev and others added 7 commits September 9, 2026 18:52
…le bound (QuackbackIO#506)

Pruning ran on every lease-reap tick even though retention is measured in
days, and its per-row retention predicate could never use
job_queue_terminal_idx, so every tick was a full scan of each workspace's
terminal rows. Split the prune onto JOB_PRUNE_INTERVAL_MS (default 1h) and
lead the predicate with the shortest retention as a constant bound so the
partial index applies; lease reaping keeps its fast cadence.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit f57343d)

Fork note: the hunk that adds the prune-clock test to worker.test.ts also
carried upstream's `describe('dormancy')` block from QuackbackIO#500, which this fork
does not take (single tenancy). Only the prune-clock test is kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e merge assessment request (QuackbackIO#507)

* fix(merge): strict item schema so OpenAI Structured Outputs accept the request

`assessMergeCandidates` described its response items with
`z.record(z.string(), z.unknown())`. zod renders that with a `propertyNames`
keyword, which OpenAI's Structured Outputs reject:

  400 Invalid schema for response_format 'structured_output':
  In context=('properties', 'results', 'type', '0', 'items'),
  'propertyNames' is not permitted.

The request is refused before the model runs, so every merge check fails and
no merge suggestion is ever created. `checkPostForMergeCandidates` then never
stamps `mergeCheckedAt`, so the sweep retries the same posts on every pass.

The loose item shape was deliberate — it let a single malformed item be
skipped instead of failing the whole batch. Under structured outputs that
tolerance is moot: the provider guarantees the shape. `results` keeps
`.catch([])`, so a present-but-wrong-shaped top level still degrades to
"no assessments" rather than failing the request, and the `typeof` guards in
the filter loop stay as they are.

Adds a regression test that asserts the schema handed to `chat()` emits no
`propertyNames`. It fails on the previous schema and passes on this one.

Fixes QuackbackIO#505

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

* style(merge): shorten schema comment and restore JSDoc spacing

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: James Morton <james.morton@quackback.io>
(cherry picked from commit de5af85)
…O#518)

In-app CSV import advertised author_name but attributed posts to the
uploader whenever author_email was empty. Name-only rows now create (or
reuse) a portal contact, matching AuthorSelector. Rows with neither
author_name nor author_email are skipped instead of falling back to the
importer. Preview, template, and helper copy follow the same contract.

(cherry picked from commit d116df3)
…#520)

* feat(tags): per-tag portal visibility ("Show on portal")

Tags were always shown on the public portal, which surprised customers who
used them for internal triage. Adds post_tags.is_public (default true, so
nothing changes on upgrade) with a "Show on portal" switch in tag settings.

Non-team portal viewers only see public tags — in filter lists, on post
cards/detail, link embeds, widget search and the apps search/suggest
endpoints — and a tagIds URL filter naming an internal tag is inert for
them so post membership can't be inferred. Team actors keep the full
catalog so they can still assign internal tags from the portal. The flag
is exposed on /api/v1/tags, the OpenAPI schema and the tags CSV export.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(fleet): acknowledge 0276_post_tags_is_public in the post-0248 replay span pin

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tags): close two internal-tag leaks flagged in review

- Public roadmap: caller-supplied tagIds now go through the same
  actor-aware public-tag guard as the feed, so an internal tag id in a
  /roadmap URL is inert for non-team viewers. Admin-configured base
  filters are unchanged (not caller-controlled). Real-DB test added.
- Client cache: ['portal','tags'] and ['portal','data'] carry a
  role-dependent tag catalog under a fixed key; invalidate both on every
  portal auth transition (sign-in success, header sign-out, access-gate
  sign-out) so a team session's internal tags never leak into the
  following signed-out session, and vice versa.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(dep-graph): record roadmaps -> posts edge (roadmap runtime tag filter reuses publicTagCondition); acyclic

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(portal): reset (not invalidate) viewer-scoped caches on every auth transition

Review follow-ups on the client cache fix:
- invalidateQueries keeps data that route loaders read back through
  ensureQueryData, so a stale team-scoped tag catalog still rendered for
  the next anonymous view. Switch to resetQueries.
- Public roadmap results are now actor-dependent for runtime tag filters;
  include ['portal','roadmapPosts'] (and 'posts'/'post', which embed the
  filtered catalog) in the viewer-scoped set.
- Route the inline "sign out" links in the feedback header and comment
  form, plus the access-gate sign-in success, through the same reset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(roadmaps): redact internal tag ids from public roadmap base filters

listPublicRoadmaps returned each roadmap's admin-configured baseFilter
verbatim, so a public roadmap curated by an internal tag handed that tag
id to anonymous callers, who could correlate it with the returned posts.
Non-team viewers now get baseFilter.tagIds filtered to public tags (the
key is dropped when nothing remains); membership is unchanged since the
curation itself is admin-defined. Team actors are unredacted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(portal): include ['portal','roadmaps'] in the viewer-scoped reset (baseFilter is now redacted per viewer)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(portal): include the ['publicPosts'] infinite feed in the viewer-scoped reset

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(portal): remove (not reset) viewer-scoped caches on auth transitions

resetQueries restores a query's initialData, and usePublicPosts seeds the
feed from the SSR payload that way, so a team member's feed page (with
internal tags) came straight back after a reset. removeQueries drops the
entries outright; every call site follows with router.invalidate(), which
re-runs loaders and rebinds observers to fresh queries as the new viewer.
Unit test now pins all three failure modes (invalidate retains, reset
restores initialData, remove clears).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit c34aef7)

Fork note: upstream numbers this migration 0276, which this fork already
uses for 0276_identity_provider_logo_key (upstream's 0273, renumbered on
the first back-merge). Renumbered to 0277_post_tags_is_public (idx 254,
when 1785700000035), SQL unchanged. Migrator-gate span, CONTRACT.md
(255 scanned) and GRAPH.md (116 edges) regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The sign-in, invite, welcome, and password-reset emails hardcoded
"Quackback" in their subjects and headings, so an install running under
its own brand still sent mail naming the platform in the one place
in-app theming, a custom logo, and a custom domain never reach — the
recipient's inbox.

Add an EMAIL_BRAND_NAME env var (default "Quackback") and thread it
through those subjects and templates as a product name that stays
separate from each workspace's own name, so "join Acme on Quackback"
keeps both halves distinct. Existing installs are unchanged.

(cherry picked from commit 0aa54a8)
…or rule

Upstream QuackbackIO#520 drops the viewer-scoped react-query caches on every portal auth
transition so a team member's internal tags never leak into the next anonymous
view, and QuackbackIO#518 makes the CSV importer require an author. Upstream's tests leave
the reset paths in the comment form, the feedback header, the portal header's
cross-tab sign-in and the access gate's sign-out unexecuted, along with the tag
export's new is_public column and the created_at fallback in the row schema.
These suites close that: each sign-out test seeds a real QueryClient and asserts
the scoped key is gone while an unrelated one survives, and the translated
components are asserted against the German catalogue.

The sample-CSV generator (`bun run db:generate-csv`) lost its anonymous-author
branch in QuackbackIO#518, and the importer would now skip such rows. It runs as a process
— argv and a file write at module scope — so it joins the CLI entry points in
the coverage exclusion, in the same shape as `scripts/*-check.ts`, and a new
suite spawns it and feeds every generated row through the importer's own parser
and schema. `coverage-scope.test.ts` pins the widened exclusion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…back-merge

The scanner suite `module-state.test.ts` timed out under the full parallel
run and passed alone; the re-run at six workers lost `runner.test.ts` instead,
whose prune count is a database-wide number that a neighbouring suite's prune
can take first. Both are the 8x entry's shape, recorded there.

Nothing in the running service changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@theRealBithive
theRealBithive merged commit 32dcc3d into main Sep 9, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants