feat(gigs): boost a gig after a week#479
Conversation
Adds gig boosting so a poster can bump an active gig back to the top of the listing once it is at least 7 days old (since creation or its last boost). Pricing is intentionally out of scope for now. - migration: add boosted_at + generated ranked_at = GREATEST(created_at, boosted_at), index ranked_at - list: default "newest" sort now orders by ranked_at so boosted gigs rise - POST /api/gigs/[id]/boost: owner-only, active-only, 7-day cooldown (429 with nextEligibleAt when too soon), fires gig.update webhook - src/lib/boost.ts: shared eligibility rule used by API + UI - UI: Boost action in GigActions; api client gigs.boost() Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
Greptile SummaryThis PR adds a "boost" mechanic that lets gig owners bump an active gig back to the top of the default listing once a 7-day cooldown has elapsed since creation or the last boost. The implementation is well-structured with shared eligibility logic between the API and UI, good test coverage, a clean migration, and correct TypeScript type updates.
Confidence Score: 4/5Safe to merge for most real-world traffic; the concurrent-boost scenario requires two nearly-simultaneous requests from the same owner, which is unlikely but technically possible. The boost route reads src/app/api/gigs/[id]/boost/route.ts — the update query needs a conditional WHERE clause (or an RPC) to make the cooldown enforcement atomic. Important Files Changed
Sequence DiagramsequenceDiagram
actor Owner
participant UI as GigActions (UI)
participant API as POST /api/gigs/[id]/boost
participant DB as Supabase (gigs table)
participant WH as Webhook Dispatch
Owner->>UI: Click "Boost Gig"
UI->>API: "POST /api/gigs/{id}/boost"
API->>API: Auth + rate-limit check
API->>DB: SELECT poster_id, status, created_at, boosted_at
DB-->>API: gig row
API->>API: getBoostEligibility(gig)
alt cooldown not elapsed
API-->>UI: 429 + nextEligibleAt
else eligible
API->>DB: "UPDATE SET boosted_at = now(), updated_at = now()"
DB-->>API: updated gig (ranked_at auto-updated)
API--)WH: dispatchWebhookAsync("gig.update")
API-->>UI: "200 { gig }"
UI->>UI: router.refresh()
end
Reviews (1): Last reviewed commit: "feat(gigs): let posters boost a gig afte..." | Re-trigger Greptile |
| const { data: existingGig } = await supabase | ||
| .from("gigs") | ||
| .select("poster_id, status, created_at, boosted_at") | ||
| .eq("id", id) | ||
| .single(); | ||
|
|
||
| if (!existingGig) { | ||
| return NextResponse.json({ error: "Gig not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| if (existingGig.poster_id !== user.id) { | ||
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | ||
| } | ||
|
|
||
| // Only active gigs appear in the public listing, so only they can be boosted. | ||
| if (existingGig.status !== "active") { | ||
| return NextResponse.json( | ||
| { error: "Only active gigs can be boosted." }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const eligibility = getBoostEligibility(existingGig); | ||
| if (!eligibility.eligible) { | ||
| return NextResponse.json( | ||
| { | ||
| error: `Gigs can be boosted once every ${BOOST_COOLDOWN_DAYS} days. Try again later.`, | ||
| nextEligibleAt: eligibility.nextEligibleAt, | ||
| }, | ||
| { status: 429 } | ||
| ); | ||
| } | ||
|
|
||
| const boostedAt = new Date().toISOString(); | ||
| const { data: gig, error } = await supabase | ||
| .from("gigs") | ||
| .update({ boosted_at: boostedAt, updated_at: boostedAt }) | ||
| .eq("id", id) | ||
| .select() | ||
| .single(); |
There was a problem hiding this comment.
TOCTOU race on the cooldown check
The read-then-write pattern here has no database-level guard: two concurrent POST requests can both read boosted_at = null (or any eligible value), both pass getBoostEligibility, and both commit the update — giving the owner two boosts within seconds. The in-memory rate limiter is per-request but does not prevent this if two requests land in the same window.
A minimal fix is to add a conditional filter to the .update() call so the row is only written when the cooldown has actually elapsed. Alternatively, a small Supabase RPC that encapsulates the check-and-update atomically avoids the race entirely.
| default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time | ||
| query = query.order("ranked_at", { ascending: false }); |
There was a problem hiding this comment.
The other sort columns that can be null (
budget_max, budget_min) both pass nullsFirst: false to keep nulls at the bottom of the list. The ranked_at order doesn't, so if any row ever has a null ranked_at those rows would float to the top in DESC order. For consistency and defensive correctness, add nullsFirst: false here.
| default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time | |
| query = query.order("ranked_at", { ascending: false }); | |
| default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time | |
| query = query.order("ranked_at", { ascending: false, nullsFirst: false }); |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
Lets a poster boost an active gig to bump it back to the top of the listing, once the gig is at least 7 days old (since creation or its last boost). Pricing is intentionally out of scope for now — this ships the mechanic only.
Changes
20260614000000_add_gig_boost.sql: addsboosted_at(nullable) + a stored generatedranked_at = GREATEST(created_at, boosted_at)and an index onranked_at. For existing/never-boosted rowsranked_at == created_at, so order is unchanged until a gig is boosted. Already applied to the linked Supabase project./api/gigs): defaultnewestsort now orders byranked_at DESC, so boosted gigs rise.POST /api/gigs/[id]/boost: owner-only, rate-limited, requiresactivestatus, enforces a 7-day cooldown (429+nextEligibleAtwhen too soon), fires the existinggig.updatewebhook.src/lib/boost.ts(getBoostEligibility,BOOST_COOLDOWN_DAYS = 7) used by both API and UI.GigActions(or a disabled "Boost available " hint);gigs.boost()API client; regenerated DB types.Tests
pnpm type-checkclean;pnpm lint0 errors; pre-push suite green (1666 tests) + build passes.🤖 Generated with Claude Code