← back to Norma
app/api/xsessions/route.ts
197 lines
import { NextRequest, NextResponse } from 'next/server';
import { query, getClient } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
const GEMINI_KEY = process.env.GEMINI_API_KEY || '';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
/**
* GET /api/xsessions
* Fetch x_sessions with post counts, ordered by created_at DESC.
* Optional: ?status=done|pending|running|error
*/
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 status = searchParams.get('status');
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (status) {
conditions.push(`s.status = $${paramIndex}`);
values.push(status);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query(
`SELECT s.*,
COUNT(p.id)::int AS actual_post_count,
COUNT(p.id) FILTER (WHERE p.is_used = true)::int AS used_post_count
FROM x_sessions s
LEFT JOIN x_posts p ON p.x_session_id = s.id
${whereClause}
GROUP BY s.id
ORDER BY s.created_at DESC`,
values
);
// Also get aggregate stats
const statsResult = await query(
`SELECT
COUNT(DISTINCT s.id)::int AS total_sessions,
COUNT(p.id)::int AS total_posts,
COUNT(p.id) FILTER (WHERE p.is_used = true)::int AS posts_used
FROM x_sessions s
LEFT JOIN x_posts p ON p.x_session_id = s.id`
);
return NextResponse.json({
sessions: result.rows,
stats: statsResult.rows[0],
});
} catch (err) {
console.error('[api/xsessions] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch X sessions' }, { status: 500 });
}
}
/**
* POST /api/xsessions
* Create a new X session. Use Gemini to generate sample posts.
* Body: { query_params: { terms: string } }
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const client = await getClient();
try {
const body = await request.json();
const queryParams = body.query_params || { terms: 'advocacy' };
const searchTerms = queryParams.terms || 'advocacy';
await client.query('BEGIN');
// Create the session
const sessionResult = await client.query(
`INSERT INTO x_sessions (query_params, status)
VALUES ($1, 'running')
RETURNING *`,
[JSON.stringify(queryParams)]
);
const session = sessionResult.rows[0];
// Generate posts with Gemini
const prompt = `Generate 10 realistic Twitter/X posts about "${searchTerms}" related to nonprofit advocacy and public policy.
Return a JSON array of objects with these fields:
- author_handle: realistic Twitter handle (without @)
- author_name: realistic full name
- content: tweet text (max 280 chars, include hashtags naturally)
- posted_at: ISO date string within the last 48 hours from now (${new Date().toISOString()})
- metrics: { likes: number, retweets: number, replies: number, views: number }
- hashtags: array of hashtags used (without #)
- has_media: boolean
Make the posts diverse - mix of personal stories, policy commentary, news sharing, calls to action, and data-driven takes. Include a range of engagement levels. Some should have high engagement, some moderate, some low.
Return ONLY the JSON array, no markdown fences or other text.`;
const geminiRes = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.9, maxOutputTokens: 4096 },
}),
});
let posts: Array<{
author_handle: string;
author_name: string;
content: string;
posted_at: string;
metrics: Record<string, number>;
hashtags: string[];
has_media: boolean;
}> = [];
if (geminiRes.ok) {
const geminiData = await geminiRes.json();
const rawText = geminiData.candidates?.[0]?.content?.parts?.[0]?.text || '[]';
// Strip markdown fences
const cleaned = rawText.replace(/```(?:json)?\s*/g, '').replace(/```\s*/g, '').trim();
try {
posts = JSON.parse(cleaned);
} catch {
console.error('[xsessions] Failed to parse Gemini response:', cleaned.slice(0, 200));
posts = [];
}
}
// Insert posts
let insertedCount = 0;
for (let i = 0; i < posts.length; i++) {
const p = posts[i];
try {
await client.query(
`INSERT INTO x_posts (x_session_id, x_post_id, author_handle, author_name, content, posted_at, metrics, hashtags, media_urls)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
session.id,
`gen_${session.id.slice(0, 8)}_${i}`,
p.author_handle || 'unknown',
p.author_name || 'Unknown User',
p.content || '',
p.posted_at || new Date().toISOString(),
JSON.stringify(p.metrics || {}),
p.hashtags || [],
p.has_media ? ['https://pbs.twimg.com/media/placeholder.jpg'] : [],
]
);
insertedCount++;
} catch (insertErr) {
console.error('[xsessions] Post insert error:', (insertErr as Error).message);
}
}
// Update session
await client.query(
`UPDATE x_sessions SET status = 'done', post_count = $1, ended_at = now() WHERE id = $2`,
[insertedCount, session.id]
);
await client.query('COMMIT');
// Fetch the completed session with posts
const finalResult = await query(
`SELECT * FROM x_sessions WHERE id = $1`, [session.id]
);
const postsResult = await query(
`SELECT * FROM x_posts WHERE x_session_id = $1 ORDER BY posted_at DESC`, [session.id]
);
return NextResponse.json(
{
session: { ...finalResult.rows[0], posts: postsResult.rows },
},
{ status: 201 }
);
} catch (err) {
await client.query('ROLLBACK');
// Try to mark session as error
console.error('[api/xsessions] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create X session' }, { status: 500 });
} finally {
client.release();
}
}