← back to Norma

app/api/clients/route.ts

182 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
import { auditLog } from '@/lib/audit';

/**
 * GET /api/clients
 * List clients with filtering, searching, sorting, and pagination.
 *
 * Query params:
 *   q             - Full-text search on name, email
 *   client_type   - borrower | member | volunteer | donor | beneficiary | general
 *   status        - active | inactive | archived
 *   priority      - high | medium | low
 *   state         - Two-letter state code
 *   assigned_to   - Staff member name (exact match)
 *   sort          - name | intake_date | last_contact_date | debt_amount (default: name)
 *   sort_dir      - asc | desc (default: asc)
 *   limit         - Max records returned (default: 50, max: 200)
 *   offset        - Pagination offset (default: 0)
 */
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 clientType = searchParams.get('client_type');
    const status = searchParams.get('status');
    const priority = searchParams.get('priority');
    const state = searchParams.get('state');
    const assignedTo = searchParams.get('assigned_to');
    const sort = searchParams.get('sort') || 'name';
    const sortDir = searchParams.get('sort_dir') === 'desc' ? 'DESC' : 'ASC';
    const limit = Math.min(parseInt(searchParams.get('limit') || '50', 10), 200);
    const offset = parseInt(searchParams.get('offset') || '0', 10);

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

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

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

    if (q) {
      conditions.push(
        `(to_tsvector('english', first_name || ' ' || last_name) @@ plainto_tsquery($${idx}) OR LOWER(email) LIKE '%' || LOWER($${idx}) || '%')`,
      );
      params.push(q);
      idx++;
    }
    if (clientType) {
      conditions.push(`client_type = $${idx++}`);
      params.push(clientType);
    }
    if (status) {
      conditions.push(`status = $${idx++}`);
      params.push(status);
    }
    if (priority) {
      conditions.push(`priority = $${idx++}`);
      params.push(priority);
    }
    if (state) {
      conditions.push(`state = $${idx++}`);
      params.push(state);
    }
    if (assignedTo) {
      conditions.push(`LOWER(assigned_to) = LOWER($${idx++})`);
      params.push(assignedTo);
    }

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

    const sortMap: Record<string, string> = {
      name: 'last_name, first_name',
      intake_date: 'intake_date',
      last_contact_date: 'last_contact_date',
      debt_amount: 'debt_amount',
    };
    const orderCol = sortMap[sort] || 'last_name, first_name';

    const [dataResult, countResult] = await Promise.all([
      query(
        `SELECT * FROM clients ${where}
         ORDER BY ${orderCol} ${sortDir} NULLS LAST
         LIMIT $${idx} OFFSET $${idx + 1}`,
        [...params, limit, offset],
      ),
      query(`SELECT COUNT(*)::int AS total FROM clients ${where}`, params),
    ]);

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

/**
 * POST /api/clients
 * Create a new client / constituent record.
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await request.json();
    const {
      first_name, last_name, email, phone, city, state, zip,
      client_type, status, priority,
      case_notes, intake_date, assigned_to, tags,
      debt_amount, debt_type, loan_servicer, repayment_plan,
      last_contact_date, total_interactions, referral_source,
      custom_fields,
    } = body;

    if (!first_name || !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 { rows } = await query(
      `INSERT INTO clients (
        org_id,
        first_name, last_name, email, phone, city, state, zip,
        client_type, status, priority,
        case_notes, intake_date, assigned_to, tags,
        debt_amount, debt_type, loan_servicer, repayment_plan,
        last_contact_date, total_interactions, referral_source,
        custom_fields
      ) VALUES (
        $1,
        $2, $3, $4, $5, $6, $7, $8,
        $9, $10, $11,
        $12, $13, $14, $15,
        $16, $17, $18, $19,
        $20, $21, $22,
        $23
      ) RETURNING *`,
      [
        orgId,
        first_name.trim(), last_name.trim(), email || null, phone || null,
        city || null, state || null, zip || null,
        client_type || 'general', status || 'active', priority || 'medium',
        case_notes || null, intake_date || null, assigned_to || null, tags || [],
        debt_amount ?? null, debt_type || null, loan_servicer || null, repayment_plan || null,
        last_contact_date || null, total_interactions ?? 0, referral_source || null,
        custom_fields ? JSON.stringify(custom_fields) : '{}',
      ],
    );

    const client = rows[0];

    await auditLog(
      'client.created',
      'client',
      client.id,
      { org_id: orgId, name: `${first_name} ${last_name}`, client_type: client.client_type },
      request.headers.get('x-forwarded-for') ?? undefined,
    );

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