← back to Norma

app/api/contacts/[id]/route.ts

165 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/:id
 * Full contact detail with work history, education, groups, and coworkers.
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const { id } = await params;

    const contactResult = await query(
      `SELECT c.*, o.name as matched_org_name, o.type as matched_org_type
       FROM contacts c
       LEFT JOIN organizations o ON o.id = c.matched_org_id
       WHERE c.id = $1${orgId ? ' AND c.org_id = $2' : ''}`,
      orgId ? [id, orgId] : [id],
    );
    if (contactResult.rowCount === 0) {
      return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
    }

    // Build the company list for coworker lookup from contact record
    const contact = contactResult.rows[0];
    const contactCompanies: string[] = [];
    if (contact.company) contactCompanies.push((contact.company as string).toLowerCase());

    const [workResult, eduResult, groupsResult, linksResult] = await Promise.all([
      query(
        `SELECT cwh.*, o.name as matched_org_name
         FROM contact_work_history cwh
         LEFT JOIN organizations o ON o.id = cwh.matched_org_id
         WHERE cwh.contact_id = $1
         ORDER BY cwh.is_current DESC, cwh.start_date DESC NULLS LAST`,
        [id],
      ),
      query(
        `SELECT * FROM contact_education WHERE contact_id = $1 ORDER BY end_year DESC NULLS LAST`,
        [id],
      ),
      query(
        `SELECT * FROM contact_groups WHERE contact_id = $1 ORDER BY group_name`,
        [id],
      ),
      query(
        `SELECT mml.*,
           CASE WHEN mml.source_type = 'contact' AND mml.source_id = $1::uuid
                THEN mml.target_type ELSE mml.source_type END as linked_type,
           CASE WHEN mml.source_type = 'contact' AND mml.source_id = $1::uuid
                THEN mml.target_id ELSE mml.source_id END as linked_id
         FROM mind_map_links mml
         WHERE (mml.source_type = 'contact' AND mml.source_id = $1::uuid)
            OR (mml.target_type = 'contact' AND mml.target_id = $1::uuid)
         ORDER BY mml.weight DESC`,
        [id],
      ),
    ]);

    // Find coworkers (contacts who share a company) -- uses work history from parallel batch
    const companies = (workResult.rows as Array<{ company_name: string }>).map(w => w.company_name).filter(Boolean);
    const uniqueCompanies = [...new Set([...contactCompanies, ...companies.map(c => c.toLowerCase())])];

    interface CoworkerRow { id: string; first_name: string; last_name: string; company: string; headline: string }
    let coworkers: CoworkerRow[] = [];
    if (uniqueCompanies.length > 0) {
      const cwResult = await query(
        `SELECT DISTINCT ON (c.id) c.id, c.first_name, c.last_name, c.company, c.headline
         FROM contacts c
         LEFT JOIN contact_work_history cwh ON cwh.contact_id = c.id
         WHERE c.id != $1
           AND (LOWER(c.company) = ANY($2) OR LOWER(cwh.company_name) = ANY($2))${orgId ? ' AND c.org_id = $3' : ''}
         LIMIT 20`,
        orgId ? [id, uniqueCompanies, orgId] : [id, uniqueCompanies],
      );
      coworkers = cwResult.rows as CoworkerRow[];
    }

    return NextResponse.json({
      contact: contact,
      work_history: workResult.rows,
      education: eduResult.rows,
      groups: groupsResult.rows,
      links: linksResult.rows,
      coworkers,
    });
  } catch (err) {
    console.error('[api/contacts/id] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch contact' }, { status: 500 });
  }
}

/**
 * PUT /api/contacts/:id
 * Update contact fields.
 */
export async function PUT(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;
  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    const { id } = await params;
    const data = await request.json();

    const allowedFields = [
      'first_name', 'last_name', 'email', 'company', 'position',
      'linkedin_url', 'headline', 'location', 'summary', 'website',
      'skills', 'groups', 'interests', 'tags', 'notes',
    ];

    const updates: string[] = [];
    const values: unknown[] = [];
    let idx = 1;

    for (const [key, value] of Object.entries(data)) {
      if (allowedFields.includes(key)) {
        updates.push(`${key} = $${idx++}`);
        values.push(value);
      }
    }

    if (updates.length === 0) {
      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
    }

    values.push(id);
    if (orgId) {
      values.push(orgId);
    }
    const result = await query(
      `UPDATE contacts SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${idx}${orgId ? ` AND org_id = $${idx + 1}` : ''} RETURNING *`,
      values,
    );

    if (result.rowCount === 0) {
      return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
    }

    // Re-match if company changed
    if (data.company) {
      await matchContactToOrgs(id);
      await createContactLinks(id);
    }

    return NextResponse.json({ contact: result.rows[0] });
  } catch (err) {
    console.error('[api/contacts/id] PUT error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 });
  }
}