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
4 changes: 2 additions & 2 deletions apps/web/app/api/bounties/[id]/claim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 });

const { id } = await params;
const bountyId = parseInt(id);
const { coupon_id } = await req.json();
if (!coupon_id) return NextResponse.json({ error: 'coupon_id is required' }, { status: 400 });

const db = getDb();
const rows = await db.sql`SELECT * FROM bounties WHERE id = ${bountyId}`;
const rows = await db.sql`SELECT * FROM bounties WHERE public_id = ${id}`;
if (!rows.length) return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
const bounty = rows[0];
const bountyId = bounty.id;

if (!['open', 'funded'].includes(bounty.status)) {
return NextResponse.json({ error: `Bounty is already ${bounty.status}` }, { status: 409 });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/bounties/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
FROM bounties b
LEFT JOIN stores s ON s.id = b.store_id
LEFT JOIN coupons c ON c.id = b.coupon_id
WHERE b.id = ${parseInt(id)}
WHERE b.public_id = ${id}
`;
if (!rows.length) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(rows[0]);
Expand Down
108 changes: 57 additions & 51 deletions apps/web/app/api/bounties/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import { getSessionDid } from '@/lib/auth';
import { generatePublicId } from '@/lib/id';

const COINPAY_BASE = 'https://coinpayportal.com';
const API_KEY = process.env.COINPAY_API_KEY!;
Expand Down Expand Up @@ -36,63 +37,68 @@ export async function POST(req: NextRequest) {
if (isNaN(reward) || reward < 0.10) return NextResponse.json({ error: 'Minimum reward is $0.10' }, { status: 400 });
if (!store_id && !store_name?.trim()) return NextResponse.json({ error: 'Store is required' }, { status: 400 });

const db = getDb();
try {
const db = getDb();
const publicId = generatePublicId();

// Insert bounty as 'open' first to get an ID
await db.sql`
INSERT INTO bounties (creator_did, store_id, store_name, title, description, reward_usd, status)
VALUES (
${did},
${store_id ?? null},
${store_name?.trim() ?? null},
${title.trim()},
${description?.trim() ?? null},
${reward},
'open'
)
`;
const [{ id: bountyId }] = await db.sql`
SELECT id FROM bounties WHERE creator_did = ${did} ORDER BY id DESC LIMIT 1
`;
// Insert bounty as 'open' with a non-sequential public id for URLs.
await db.sql`
INSERT INTO bounties (public_id, creator_did, store_id, store_name, title, description, reward_usd, status)
VALUES (
${publicId},
${did},
${store_id ?? null},
${store_name?.trim() ?? null},
${title.trim()},
${description?.trim() ?? null},
${reward},
'open'
)
`;

// Create a CoinPay payment for the creator to fund the bounty
// Docs: POST /api/payments/create
let paymentAddress: string | null = null;
let paymentId: string | null = null;
// Create a CoinPay payment for the creator to fund the bounty
// Docs: POST /api/payments/create
let paymentAddress: string | null = null;
let paymentId: string | null = null;

try {
const res = await fetch(`${COINPAY_BASE}/api/payments/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({
business_id: MERCHANT_ID,
amount_usd: reward,
currency: 'usdc_pol', // default to USDC on Polygon — low fees
description: `Coupon bounty: ${title.trim()}`,
redirect_url: `${APP_URL}/bounties/${bountyId}?funded=1`,
metadata: { type: 'bounty_fund', bounty_id: bountyId, creator_did: did },
}),
});
try {
const res = await fetch(`${COINPAY_BASE}/api/payments/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({
business_id: MERCHANT_ID,
amount_usd: reward,
currency: 'usdc_pol', // default to USDC on Polygon — low fees
description: `Coupon bounty: ${title.trim()}`,
redirect_url: `${APP_URL}/bounties/${publicId}?funded=1`,
metadata: { type: 'bounty_fund', bounty_id: publicId, creator_did: did },
}),
});

if (res.ok) {
const data = await res.json();
paymentId = data.payment_id ?? data.id ?? null;
paymentAddress = data.payment_address ?? null;
if (paymentId) {
await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE id = ${bountyId}`;
if (res.ok) {
const data = await res.json();
paymentId = data.payment_id ?? data.id ?? null;
paymentAddress = data.payment_address ?? null;
if (paymentId) {
await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE public_id = ${publicId}`;
}
} else {
const err = await res.text();
console.error('CoinPay payment create error:', err);
}
} else {
const err = await res.text();
console.error('CoinPay payment create error:', err);
} catch (e) {
console.error('CoinPay payment create failed:', e);
}

return NextResponse.json({
id: publicId,
public_id: publicId,
payment_id: paymentId,
payment_address: paymentAddress,
pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null,
}, { status: 201 });
} catch (e) {
console.error('CoinPay payment create failed:', e);
console.error('Failed to create bounty:', e);
return NextResponse.json({ error: 'Failed to create bounty. Please try again.' }, { status: 500 });
}

return NextResponse.json({
id: bountyId,
payment_id: paymentId,
payment_address: paymentAddress,
pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null,
}, { status: 201 });
}
2 changes: 1 addition & 1 deletion apps/web/app/api/webhooks/coinpay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function POST(req: NextRequest) {
UPDATE bounties
SET status = 'funded', payment_id = ${event.data?.payment_id ?? event.id},
updated_at = ${new Date().toISOString()}
WHERE id = ${meta.bounty_id} AND status = 'open'
WHERE public_id = ${meta.bounty_id} AND status = 'open'
`;
console.log('Bounty funded:', meta.bounty_id);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/bounties/[id]/BountyClaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';

interface Coupon { id: number; title: string; code: string | null; }

export default function BountyClaim({ bountyId, storeId }: { bountyId: number; storeId: number | null }) {
export default function BountyClaim({ bountyPublicId, storeId }: { bountyPublicId: string; storeId: number | null }) {
const router = useRouter();
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [selectedId, setSelectedId] = useState('');
Expand All @@ -23,7 +23,7 @@ export default function BountyClaim({ bountyId, storeId }: { bountyId: number; s
setError('');
setLoading(true);
try {
const res = await fetch(`/api/bounties/${bountyId}/claim`, {
const res = await fetch(`/api/bounties/${bountyPublicId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coupon_id: parseInt(selectedId) }),
Expand Down
36 changes: 19 additions & 17 deletions apps/web/app/bounties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import BountyClaim from './BountyClaim';

interface Bounty {
id: number;
public_id: string;
title: string;
description: string | null;
reward_usd: number;
Expand All @@ -24,24 +25,25 @@ interface Bounty {
created_at: string;
}

async function getBounty(id: number): Promise<Bounty | null> {
try {
const db = getDb();
const rows = await db.sql`
SELECT b.*, COALESCE(s.name, b.store_name) AS display_store,
c.code AS coupon_code, c.title AS coupon_title
FROM bounties b
LEFT JOIN stores s ON s.id = b.store_id
LEFT JOIN coupons c ON c.id = b.coupon_id
WHERE b.id = ${id}
`;
return rows.length ? rows[0] : null;
} catch { return null; }
// Look up by public_id. We intentionally do NOT swallow DB errors here: a
// thrown query (e.g. paused DB node) must surface as an error, not a 404 —
// otherwise a real, existing bounty gets reported as "not found".
async function getBounty(publicId: string): Promise<Bounty | null> {
const db = getDb();
const rows = await db.sql`
SELECT b.*, COALESCE(s.name, b.store_name) AS display_store,
c.code AS coupon_code, c.title AS coupon_title
FROM bounties b
LEFT JOIN stores s ON s.id = b.store_id
LEFT JOIN coupons c ON c.id = b.coupon_id
WHERE b.public_id = ${publicId}
`;
return rows.length ? rows[0] : null;
}

export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const bounty = await getBounty(parseInt(id));
const bounty = await getBounty(id);
if (!bounty) return { title: 'Bounty not found' };
return {
title: `Bounty: ${bounty.title}`,
Expand All @@ -61,7 +63,7 @@ const COINPAY_BASE = 'https://coinpayportal.com';

export default async function BountyPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [bounty, did] = await Promise.all([getBounty(parseInt(id)), getSessionDid()]);
const [bounty, did] = await Promise.all([getBounty(id), getSessionDid()]);
if (!bounty) notFound();

const status = STATUS_LABELS[bounty.status] ?? { label: bounty.status, color: 'bg-gray-100 text-gray-600 border-gray-200' };
Expand Down Expand Up @@ -132,12 +134,12 @@ export default async function BountyPage({ params }: { params: Promise<{ id: str
)}

{canClaim && (
<BountyClaim bountyId={bounty.id} storeId={bounty.store_id} />
<BountyClaim bountyPublicId={bounty.public_id} storeId={bounty.store_id} />
)}

{!did && ['open', 'funded'].includes(bounty.status) && (
<a
href={`/api/auth/coinpay?returnTo=/bounties/${bounty.id}`}
href={`/api/auth/coinpay?returnTo=/bounties/${bounty.public_id}`}
className="w-full text-center bg-gray-900 hover:bg-gray-700 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
>
Connect with CoinPay to claim
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/bounties/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export default function NewBountyPage() {
});
const data = await res.json();
if (!res.ok) { setError(data.error ?? 'Failed to create bounty'); return; }
// If CoinPay returned a checkout URL, redirect there to fund the bounty
if (data.checkout_url) {
window.location.href = data.checkout_url;
// If CoinPay returned a payment URL, redirect there to fund the bounty
if (data.pay_url) {
window.location.href = data.pay_url;
} else {
router.push(`/bounties/${data.id}`);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/bounties/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const metadata: Metadata = {

interface Bounty {
id: number;
public_id: string;
title: string;
description: string | null;
reward_usd: number;
Expand Down Expand Up @@ -175,7 +176,7 @@ export default async function BountiesPage() {
{bounties.map((b) => (
<Link
key={b.id}
href={`/bounties/${b.id}`}
href={`/bounties/${b.public_id}`}
className="group border border-gray-200 rounded-xl p-5 flex items-center gap-4 hover:border-orange-300 hover:shadow-lg hover:shadow-orange-50 transition-all"
>
<div className="flex-1 min-w-0">
Expand Down
19 changes: 19 additions & 0 deletions apps/web/lib/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { randomBytes } from 'node:crypto';

// URL-safe, non-sequential public identifiers for resources whose integer
// primary keys should not be exposed in URLs (enumeration / count leakage).
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

/**
* Generate a short, URL-safe, non-sequential id (base62, ~71 bits at len 12).
* Collision probability is negligible at our scale; callers should still rely
* on a UNIQUE constraint as the source of truth.
*/
export function generatePublicId(length = 12): string {
const bytes = randomBytes(length);
let id = '';
for (let i = 0; i < length; i++) {
id += ALPHABET[bytes[i] % ALPHABET.length];
}
return id;
}
20 changes: 20 additions & 0 deletions apps/web/scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ import { Database } from '@sqlitecloud/drivers';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { randomBytes } from 'node:crypto';

// Mirror of apps/web/lib/id.ts — short, URL-safe, non-sequential public ids.
const ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function generatePublicId(length = 12) {
const bytes = randomBytes(length);
let id = '';
for (let i = 0; i < length; i++) id += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
return id;
}

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -117,6 +127,7 @@ console.log(' blog_posts');
await db.sql`
CREATE TABLE IF NOT EXISTS bounties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT,
creator_did TEXT NOT NULL,
store_id INTEGER REFERENCES stores(id),
store_name TEXT,
Expand All @@ -131,6 +142,15 @@ await db.sql`
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
// public_id: non-sequential URL identifier (added after initial release).
await addColumn(() => db.sql`ALTER TABLE bounties ADD COLUMN public_id TEXT`);
// Backfill any rows missing a public_id (existing bounties created pre-migration).
const needIds = await db.sql`SELECT id FROM bounties WHERE public_id IS NULL OR public_id = ''`;
for (const row of needIds) {
await db.sql`UPDATE bounties SET public_id = ${generatePublicId()} WHERE id = ${row.id}`;
}
if (needIds.length) console.log(` bounties: backfilled ${needIds.length} public_id(s)`);
await db.sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_bounties_public_id ON bounties(public_id)`;
console.log(' bounties');

const [{ n }] = await db.sql`SELECT COUNT(*) AS n FROM stores`;
Expand Down
Loading