← back to Norma

app/api/contacts/route.ts

145 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
import { matchContactToOrgs } from '@/lib/contact-matcher';
import { createContactLinks } from '@/lib/contact-links';

/**
 * GET /api/contacts
 * List/search contacts with filtering and pagination.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { searchParams } = new URL(request.url);
    const q = searchParams.get('q');
    const company = searchParams.get('company');
    const enrichmentStatus = searchParams.get('enrichment_status');
    const hasOrgMatch = searchParams.get('has_org_match');
    const tag = searchParams.get('tag');
    const sort = searchParams.get('sort') || 'name';
    const sortDir = searchParams.get('sort_dir') === 'desc' ? 'DESC' : 'ASC';
    const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
    const offset = parseInt(searchParams.get('offset') || '0');

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

    const conditions: string[] = [];
    const params: unknown[] = [];
    let idx = 1;

    if (orgId) {
      conditions.push(`c.org_id = $${idx}`);
      params.push(orgId);
      idx++;
    }

    if (q) {
      conditions.push(`(to_tsvector('english', first_name || ' ' || last_name) @@ plainto_tsquery($${idx}) OR LOWER(company) LIKE '%' || LOWER($${idx}) || '%' OR LOWER(headline) LIKE '%' || LOWER($${idx}) || '%')`);
      params.push(q);
      idx++;
    }
    if (company) {
      conditions.push(`LOWER(company) = LOWER($${idx})`);
      params.push(company);
      idx++;
    }
    if (enrichmentStatus) {
      conditions.push(`enrichment_status = $${idx}`);
      params.push(enrichmentStatus);
      idx++;
    }
    if (hasOrgMatch === 'true') {
      conditions.push('matched_org_id IS NOT NULL');
    } else if (hasOrgMatch === 'false') {
      conditions.push('matched_org_id IS NULL');
    }
    if (tag) {
      conditions.push(`$${idx} = ANY(tags)`);
      params.push(tag);
      idx++;
    }

    const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';

    const sortMap: Record<string, string> = {
      name: 'first_name', company: 'company', connected_on: 'connected_on',
      relevance_score: 'relevance_score', created_at: 'created_at',
    };
    const orderCol = sortMap[sort] || 'first_name';

    const [dataResult, countResult] = await Promise.all([
      query(
        `SELECT c.*, o.name as matched_org_name
         FROM contacts c
         LEFT JOIN organizations o ON o.id = c.matched_org_id
         ${where}
         ORDER BY ${orderCol} ${sortDir} NULLS LAST
         LIMIT $${idx} OFFSET $${idx + 1}`,
        [...params, limit, offset],
      ),
      query(`SELECT COUNT(*) FROM contacts c ${where}`, params),
    ]);

    return NextResponse.json({
      contacts: dataResult.rows,
      total: parseInt(countResult.rows[0].count),
      limit,
      offset,
    });
  } catch (err) {
    console.error('[api/contacts] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch contacts' }, { status: 500 });
  }
}

/**
 * POST /api/contacts
 * Create a single contact.
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const data = await request.json();
    if (!data.first_name || !data.last_name) {
      return NextResponse.json({ error: 'first_name and last_name are required' }, { status: 400 });
    }

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

    const result = await query(
      `INSERT INTO contacts (first_name, last_name, email, company, position, linkedin_url, headline, location, source, org_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
       ON CONFLICT (LOWER(first_name), LOWER(last_name), LOWER(COALESCE(email, ''))) DO UPDATE
       SET company = COALESCE(EXCLUDED.company, contacts.company),
           position = COALESCE(EXCLUDED.position, contacts.position),
           linkedin_url = COALESCE(EXCLUDED.linkedin_url, contacts.linkedin_url),
           org_id = COALESCE(EXCLUDED.org_id, contacts.org_id),
           updated_at = NOW()
       RETURNING *`,
      [
        data.first_name.trim(), data.last_name.trim(),
        data.email || null, data.company || null, data.position || null,
        data.linkedin_url || null, data.headline || null, data.location || null,
        data.source || 'manual', orgId,
      ],
    );

    const contact = result.rows[0];

    // Auto-match org and create links
    await matchContactToOrgs(contact.id);
    await createContactLinks(contact.id);

    return NextResponse.json({ contact }, { status: 201 });
  } catch (err) {
    console.error('[api/contacts] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 });
  }
}