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