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