← back to Norma

app/api/social/competitors/crawl/route.ts

90 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { crawlSocialLinks, type FoundHandle } from '@/lib/social-crawl';

/**
 * POST /api/social/competitors/crawl
 *
 * Fetches a website URL (plus common sub-pages), extracts every social media
 * handle it can find, deduplicates them, and upserts each one into the
 * social_competitors table.
 *
 * Body: { url: string, category?: string, expertise_area?: string }
 *
 * Response:
 *   { success: true,  url, found: [{ platform, handle, display_name, status }],
 *     total_found: N, total_created: N }
 *   { success: false, error: string, url }
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const body = await request.json();
  const { url, category = 'competitor', expertise_area } = body as {
    url: string;
    category?: string;
    expertise_area?: string;
  };

  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'url is required' }, { status: 400 });
  }

  // ── crawl ──────────────────────────────────────────────────────────────────
  const crawl = await crawlSocialLinks(url);

  if (!crawl.ok) {
    return NextResponse.json({ success: false, error: crawl.error, url: crawl.normalizedUrl });
  }

  if (crawl.found.length === 0) {
    return NextResponse.json({
      success: true,
      url: crawl.normalizedUrl,
      found: [],
      total_found: 0,
      total_created: 0,
    });
  }

  // ── upsert each found handle ───────────────────────────────────────────────
  const results: Array<FoundHandle & { status: 'created' | 'existing' }> = [];
  let totalCreated = 0;

  for (const { platform, handle } of crawl.found) {
    const upsertResult = await query(
      `INSERT INTO social_competitors
         (platform, handle, display_name, category, expertise_area)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (platform, handle) DO UPDATE SET
         display_name   = COALESCE(EXCLUDED.display_name, social_competitors.display_name),
         category       = EXCLUDED.category,
         expertise_area = COALESCE(EXCLUDED.expertise_area, social_competitors.expertise_area),
         is_active      = true
       RETURNING id, (xmax = 0) AS inserted`,
      [platform, handle, crawl.siteName || null, category, expertise_area || null]
    );

    // xmax = 0 means the row was freshly inserted (not updated by the ON CONFLICT path)
    const wasInserted = upsertResult.rows[0]?.inserted === true;
    if (wasInserted) totalCreated++;

    results.push({
      platform,
      handle,
      display_name: crawl.siteName || null,
      status: wasInserted ? 'created' : 'existing',
    });
  }

  return NextResponse.json({
    success: true,
    url: crawl.normalizedUrl,
    found: results,
    total_found: results.length,
    total_created: totalCreated,
  });
}