Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/app/api/gigs/[id]/boost/route.ts
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();
Comment on lines +23 to +62

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


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 }
);
}
}
4 changes: 2 additions & 2 deletions src/app/api/gigs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

}

// Apply pagination — ensure non-negative offset (#69)
Expand Down
7 changes: 6 additions & 1 deletion src/app/dashboard/gigs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,12 @@ export default async function MyGigsPage({ searchParams }: MyGigsPageProps) {
</div>
</div>

<GigActions gigId={gig.id} status={gig.status} />
<GigActions
gigId={gig.id}
status={gig.status}
createdAt={gig.created_at}
boostedAt={gig.boosted_at}
/>
</div>

{(pendingByGig[gig.id]?.length ?? 0) > 0 && (
Expand Down
45 changes: 44 additions & 1 deletion src/components/gigs/GigActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,28 @@ import {
CheckCircle,
Loader2,
XCircle,
Rocket,
} from "lucide-react";
import Link from "next/link";
import { useDialog } from "@/components/providers/DialogProvider";
import { getBoostEligibility } from "@/lib/boost";

interface GigActionsProps {
gigId: string;
status: string;
createdAt?: string | null;
boostedAt?: string | null;
}

export function GigActions({ gigId, status }: GigActionsProps) {
export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsProps) {
const router = useRouter();
const { confirm } = useDialog();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const boost = getBoostEligibility({ created_at: createdAt, boosted_at: boostedAt });

const handleStatusChange = async (newStatus: string) => {
setIsLoading(true);
setError(null);
Expand All @@ -49,6 +55,23 @@ export function GigActions({ gigId, status }: GigActionsProps) {
router.refresh();
};

const handleBoost = async () => {
setIsLoading(true);
setError(null);

const result = await gigsApi.boost(gigId);

if (result.error) {
setError(result.error);
setIsLoading(false);
return;
}

setIsOpen(false);
setIsLoading(false);
router.refresh();
};

const handleDelete = async () => {
if (!await confirm("Are you sure you want to delete this gig? This action cannot be undone.")) {
return;
Expand Down Expand Up @@ -118,6 +141,26 @@ export function GigActions({ gigId, status }: GigActionsProps) {

{status === "active" && (
<>
{boost.eligible ? (
<button
onClick={handleBoost}
className="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded flex items-center gap-2 text-primary"
disabled={isLoading}
>
<Rocket className="h-4 w-4" />
Boost Gig
</button>
) : (
<div className="w-full px-3 py-2 text-left text-xs text-muted-foreground flex items-center gap-2">
<Rocket className="h-4 w-4 shrink-0" />
<span>
Boost available{" "}
{boost.nextEligibleAt
? new Date(boost.nextEligibleAt).toLocaleDateString()
: "soon"}
</span>
</div>
)}
<button
onClick={() => handleStatusChange("paused")}
className="w-full px-3 py-2 text-left text-sm hover:bg-muted rounded flex items-center gap-2"
Expand Down
2 changes: 2 additions & 0 deletions src/components/gigs/GigCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const baseGig = {
poster_id: "user-1",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
boosted_at: null,
ranked_at: new Date().toISOString(),
expires_at: null,
applications_count: 0,
duration: null,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export const gigs = {
method: "DELETE",
}),

boost: (id: string) =>
request(`/api/gigs/${id}/boost`, {
method: "POST",
}),

getMy: () => request("/api/gigs/my"),
};

Expand Down
56 changes: 56 additions & 0 deletions src/lib/boost.test.ts
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);
});
});
48 changes: 48 additions & 0 deletions src/lib/boost.ts
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(),
};
}
4 changes: 4 additions & 0 deletions src/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ export type Database = {
views_count: number;
created_at: string;
updated_at: string;
boosted_at: string | null;
ranked_at: string | null;
};
Insert: {
id?: string;
Expand All @@ -329,6 +331,7 @@ export type Database = {
views_count?: number;
created_at?: string;
updated_at?: string;
boosted_at?: string | null;
};
Update: {
id?: string;
Expand All @@ -352,6 +355,7 @@ export type Database = {
views_count?: number;
created_at?: string;
updated_at?: string;
boosted_at?: string | null;
};
Relationships: [
{
Expand Down
4 changes: 4 additions & 0 deletions src/types/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export type Database = {
Row: {
ai_tools_preferred: string[] | null
applications_count: number | null
boosted_at: string | null
budget_max: number | null
budget_min: number | null
budget_type: Database["public"]["Enums"]["budget_type"]
Expand All @@ -377,6 +378,7 @@ export type Database = {
location_type: Database["public"]["Enums"]["location_type"] | null
payment_coin: string | null
poster_id: string
ranked_at: string | null
skills_required: string[] | null
status: Database["public"]["Enums"]["gig_status"] | null
title: string
Expand All @@ -386,6 +388,7 @@ export type Database = {
Insert: {
ai_tools_preferred?: string[] | null
applications_count?: number | null
boosted_at?: string | null
budget_max?: number | null
budget_min?: number | null
budget_type: Database["public"]["Enums"]["budget_type"]
Expand All @@ -409,6 +412,7 @@ export type Database = {
Update: {
ai_tools_preferred?: string[] | null
applications_count?: number | null
boosted_at?: string | null
budget_max?: number | null
budget_min?: number | null
budget_type?: Database["public"]["Enums"]["budget_type"]
Expand Down
12 changes: 12 additions & 0 deletions supabase/migrations/20260614000000_add_gig_boost.sql
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);
Loading