← back to Norma
app/api/integrations/route.ts
187 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
/**
* GET /api/integrations
* List all connected services with status.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const result = await query(
`SELECT id, service_id, name, category, status, config, metadata,
connected_by, connected_at, last_synced_at, error_message, updated_at
FROM connected_services
ORDER BY
CASE status WHEN 'connected' THEN 0 WHEN 'partial' THEN 1 ELSE 2 END,
name`
);
const connected = result.rows.filter((r: { status: string }) => r.status === 'connected').length;
return NextResponse.json({
services: result.rows,
total: result.rows.length,
connected,
});
}
/**
* PATCH /api/integrations
* Update a service's config, status, or metadata.
*/
export async function PATCH(request: NextRequest) {
const auth = requireRole(request, 'admin');
if (auth instanceof NextResponse) return auth;
const body = await request.json();
const { service_id, status, config, metadata } = body;
if (!service_id) return NextResponse.json({ error: 'service_id required' }, { status: 400 });
const sets: string[] = ['updated_at = NOW()'];
const values: unknown[] = [];
let idx = 1;
if (status) {
sets.push(`status = $${idx++}`);
values.push(status);
if (status === 'connected') {
sets.push(`connected_at = NOW()`);
sets.push(`connected_by = 'admin'`);
sets.push(`error_message = NULL`);
}
}
if (config) {
sets.push(`config = config || $${idx++}::jsonb`);
values.push(JSON.stringify(config));
}
if (metadata) {
sets.push(`metadata = metadata || $${idx++}::jsonb`);
values.push(JSON.stringify(metadata));
}
values.push(service_id);
const result = await query(
`UPDATE connected_services SET ${sets.join(', ')} WHERE service_id = $${idx} RETURNING *`,
values
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
return NextResponse.json({ service: result.rows[0] });
}
/**
* POST /api/integrations
* Test a service connection.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin');
if (auth instanceof NextResponse) return auth;
const { service_id, action } = await request.json();
if (!service_id || action !== 'test') {
return NextResponse.json({ error: 'service_id and action="test" required' }, { status: 400 });
}
// Service-specific connection tests
const tests: Record<string, () => Promise<{ ok: boolean; message: string }>> = {
slack: async () => {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return { ok: false, message: 'SLACK_WEBHOOK_URL not set in .env.local' };
try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'Norma connection test - all systems go!' }),
});
return res.ok
? { ok: true, message: 'Message sent to Slack successfully' }
: { ok: false, message: `Slack returned ${res.status}` };
} catch (err) {
return { ok: false, message: `Connection failed: ${(err as Error).message}` };
}
},
clickup: async () => {
const apiKey = process.env.CLICKUP_API_KEY;
if (!apiKey) return { ok: false, message: 'CLICKUP_API_KEY not set in .env.local' };
try {
const res = await fetch('https://api.clickup.com/api/v2/user', {
headers: { Authorization: apiKey },
});
if (res.ok) {
const data = await res.json();
return { ok: true, message: `Connected as ${data.user?.username || 'unknown'}` };
}
return { ok: false, message: `ClickUp returned ${res.status}` };
} catch (err) {
return { ok: false, message: `Connection failed: ${(err as Error).message}` };
}
},
action_network: async () => {
const apiKey = process.env.ACTION_NETWORK_API_KEY;
if (!apiKey) return { ok: false, message: 'ACTION_NETWORK_API_KEY not set in .env.local' };
try {
const res = await fetch('https://actionnetwork.org/api/v2/petitions?page=1', {
headers: { 'OSDI-API-Token': apiKey },
});
if (res.ok) {
const data = await res.json();
const count = data._embedded?.['osdi:petitions']?.length || 0;
return { ok: true, message: `Connected — ${count} petitions on page 1` };
}
return { ok: false, message: `Action Network returned ${res.status}` };
} catch (err) {
return { ok: false, message: `Connection failed: ${(err as Error).message}` };
}
},
fred_api: async () => {
const apiKey = process.env.FRED_API_KEY;
if (!apiKey) return { ok: false, message: 'FRED_API_KEY not set (free at stlouisfed.org)' };
try {
const res = await fetch(
`https://api.stlouisfed.org/fred/series?series_id=SLOAS&api_key=${apiKey}&file_type=json`
);
if (res.ok) return { ok: true, message: 'Connected to St. Louis Fed FRED API' };
return { ok: false, message: `FRED returned ${res.status}` };
} catch (err) {
return { ok: false, message: `Connection failed: ${(err as Error).message}` };
}
},
census_api: async () => {
const apiKey = process.env.CENSUS_API_KEY;
if (!apiKey) return { ok: false, message: 'CENSUS_API_KEY not set (free at api.census.gov)' };
try {
const res = await fetch(
`https://api.census.gov/data/2022/acs/acs5?get=NAME&for=state:06&key=${apiKey}`
);
if (res.ok) return { ok: true, message: 'Connected to US Census Bureau API' };
return { ok: false, message: `Census returned ${res.status}` };
} catch (err) {
return { ok: false, message: `Connection failed: ${(err as Error).message}` };
}
},
};
const testFn = tests[service_id];
if (!testFn) {
return NextResponse.json({ ok: true, message: `${service_id} marked as connected (no test available)` });
}
const result = await testFn();
// Update service status based on test result
await query(
`UPDATE connected_services
SET status = $1, error_message = $2, last_synced_at = NOW(), updated_at = NOW()
WHERE service_id = $3`,
[result.ok ? 'connected' : 'error', result.ok ? null : result.message, service_id]
);
return NextResponse.json(result);
}