Repository navigation
feat(community): cooking groups (tables, invites, shared cooks, blocks) and challenges (entries, packs, voting) - #166
Conversation
…s) and challenges (entries, packs, voting)
Tables: owner-created tables with owner-approved invites, a cap of 8
members, shared-cook cards (18+ only, server-set identity, optional
recipe content, personal-data screening), author deletes, leave rules,
mutual block/unblock with silent two-way feed filtering, and removal
that redacts the departed member's cards ("Left the table").
Challenges: pack items frozen into each challenge (missing pack 404,
malformed item 422), entries linking one of your published revisions
(wrong revision -> recipe_revised_offline, foreign recipe ->
quarantined_rights, entries_closed outside the window), one movable
vote per account gated by voting window and the 24 h account age
(canVoteAt), tallies that exclude self-votes and stay hidden until
voting closes, and a server-rendered public challenge page (escaped,
tallies only when closed).
Also: challenge-entry notes are optional (SPEC 4.2) — the route had
been demanding a field the contract marks optional.
Tests: test/groups.test.ts covers tables and challenges end to end
over the production wiring with a stubbed Apple verifier and a
controllable clock, plus pure-rule tests for lifecycle derivation,
tallies, pack resolution, and page rendering. 42/42 tests pass
(incl. the existing api.test.ts suite) and tsc --noEmit is clean.
Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com>
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
|
|
Independent review (Obvious, Autobuild + Max) Reviewed at head Verdict: changes neededThe tables half is close to ready. The challenges half implements one spec-mandated behavior the body claims — and its own in-code comment claims — that is not implemented (probe-verified), and skips two moderation rules the spec requires. Nothing here questions the test quality: the suite is real, and the mutation control below confirms it bites. Findings (by severity)1. HIGH — "One entry per account" is not implemented: re-entering creates a duplicate entry and abandons the old one. 2. MEDIUM — entries POST has no rate limit (SPEC §4.3.4 mandates limits on entry and vote writes). 3. MEDIUM — a reported entry stays visible in the challenge until the report resolves (SPEC §4.3.5: hidden until then). 4. MEDIUM — the public page renders entries the JSON routes hide. 5. LOW — the §6.3 block rule for votes is missing and undisclosed: "the blocked cannot vote on your challenge entries". 6. LOW — every entry title falls back to 7. LOW — replaying a share burns daily quota. 8. LOW — join-by-code is unthrottled. Body accuracy
What I verified (commands + results)
What I couldn't verify
Bottom line: findings 1–4 should be addressed before merge (1 is a body-claim gap; 3–4 are moderation gaps), 5–8 can ride along. The harness and suite are genuinely good — the mutation control shows the tests would catch the regressions these fixes could introduce. |
…imits
Independent PR review findings, each with a test that failed first:
- §4.3.6 one entry per account, enforced: re-entering now tombstones the
prior entry (removed/replacedBy), so it stops rendering and stops
accepting votes; the author's own stored vote re-points to the
replacement instead of orphaning; myEntryId no longer goes stale.
- §4.3.4 entry writes are rate limited (10/day) after the refusal
checks — 403/409/422 cost nothing, only successful writes burn quota.
- §4.3.5 an entry with a queued moderation case hides from other
viewers in the challenge detail until the report resolves; the
author keeps seeing their own entry; entryCount follows the viewer.
- §4.4 the public page loader applies the same visibility rules as the
JSON routes: replaced entries dropped, queued-case entries hidden,
only entries on published revisions rendered (it previously
hardcoded every entry visible).
- §6.3 the blocked cannot vote on your challenge entries: 403 with
reason "blocked" (contract addition noted in the PR body).
- Entry titles now read the published recipe's content.title instead of
a field publications never store ("entry ur_1" fallback only when
content carries no title).
- §5.2 the share route's operationId replay check moved above the rate
limit so client retries do not burn the daily 40-share quota.
- §5.1 join-by-code attempts are throttled (20/day/account); failed
guesses burn quota by design.
Tests: 50 pass / 0 fail across the community suite (402 expect calls);
the eight new tests fail against the previous code. tsc --noEmit clean.
Co-authored-by: Sam Gu <sgu07966@gmail.com>
|
Round 2 — fixes pushed at
One reviewer suggestion deliberately not taken: keying entries on Checks this session, on the fix head: |
|
Hosted macOS CI on the fix head |
|
Note Review skipped Codemetrics is installed on this repository, but it hasn't been added to your team yet. To enable AI code reviews for this repo, go to your dashboard and add FridgeLuck to your team's repositories. |
[Medium impact] Adds new community features for challenges and cooking groups. This PR should not merge until block ownership, returning membership, revision linking, moderation decisions, and concurrent writes are fixed.
|
| if (target === accountId) { | ||
| throw errValidation("validation", "you cannot unblock yourself"); | ||
| } | ||
| await blocks.doc(blockPairId(accountId, target)).set({ accountA: [accountId, target].sort()[0], accountB: [accountId, target].sort()[1], removedAt: ctx.clock.now().toISOString() }); |
There was a problem hiding this comment.
If A blocks B, B can call DELETE /blocks/A to erase A's block. Both accounts use the same blockPairId, and the delete never checks createdBy. GET /blocks also reveals A's private block to B.
Store each person's block separately. Apply either person's block to both feeds, but let callers list and remove only their own blocks.
How this was verified: Both accounts address the same sorted pair, and the delete and list routes never check who created the block.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/tables/index.ts
Line: 548
Comment:
**Blocked users can undo blocks**
If A blocks B, B can call `DELETE /blocks/A` to erase A's block. Both accounts use the same `blockPairId`, and the delete never checks `createdBy`. `GET /blocks` also reveals A's private block to B.
Store each person's block separately. Apply either person's block to both feeds, but let callers list and remove only their own blocks.
**How this was verified:** Both accounts address the same sorted pair, and the delete and list routes never check who created the block.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. Blocks are now directional rows keyed by blockDocId(blocker, blocked) — the blocked party can neither list the row nor erase it (DELETE /blocks/:accountId removes only the caller's own row). Either side's row still filters the pair from each other's feeds, votes, and cards; the legacy sorted-pair id (blockPairId) is still read for older rows. Covered by regression tests: only the blocker sees the row, B's delete erases only B's row, and the pair's feeds stay filtered.
| await consumeRate(ctx.store, "challenge-entry", accountId, DAILY_ENTRY_LIMIT, ctx.clock); | ||
| await entries.doc(entryRecord.entryId).create({ ...entryRecord }); | ||
| if (prior !== undefined) { | ||
| await entries.doc(prior.entryId).update({ removed: true, replacedBy: entryRecord.entryId }); |
There was a problem hiding this comment.
Overlapping requests create extra entries
Two submissions from the same account can read the same prior before either writes. Each creates a new active entry and removes only the old record, leaving both new entries visible and open for votes. consumeRate protects only the quota counter.
Read the current entry and commit its replacement in one transaction, including the saved operationId response.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/challenges/index.ts
Line: 258-261
Comment:
**Overlapping requests create extra entries**
Two submissions from the same account can read the same `prior` before either writes. Each creates a new active entry and removes only the old record, leaving both new entries visible and open for votes. `consumeRate` protects only the quota counter.
Read the current entry and commit its replacement in one transaction, including the saved `operationId` response.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. The entry POST now commits the replay check, prior-entry selection, create, prior replacement, own-vote re-point, and saved operationId response inside one store.runTransaction, with all reads preceding writes — two overlapping submissions serialize, the loser replaces the winner's entry, and exactly one active entry remains. Covered by a jittered-store race test asserting one active entry.
| const memberRow: TableMemberRow = { accountId: joinerId, role: "member", joinedAt, departedAt: null }; | ||
| const members = [...record.members, memberRow]; | ||
| await tx.collection(TABLES_COLLECTION).doc(tableId).update({ members }); |
There was a problem hiding this comment.
Returning members stay locked out
Approving a returning member appends a new row after their departed row. requireActiveMembership, leaving, and removal use find by account, so they keep selecting the old row. Approval succeeds and consumes a member slot, but the member still gets 404 and the owner cannot remove them.
Reuse the existing member row or make every membership check consistently select the active row.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/tables/index.ts
Line: 280-282
Comment:
**Returning members stay locked out**
Approving a returning member appends a new row after their departed row. `requireActiveMembership`, leaving, and removal use `find` by account, so they keep selecting the old row. Approval succeeds and consumes a member slot, but the member still gets 404 and the owner cannot remove them.
Reuse the existing member row or make every membership check consistently select the active row.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. Membership checks now use an active-row predicate (row with removedAt unset) instead of first-row-by-account, and re-approving a returning member reuses their departed row instead of appending a second one — so approval, 404s, leaving, and owner removal all agree. Covered by a regression test: a returning member re-enters, is servable, and can be removed.
| .find( | ||
| (p) => | ||
| p["state"] === "published" && | ||
| p["recipeRef"]?.["source"] === recipeRef.source && | ||
| p["recipeRef"]?.["id"] === recipeRef.id, | ||
| ); | ||
| if (match === undefined) { | ||
| throw errForbidden("the entry must link one of your published revisions", "quarantined_rights"); | ||
| } | ||
| if (match["recipeRef"]["revision"] !== recipeRef.revision) { | ||
| throw new CommunityError(409, "conflict", "that recipe has a newer revision on the server", "recipe_revised_offline"); |
There was a problem hiding this comment.
Published updates cannot enter
The lookup picks the first published record for a recipe before checking its revision. Publishing revisions 1 and 2 leaves both records published, so linking revision 2 can select revision 1 and return recipe_revised_offline even though revision 2 exists.
Find the exact published revision first. Return the mismatch error only when no exact match exists.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/challenges/index.ts
Line: 224-234
Comment:
**Published updates cannot enter**
The lookup picks the first published record for a recipe before checking its revision. Publishing revisions 1 and 2 leaves both records published, so linking revision 2 can select revision 1 and return `recipe_revised_offline` even though revision 2 exists.
Find the exact published revision first. Return the mismatch error only when no exact match exists.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. The route now collects every published record for the recipe and picks the EXACT revision when it is published; recipe_revised_offline is returned only when no record at that revision is published. Re-publishing revision 2 (or linking revision 1 while it is still live) no longer refuses. Covered by a regression test: linking the newest published revision enters instead of refusing.
| const openCases = await ctx.store.collection(CASES_COLLECTION).where("targetId", card.cardId).where("state", "queued").get(); | ||
| if (openCases.length > 0) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Cards and entries reappear after an operator chooses hide, remove, or escalate_sam. The table feed, challenge detail, and page loader hide only queued cases. The decision route marks every case resolved but changes the target only for recipes, so resolving a tableCard or challengeEntry case restores it regardless of the decision.
Preserve the decision's effect on the target or check resolved decisions when building these views.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/tables/index.ts
Line: 155-158
Comment:
**Removed content returns**
Cards and entries reappear after an operator chooses `hide`, `remove`, or `escalate_sam`. The table feed, challenge detail, and page loader hide only queued cases. The decision route marks every case resolved but changes the target only for recipes, so resolving a `tableCard` or `challengeEntry` case restores it regardless of the decision.
Preserve the decision's effect on the target or check resolved decisions when building these views.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. Added shared casesForTarget/casesHideTarget helpers in core/views.ts: a queued case hides content from everyone but its author; a resolved hide/remove/escalate keeps it hidden from everyone; the latest decidedAt wins, so dismissing a fresh report over previously hidden content restores it. The table feed, single-card lookup, challenge detail, and list entry counts all ride the helpers. Covered by regression tests: hide keeps a card hidden, a later dismiss restores it, and remove keeps a challenge entry hidden.
| if (invite.requestedByAccountId !== null && invite.requestedByAccountId !== accountId) { | ||
| throw errConflict("conflict", "someone has already asked to join with this code"); | ||
| } | ||
| if (invite.requestedByAccountId === null) { | ||
| await invites.doc(invite.inviteId).update({ requestedByAccountId: accountId, requestedByDisplayName: requesterName }); |
There was a problem hiding this comment.
Join requests overwrite each other
Two people presenting the same unused code can both read requestedByAccountId === null, both receive 202, and overwrite each other's request. The last write changes which person the owner will approve.
Read and claim the invite in one transaction so only one person can claim it and later requests cannot replace that person.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/tables/index.ts
Line: 346-350
Comment:
**Join requests overwrite each other**
Two people presenting the same unused code can both read `requestedByAccountId === null`, both receive 202, and overwrite each other's request. The last write changes which person the owner will approve.
Read and claim the invite in one transaction so only one person can claim it and later requests cannot replace that person.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. The join route reads and claims the invite row inside one transaction, so of two people presenting the same unused code exactly one claims it and later requests cannot overwrite the claim. Covered by a jittered-store race test asserting exactly one 202 claim.
| await cards.doc(cardRecord.cardId).create({ ...cardRecord }); | ||
| const response = cardView(cardRecord, accountId, false); | ||
| await ctx.store.collection(OPERATIONS_COLLECTION).doc(operationId).create({ | ||
| accountId, | ||
| createdAt: cardRecord.createdAt, | ||
| response, | ||
| }); |
There was a problem hiding this comment.
Overlapping retries with the same operationId can both pass the replay check before either saves its response. Each then creates a different card. The later operations.create returns false, but that result is ignored and both requests return 201.
Commit the card and saved response together, with a transaction that checks operationId before writing.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/tables/index.ts
Line: 475-481
Comment:
**Retries share cooks twice**
Overlapping retries with the same `operationId` can both pass the replay check before either saves its response. Each then creates a different card. The later `operations.create` returns false, but that result is ignored and both requests return 201.
Commit the card and saved response together, with a transaction that checks `operationId` before writing.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. Card create now commits the operationId replay check, card write, and saved response in one transaction; overlapping retries with the same operationId converge on one card and both receive the same saved response. Covered by a jittered-store race test asserting one card and identical 201 responses.
| return String(a["createdAt"]).localeCompare(String(b["createdAt"])); | ||
| }); | ||
| return json(200, { | ||
| challenge: challengeView(record, status, viewerEntries.length, { myEntryId: myEntry?.entryId ?? null, myVoteEntryId: myVote }), |
There was a problem hiding this comment.
entryCount includes entries the viewer cannot see. The list counts every non-replaced entry, including reported entries and entries whose publication is hidden. Detail excludes reported entries but still counts entries later skipped by entryStateFor.
The contract defines this as the visible-entry count. Count entries that pass the viewer's visibility checks on both routes so users do not see entries advertised that disappear when opened.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/challenges/index.ts
Line: 188
Comment:
**Entry counts do not match**
`entryCount` includes entries the viewer cannot see. The list counts every non-replaced entry, including reported entries and entries whose publication is hidden. Detail excludes reported entries but still counts entries later skipped by `entryStateFor`.
The contract defines this as the visible-entry count. Count entries that pass the viewer's visibility checks on both routes so users do not see entries advertised that disappear when opened.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. Both list and detail now count only entries the viewer can open (queued-case hiding from non-authors, resolved hide/remove/escalate, in-review entries of others all excluded); the detail count rides the rendered entry set so tile and list can never disagree. Covered by a regression test asserting the same viewer-visible count on list and detail.
| handler: async (req) => { | ||
| const accountId = requireAccount(req); | ||
| const record = await requireChallenge(challengeIdShape.validate(req.params["challengeId"])); | ||
| assertEntriesOpen(record); |
There was a problem hiding this comment.
assertEntriesOpen runs before the operationId lookup. If a successful 201 response is lost near the deadline, retrying after entries close returns entries_closed even though the entry was saved.
Return the saved response for the same account before checking whether a new entry is still allowed.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/community/src/modules/challenges/index.ts
Line: 199
Comment:
**Saved entries fail on retry**
`assertEntriesOpen` runs before the `operationId` lookup. If a successful 201 response is lost near the deadline, retrying after entries close returns `entries_closed` even though the entry was saved.
Return the saved response for the same account before checking whether a new entry is still allowed.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in 8f1d7ce. The POST route now validates the operationId, answers a saved response for the same account first, and only then checks the entry window — a retry that lost its 201 near the deadline replays the stored response instead of failing with entries_closed. Covered by a regression test replaying a saved entry after the window closes.
One test per unresolved Greptile thread: per-person block rows, resolved moderation decisions keeping their effect, returning members, invite-claim and operationId races (driven with a macrotask-jittered store wrapper), published-revision selection, viewer-visible entry counts, and retries of saved entries after the window closes. All ten fail on 015fc30; the pre-existing suite is green there (50 pass).
Route every transactional read and write through the SDK Transaction (t.get/t.create/t.set/t.update, t.get(query)), so a thrown fn commits nothing and concurrent fns retry on contention instead of losing writes. The SDK forbids reads after writes, so the transactional view keeps a per-call write journal (as the in-memory store does): doc reads answer from the journal once a doc is written in the tx, and update() merges onto journal state, priming it via t.get only while reads are still allowed. Emulator probes pin the seam properties: rollback (a fn that writes then throws commits nothing), read-your-writes, and two parallel counter increments without a lost update. On the reviewed head the rollback probe fails (the write survives) and the parallel probe loses an update. Co-authored-by: Sam Gu <sgu07966@gmail.com>
- blocks: directional per-blocker rows (blockDocId); either side's row hides the pair, legacy sorted-pair rows still read, and only the blocker can list or unblock (§6.3) - moderation: shared casesForTarget/casesHideTarget helpers — queued cases hide content from non-authors, resolved hide/remove/escalate keep it hidden from everyone, and the latest operator decision wins (a dismiss of a fresh report restores the content); tables feeds, card lookup, and challenge entry views all ride the helpers - tables: invite claims and card creates are one-transaction read-check-write-write with saved-response replay; joining an open seat reuses the removed member's row; feed entry counts match the rendered set - challenges: entryCount counts only what the viewer can open on list and detail; detail tallies ride the rendered set; the saved entry response answers before the window check so a late retry replays instead of 409; entry commits (create + prior replacement + own-vote re-point + saved response) run in one transaction; published-revision selection picks the exact revision when published so older live revisions stay enterable - firestore: finish the f1e2e31 cherry-pick resolution (no conflict markers) Verified: tsc clean; bun test 60 pass / 0 fail (3 Firestore-emulator tests skip without an emulator); greptile-fixes suite 55+ consecutive clean runs. Co-authored-by: obvious-autobuild[bot] <app/obvious-autobuild[bot]@users.noreply.github.com>
…l HTTP Boot the production app behind Bun.serve on the Firestore emulator and issue genuinely concurrent requests for the four overlapping-request paths behind PR #166: two joiners presenting one code (one 202, one 409), two entries from one account (one active entry), two retries with one operationId (one card, same saved response), and two approvals at the member cap (one claims the last slot, one table_full). An in-flight probe asserts the requests really overlapped in the server.
What changed and why
Second slice of the community backend (SPEC §5 cooking groups, §4 challenges — numbering per the spec's own sections, corrected from round 1), following #160's contract/identity/publications/moderation/Explore slice. Branch base:
obv/burst5-fridgeluck-community-api.Tables (
backend/community/src/modules/tables/) — §5 cooking groups:table_fullat the cap); owner-only invite minting with 6-char codes; join-by-code returns 202 with a pending ask the owner approves (OpenAPI-accurate).not_adult), server-setauthorDisplayName(clients cannot set identity), optional full recipe content, and personal-data screening on notes (personal_data→ 422).GET /v1/tables/:tableId/cardsadded as a planned addition — the contract has no Table card-feed read route, but blocks/hiding/leave-redaction are feed behaviors that need a read surface.Challenges (
backend/community/src/modules/challenges/) — §4:myEntryId/myVoteEntryId.usdaFdcId,displayName,quantity).recipe_revised_offline, foreign recipe → 403quarantined_rights, outside the entry window → 409entries_closed. One entry per account, enforced server-side (§4.3.6): a new link replaces the old entry — the old id stops rendering and stops accepting votes, and the author's own stored vote re-points to the replacement.vote_too_early) and the 24 h account age fromcanVoteAt(§6.3) and the block rule (§6.3: the blocked cannot vote on your entries → 403blocked).GET /v1/challenges/:challengeId/pagerendered byrenderChallengePage: fully escaped, tallies only when closed, and — after round 2 — the same entry visibility rules as the JSON routes.Review fixes (round 2, commit
015fc30)Every finding from the independent review was fixed; each fix ships with a test that fails against the previous code (verified in one run: 19 pass / 8 fail before the fix, then 50 pass / 0 fail after):
myEntryIdwent staleremoved/replacedBy), so it stops rendering and 404s on vote; author's own vote doc re-points (§4.3.6); list/detail/filter counts followmyEntryId, vote-doc re-point, old-id vote 404consumeRate("challenge-entry", 10/day)placed after the 403/409/422 refusal checks — only successful writes burn quotaentryCountcounts viewer-visible entriesvisibleloadChallengePageapplies the JSON routes' rules: replaced dropped, queued-case hidden, only published-revision entries renderblocked(contract addition noted here, likeaccount_too_newin round 1)blocked, unblocked control 204entry <recipeId>publication.content.title; fallback keptconsumeRatein the share routeconsumeRate("table-join", 20/day/account)above code validation — failed guesses burn quota by design (they are the attack)One review observation I did not act on: the reviewer's suggested entry keying (
${challengeId}__${accountId}doc ids) would break the vote route's directentries.doc(entryId)lookup, so the replacement is modeled as a tombstone on the oldent_*doc instead — same observable behavior (old id dead, votes stop counting), no lookup rewrite.How I checked it (round 2)
bun install(117 packages, fresh sandbox),bun run check(tsc --noEmit): clean.bun testinbackend/community: 50 pass / 0 fail, 402 expect() calls acrosstest/api.test.tsandtest/groups.test.ts(27 tests). Fail-before-fix run:bun test test/groups.test.ts→ 19 pass / 8 fail before the fix commit; same suite 27 pass / 0 fail after.vote_too_early, pack resolver freezing and refusals, page escaping (no<script>survives,voteCountabsent while open).What I could not check
/v1-path route. Serving it at a non-/v1public URL needs core request-plumbing changes (the app currently gates non-/v1paths and the router dispatches JSON bodies only) — core changes were outside this task's permitted scope, so the HTML route is registered under/v1/...and the renderer is exercised by tests. Wiring it to a public path is the documented next step.Assumptions
/voteerror table lists onlyvote_too_early; two gates ride 403 with explicit reasons:account_too_new(§6.3 24 h account age) andblocked(§6.3 block rule) — both additions to the enumerated reasons, noted here per the review's suggestion.Human author: Sam Gu (sgu07966@gmail.com)
🔗 Obvious Project · 🧵 Obvious Thread
Review follow-up (Greptile findings, landed in 8f1d7ce + 716d7ab)
All seven P1 and two P2 findings are fixed; each inline thread has a reply pointing at the fix and its regression test in
backend/community/test/greptile-fixes.test.ts(committed failing-first inc26d235).blockDocId); callers list and unblock only their own row; either side's row filters the pair everywhere; legacy pair-id rows still readrecipe_revised_offlineonly when no record at that revision is publishedcasesForTarget/casesHideTarget: queued hides from non-authors; resolved hide/remove/escalate hides; latest decision wins (dismiss restores); applied to table feed, card lookup, challenge detail and countsentryCountcounts only viewer-visible entries on list and detail; detail count rides the rendered setoperationIdresponse answers before the entry-window checkHow verified:
tsc --noEmitclean;bun test test/→ 60 pass / 0 fail (3 Firestore-emulator integration tests skip without an emulator); the race-test suite ran 55+ consecutive clean runs after fixing adecidedAttie-break. The two moderation-visibility behaviors were exercised through the API routes with an operator app over the same store.Not verified locally: iOS/Xcode builds (no Xcode here) — unchanged by this push; Firestore-backed transactions (covered by the cherry-picked
716d7ab, emulator tests skip without an emulator).Assumptions: blocks are one-directional rows with either-side effect; moderation semantics are "latest operator decision wins" for resolved cases; entries linking an older still-published revision stay servable; the
/vote403 reasons (account_too_new,blocked) ride the existing error shape as additions.