← back to Stayclaim
src/app/api/promote/route.ts
95 lines
/**
* POST /api/promote — sponsored placement intake.
*
* Stores the submission as `pending`. We email-verify and confirm
* affiliation before flipping to `approved`, then `active` after
* Stripe checkout (Stripe wiring lives behind the RAK that's pending).
*
* NOTE: this route is allowed to import from sponsored/* per
* .eslintrc.json overrides (api routes are firewall-aware glue).
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { FREE_FOREVER } from '@/lib/flags';
import { rateLimit } from '@/lib/rate-limit';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Soft-disabled when FREE_FOREVER=true. Returns 410 Gone.
async function checkRetired(): Promise<NextResponse | null> {
if (FREE_FOREVER) {
return NextResponse.json(
{ error: 'retired', message: 'Promotion has been retired. The archive is free forever — no paid placements.' },
{ status: 410 }
);
}
return null;
}
const Body = z.object({
listing_id: z.string().uuid(),
source: z.enum(['zillow', 'redfin', 'airbnb', 'other']),
// Restrict to http(s) only — z.string().url() permits javascript:/data:/file:.
external_url: z.string().url().refine((u) => /^https?:\/\//i.test(u), {
message: 'external_url must be http(s)',
}),
headline: z.string().min(1).max(200),
price_label: z.string().max(80).optional(),
contact_email: z.string().email(),
affiliation: z.string().min(10).max(1000),
permit_number: z.string().max(80).optional(),
});
export async function POST(req: Request) {
const retired = await checkRetired();
if (retired) return retired;
const rl = await rateLimit('promote');
if (!rl.ok) {
return NextResponse.json(
{ error: 'rate_limited' },
{ status: 429, headers: { 'Retry-After': String(rl.retryAfter ?? 600) } },
);
}
let parsed;
try {
parsed = Body.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json(
{ error: 'invalid body', fieldErrors: e.flatten().fieldErrors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'invalid body' }, { status: 400 });
}
try {
const r = await pool.query(
`INSERT INTO sponsored_placement
(listing_id, source, external_url, headline, price_label, contact_email, affiliation, permit_number)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, status, submitted_at`,
[
parsed.listing_id,
parsed.source,
parsed.external_url,
parsed.headline,
parsed.price_label ?? null,
parsed.contact_email.trim().toLowerCase(),
parsed.affiliation,
parsed.permit_number ?? null,
]
);
return NextResponse.json(r.rows[0], { status: 201 });
} catch (err) {
console.error('[api/promote] insert failed', err);
const code = (err as { code?: string } | null)?.code;
if (code === '23503') return NextResponse.json({ error: 'unknown listing' }, { status: 422 });
if (code === '23505') return NextResponse.json({ error: 'duplicate' }, { status: 409 });
return NextResponse.json({ error: 'internal error' }, { status: 500 });
}
}