← back to Norma

app/api/nonprofit/signup/route.ts

295 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { hashPassword } from '@/lib/auth';
import { auditLog } from '@/lib/audit';

// --------------------------------------------------------------------------
// Types
// --------------------------------------------------------------------------

interface NonprofitSignupBody {
  // Required
  org_name: string;
  contact_name: string;
  contact_email: string;
  password: string;

  // Optional org info
  ein?: string;
  website?: string;
  mission?: string;
  focus_areas?: string[];
  org_size?: string;
  annual_budget?: string;
  year_founded?: number;

  // Optional contact info
  contact_phone?: string;
  contact_title?: string;

  // Optional social links
  twitter_handle?: string;
  linkedin_url?: string;
  instagram_handle?: string;
  facebook_url?: string;
  youtube_url?: string;
  tiktok_handle?: string;

  // Optional address
  address_street?: string;
  address_city?: string;
  address_state?: string;
  address_zip?: string;

  // Optional integrations
  api_connections?: Record<string, unknown>;
}

// --------------------------------------------------------------------------
// Validation helpers
// --------------------------------------------------------------------------

function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function validateEIN(ein: string): boolean {
  // EIN format: XX-XXXXXXX (digits with optional dash)
  return /^\d{2}-?\d{7}$/.test(ein);
}

function sanitize(val: string | undefined | null): string | null {
  if (!val || typeof val !== 'string') return null;
  return val.trim() || null;
}

// --------------------------------------------------------------------------
// GET /api/nonprofit/signup?email=...
// Check if an email already exists (for front-end signup validation).
// No auth required.
// --------------------------------------------------------------------------

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const email = searchParams.get('email');

  if (!email) {
    return NextResponse.json(
      { error: 'Query parameter "email" is required' },
      { status: 400 },
    );
  }

  if (!validateEmail(email)) {
    return NextResponse.json(
      { error: 'Invalid email format' },
      { status: 400 },
    );
  }

  try {
    const result = await query(
      'SELECT id FROM nonprofit_accounts WHERE LOWER(contact_email) = LOWER($1)',
      [email.trim()],
    );

    return NextResponse.json({
      exists: (result.rowCount ?? 0) > 0,
    });
  } catch (err) {
    console.error('[api/nonprofit/signup] GET error:', (err as Error).message);
    return NextResponse.json(
      { error: 'Failed to check email' },
      { status: 500 },
    );
  }
}

// --------------------------------------------------------------------------
// POST /api/nonprofit/signup
// Create a new nonprofit account. No auth required.
// --------------------------------------------------------------------------

