-
Notifications
You must be signed in to change notification settings - Fork 46
feat(gigs): boost a gig after a week #479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getAuthContext } from "@/lib/auth/get-user"; | ||
| import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; | ||
| import { getBoostEligibility, BOOST_COOLDOWN_DAYS } from "@/lib/boost"; | ||
| import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; | ||
|
|
||
| // POST /api/gigs/[id]/boost - Bump an active gig back to the top of the listing. | ||
| export async function POST( | ||
| request: NextRequest, | ||
| { params }: { params: Promise<{ id: string }> } | ||
| ) { | ||
| try { | ||
| const { id } = await params; | ||
| const auth = await getAuthContext(request); | ||
| if (!auth) { | ||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| } | ||
| const { user, supabase } = auth; | ||
|
|
||
| const rl = checkRateLimit(getRateLimitIdentifier(request, user.id), "write"); | ||
| if (!rl.allowed) return rateLimitExceeded(rl); | ||
|
|
||
| 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(); | ||
|
|
||
| if (error) { | ||
| return NextResponse.json({ error: error.message }, { status: 400 }); | ||
| } | ||
|
|
||
| dispatchWebhookAsync(user.id, "gig.update", { | ||
| gig_id: id, | ||
| boosted_at: boostedAt, | ||
| }); | ||
|
|
||
| return NextResponse.json({ gig }); | ||
| } catch { | ||
| return NextResponse.json( | ||
| { error: "An unexpected error occurred" }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -135,8 +135,8 @@ export async function GET(request: NextRequest) { | |||||||||
| case "budget_low": | ||||||||||
| query = query.order("budget_min", { ascending: true, nullsFirst: false }); | ||||||||||
| break; | ||||||||||
| default: // newest | ||||||||||
| query = query.order("created_at", { ascending: false }); | ||||||||||
| default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time | ||||||||||
| query = query.order("ranked_at", { ascending: false }); | ||||||||||
|
Comment on lines
+138
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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! |
||||||||||
| } | ||||||||||
|
|
||||||||||
| // Apply pagination — ensure non-negative offset (#69) | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { getBoostEligibility, BOOST_COOLDOWN_DAYS } from "./boost"; | ||
|
|
||
| const DAY_MS = 24 * 60 * 60 * 1000; | ||
| const now = new Date("2026-06-14T00:00:00.000Z"); | ||
|
|
||
| function daysAgo(days: number): string { | ||
| return new Date(now.getTime() - days * DAY_MS).toISOString(); | ||
| } | ||
|
|
||
| describe("getBoostEligibility", () => { | ||
| it("is not eligible when created less than the cooldown ago", () => { | ||
| const result = getBoostEligibility({ created_at: daysAgo(3) }, now); | ||
| expect(result.eligible).toBe(false); | ||
| expect(result.nextEligibleAt).not.toBeNull(); | ||
| }); | ||
|
|
||
| it("is eligible once the cooldown has elapsed since creation", () => { | ||
| const result = getBoostEligibility({ created_at: daysAgo(BOOST_COOLDOWN_DAYS) }, now); | ||
| expect(result.eligible).toBe(true); | ||
| expect(result.nextEligibleAt).toBeNull(); | ||
| }); | ||
|
|
||
| it("is eligible well past the cooldown", () => { | ||
| const result = getBoostEligibility({ created_at: daysAgo(30) }, now); | ||
| expect(result.eligible).toBe(true); | ||
| }); | ||
|
|
||
| it("uses the last boost time, not creation, for the cooldown", () => { | ||
| const result = getBoostEligibility( | ||
| { created_at: daysAgo(30), boosted_at: daysAgo(2) }, | ||
| now | ||
| ); | ||
| expect(result.eligible).toBe(false); | ||
| }); | ||
|
|
||
| it("becomes eligible again a cooldown after the last boost", () => { | ||
| const result = getBoostEligibility( | ||
| { created_at: daysAgo(30), boosted_at: daysAgo(BOOST_COOLDOWN_DAYS) }, | ||
| now | ||
| ); | ||
| expect(result.eligible).toBe(true); | ||
| }); | ||
|
|
||
| it("reports the next eligible time as one cooldown after the reference", () => { | ||
| const result = getBoostEligibility({ created_at: daysAgo(1) }, now); | ||
| expect(result.nextEligibleAt).toBe( | ||
| new Date(now.getTime() + (BOOST_COOLDOWN_DAYS - 1) * DAY_MS).toISOString() | ||
| ); | ||
| }); | ||
|
|
||
| it("treats gigs with no timestamps as eligible rather than locked", () => { | ||
| expect(getBoostEligibility({}, now).eligible).toBe(true); | ||
| expect(getBoostEligibility({ created_at: "not-a-date" }, now).eligible).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Gig boosting rules, shared between the API route and the UI so eligibility | ||
| // is computed identically in both places. | ||
|
|
||
| /** A gig must be at least this old (since creation or its last boost) to boost again. */ | ||
| export const BOOST_COOLDOWN_DAYS = 7; | ||
|
|
||
| const COOLDOWN_MS = BOOST_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; | ||
|
|
||
| interface BoostableGig { | ||
| created_at?: string | null; | ||
| boosted_at?: string | null; | ||
| } | ||
|
|
||
| export interface BoostEligibility { | ||
| eligible: boolean; | ||
| /** ISO timestamp when the gig becomes boostable again, or null if already eligible. */ | ||
| nextEligibleAt: string | null; | ||
| } | ||
|
|
||
| /** | ||
| * A gig can be boosted once at least BOOST_COOLDOWN_DAYS have passed since it was | ||
| * created or last boosted (whichever is more recent). | ||
| */ | ||
| export function getBoostEligibility( | ||
| gig: BoostableGig, | ||
| now: Date = new Date() | ||
| ): BoostEligibility { | ||
| const reference = gig.boosted_at ?? gig.created_at; | ||
| if (!reference) { | ||
| // No timestamp to reason about — treat as eligible rather than locking it forever. | ||
| return { eligible: true, nextEligibleAt: null }; | ||
| } | ||
|
|
||
| const referenceMs = new Date(reference).getTime(); | ||
| if (!Number.isFinite(referenceMs)) { | ||
| return { eligible: true, nextEligibleAt: null }; | ||
| } | ||
|
|
||
| const nextEligibleMs = referenceMs + COOLDOWN_MS; | ||
| if (now.getTime() >= nextEligibleMs) { | ||
| return { eligible: true, nextEligibleAt: null }; | ||
| } | ||
|
|
||
| return { | ||
| eligible: false, | ||
| nextEligibleAt: new Date(nextEligibleMs).toISOString(), | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| -- Gig boosting: let posters bump a gig back to the top of the listing. | ||
| -- `boosted_at` records the most recent boost (null = never boosted). | ||
| -- `ranked_at` is the effective recency used for the default "newest" sort: | ||
| -- the later of when the gig was created and when it was last boosted. | ||
| ALTER TABLE gigs ADD COLUMN boosted_at TIMESTAMPTZ; | ||
|
|
||
| ALTER TABLE gigs | ||
| ADD COLUMN ranked_at TIMESTAMPTZ | ||
| GENERATED ALWAYS AS (GREATEST(created_at, boosted_at)) STORED; | ||
|
|
||
| -- Drive the default listing order off the effective recency. | ||
| CREATE INDEX idx_gigs_ranked_at ON gigs (ranked_at DESC); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 passgetBoostEligibility, 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.