← 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 });
  }
}