export async function POST(request: NextRequest) {
  try {
    const body: NonprofitSignupBody = await request.json();

    // ------------------------------------------------------------------
    // Validate required fields
    // ------------------------------------------------------------------

    const errors: string[] = [];

    if (!body.org_name || typeof body.org_name !== 'string' || body.org_name.trim() === '') {
      errors.push('org_name is required');
    }
    if (!body.contact_name || typeof body.contact_name !== 'string' || body.contact_name.trim() === '') {
      errors.push('contact_name is required');
    }
    if (!body.contact_email || typeof body.contact_email !== 'string' || body.contact_email.trim() === '') {
      errors.push('contact_email is required');
    } else if (!validateEmail(body.contact_email)) {
      errors.push('contact_email is not a valid email address');
    }
    if (!body.password || typeof body.password !== 'string' || body.password.length < 8) {
      errors.push('password is required and must be at least 8 characters');
    }

    if (errors.length > 0) {
      return NextResponse.json(
        { error: 'Validation failed', details: errors },
        { status: 400 },
      );
    }

    // ------------------------------------------------------------------
    // Validate optional fields
    // ------------------------------------------------------------------

    if (body.ein && !validateEIN(body.ein)) {
      return NextResponse.json(
        { error: 'Invalid EIN format. Expected XX-XXXXXXX' },
        { status: 400 },
      );
    }

    if (body.year_founded) {
      const year = Number(body.year_founded);
      if (isNaN(year) || year < 1800 || year > new Date().getFullYear()) {
        return NextResponse.json(
          { error: `year_founded must be between 1800 and ${new Date().getFullYear()}` },
          { status: 400 },
        );
      }
    }

    // ------------------------------------------------------------------
    // Check for duplicate email
    // ------------------------------------------------------------------

    const existing = await query(
      'SELECT id FROM nonprofit_accounts WHERE LOWER(contact_email) = LOWER($1)',
      [body.contact_email.trim()],
    );

    if (existing.rowCount && existing.rowCount > 0) {
      return NextResponse.json(
        { error: 'An account with this email already exists' },
        { status: 409 },
      );
    }

    // ------------------------------------------------------------------
    // Hash password and insert
    // ------------------------------------------------------------------

    const passwordHash = await hashPassword(body.password);

    const result = await query(
      `INSERT INTO nonprofit_accounts (
        org_name,
        ein,
        website,
        mission,
        focus_areas,
        contact_name,
        contact_email,
        contact_phone,
        contact_title,
        twitter_handle,
        linkedin_url,
        instagram_handle,
        facebook_url,
        youtube_url,
        tiktok_handle,
        address_street,
        address_city,
        address_state,
        address_zip,
        org_size,
        annual_budget,
        year_founded,
        api_connections,
        password_hash,
        onboarding_complete,
        onboarding_step
      ) VALUES (
        $1, $2, $3, $4, $5,
        $6, $7, $8, $9,
        $10, $11, $12, $13, $14, $15,
        $16, $17, $18, $19,
        $20, $21, $22, $23,
        $24, false, 1
      ) RETURNING id, org_name, contact_email, onboarding_step, created_at`,
      [
        body.org_name.trim(),
        sanitize(body.ein),
        sanitize(body.website),
        sanitize(body.mission),
        body.focus_areas && Array.isArray(body.focus_areas) ? body.focus_areas : null,
        body.contact_name.trim(),
        body.contact_email.trim().toLowerCase(),
        sanitize(body.contact_phone),
        sanitize(body.contact_title),
        sanitize(body.twitter_handle),
        sanitize(body.linkedin_url),
        sanitize(body.instagram_handle),
        sanitize(body.facebook_url),
        sanitize(body.youtube_url),
        sanitize(body.tiktok_handle),
        sanitize(body.address_street),
        sanitize(body.address_city),
        sanitize(body.address_state),
        sanitize(body.address_zip),
        sanitize(body.org_size),
        sanitize(body.annual_budget),
        body.year_founded ? Number(body.year_founded) : null,
        body.api_connections ? JSON.stringify(body.api_connections) : '{}',
        passwordHash,
      ],
    );

    const account = result.rows[0];

    // Audit log
    const ip =
      request.headers.get('x-forwarded-for') ||
      request.headers.get('x-real-ip') ||
      undefined;
    await auditLog(
      'nonprofit.signup',
      'nonprofit_account',
      account.id,
      { org_name: body.org_name.trim(), contact_email: body.contact_email.trim() },
      ip,
    );

    return NextResponse.json(
      {
        id: account.id,
        org_name: account.org_name,
        contact_email: account.contact_email,
        onboarding_step: account.onboarding_step,
        created_at: account.created_at,
        message: 'Account created successfully',
      },
      { status: 201 },
    );
  } catch (err) {
    console.error('[api/nonprofit/signup] POST error:', (err as Error).message);

    // Handle unique constraint violation (race condition on email)
    if ((err as Record<string, unknown>).code === '23505') {
      return NextResponse.json(
        { error: 'An account with this email already exists' },
        { status: 409 },
      );
    }

    return NextResponse.json(
      { error: 'Failed to create account' },
      { status: 500 },
    );
  }
}