Skip to content

feat(gigs): boost a gig after a week#479

Merged
ralyodio merged 1 commit into
masterfrom
feat/gig-boost
Jun 14, 2026
Merged

feat(gigs): boost a gig after a week#479
ralyodio merged 1 commit into
masterfrom
feat/gig-boost

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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

  • Migration 20260614000000_add_gig_boost.sql: adds boosted_at (nullable) + a stored generated ranked_at = GREATEST(created_at, boosted_at) and an index on ranked_at. For existing/never-boosted rows ranked_at == created_at, so order is unchanged until a gig is boosted. Already applied to the linked Supabase project.
  • Listing (/api/gigs): default newest sort now orders by ranked_at DESC, so boosted gigs rise.
  • Boost API POST /api/gigs/[id]/boost: owner-only, rate-limited, requires active status, enforces a 7-day cooldown (429 + nextEligibleAt when too soon), fires the existing gig.update webhook.
  • Shared rule src/lib/boost.ts (getBoostEligibility, BOOST_COOLDOWN_DAYS = 7) used by both API and UI.
  • UI: "Boost Gig" action in GigActions (or a disabled "Boost available " hint); gigs.boost() API client; regenerated DB types.

Tests

pnpm type-check clean; pnpm lint 0 errors; pre-push suite green (1666 tests) + build passes.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio ralyodio merged commit 97aa847 into master Jun 14, 2026
6 checks passed
@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This 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.

  • Boost API (POST /api/gigs/[id]/boost): validates auth, ownership, active status, and the 7-day cooldown before writing boosted_at; fires the existing gig.update webhook asynchronously.
  • Migration: adds boosted_at (nullable) and a stored generated column ranked_at = GREATEST(created_at, boosted_at) with an index; the default listing sort is updated to order by ranked_at DESC.
  • UI: GigActions gains a "Boost Gig" button (or a "Boost available <date>" hint when ineligible), driven by the same getBoostEligibility helper used server-side.

Confidence Score: 4/5

Safe 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 boosted_at, evaluates eligibility in application code, then issues an unconditional UPDATE with no database-level cooldown guard. Two concurrent POST requests from the same user can both pass the eligibility check and both commit, effectively letting someone boost twice in rapid succession. Everything else — the migration, shared eligibility helper, UI gating, test coverage, and type definitions — is solid.

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

Filename Overview
src/app/api/gigs/[id]/boost/route.ts New boost endpoint — auth, status, and cooldown checks are present, but the eligibility check and the database write are not atomic, creating a TOCTOU race that allows multiple concurrent boosts to succeed.
src/lib/boost.ts Shared eligibility logic is clean, handles null/invalid timestamps gracefully, and has good test coverage in boost.test.ts.
src/app/api/gigs/route.ts Default sort switched from created_at to ranked_at; missing nullsFirst: false is inconsistent with other sort branches but unlikely to matter in practice.
supabase/migrations/20260614000000_add_gig_boost.sql Migration adds boosted_at and the generated ranked_at = GREATEST(created_at, boosted_at) column plus index. Semantics are correct — PostgreSQL GREATEST ignores NULLs so existing rows retain their creation order.
src/components/gigs/GigActions.tsx Boost button added with correct client-side eligibility gate and disabled state; calls router.refresh() after success so the UI reflects the new boosted_at.
src/lib/boost.test.ts Comprehensive unit tests covering boundary conditions, null timestamps, and last-boost-time precedence — all deterministic via injected now.
src/types/database.ts Types updated correctly: boosted_at and ranked_at added to Row; ranked_at (generated column) is correctly absent from Insert/Update types.
src/lib/api.ts Simple addition of boost(id) client helper using POST — consistent with existing API client patterns.

Sequence Diagram

sequenceDiagram
    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
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(gigs): let posters boost a gig afte..." | Re-trigger Greptile

Comment on lines +23 to +62
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Codex Fix in Claude Code

Comment thread src/app/api/gigs/route.ts
Comment on lines +138 to +139
default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time
query = query.order("ranked_at", { ascending: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Fix in Codex Fix in Claude Code

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.

1 participant