← back to Norma

app/api/social/ai-image/route.ts

146 lines

import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import { query } from '@/lib/db';

export const dynamic = 'force-dynamic';

const GEMINI_IMAGE_KEY = process.env.GEMINI_IMAGE_API_KEY || process.env.GEMINI_API_KEY || '';
const GEMINI_IMAGE_MODEL = 'gemini-2.5-flash-image';
const GEMINI_IMAGE_URL =
  `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_IMAGE_MODEL}:generateContent?key=${GEMINI_IMAGE_KEY}`;

const OUT_DIR = '/root/Projects/Norma/public/social-generated';

/**
 * POST /api/social/ai-image
 * Body: { prompt: string, post_id?: uuid }
 */
export async function POST(request: NextRequest) {
  let body: { prompt?: string; post_id?: string };
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
  }

  const { prompt, post_id } = body;
  if (!prompt || typeof prompt !== 'string') {
    return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
  }

  const recordError = async (errMsg: string) => {
    try {
      const ins = await query(
        `INSERT INTO social_ai_images (prompt, model, error, used_in_post)
         VALUES ($1, $2, $3, $4)
         RETURNING id`,
        [prompt, GEMINI_IMAGE_MODEL, errMsg, post_id ?? null],
      );
      return ins.rows[0]?.id ?? null;
    } catch (dbErr) {
      console.error('[ai-image] failed to record error row:', (dbErr as Error).message);
      return null;
    }
  };

  let geminiResponse: Response;
  try {
    geminiResponse = await fetch(GEMINI_IMAGE_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
      }),
    });
  } catch (err) {
    const msg = `Gemini fetch failed: ${(err as Error).message}`;
    console.error('[ai-image]', msg);
    const id = await recordError(msg);
    return NextResponse.json(
      { error: 'Gemini image service unavailable', id },
      { status: 500 },
    );
  }

  if (!geminiResponse.ok) {
    const errText = await geminiResponse.text().catch(() => '');
    const status = geminiResponse.status;
    const msg = `Gemini API ${status}: ${errText.slice(0, 300)}`;
    console.error('[ai-image]', msg);
    const id = await recordError(msg);

    if (status === 403 || status === 401) {
      return NextResponse.json(
        {
          error: 'Gemini image key needs refresh',
          detail: 'The image generation API key is revoked or unauthorized. Update GEMINI_IMAGE_KEY.',
          id,
        },
        { status: 500 },
      );
    }

    return NextResponse.json(
      { error: `Gemini image generation failed (${status})`, id },
      { status: 500 },
    );
  }

  let json: any;
  try {
    json = await geminiResponse.json();
  } catch (err) {
    const msg = `Invalid Gemini JSON: ${(err as Error).message}`;
    const id = await recordError(msg);
    return NextResponse.json({ error: msg, id }, { status: 500 });
  }

  // Find inlineData in parts
  const parts: any[] = json?.candidates?.[0]?.content?.parts ?? [];
  const imagePart = parts.find((p) => p?.inlineData?.data);
  const b64: string | undefined = imagePart?.inlineData?.data;

  if (!b64) {
    const msg = 'Gemini response contained no image data';
    const id = await recordError(msg);
    return NextResponse.json({ error: msg, id }, { status: 500 });
  }

  const fileId = randomUUID();
  const fileName = `${fileId}.png`;
  const filePath = path.join(OUT_DIR, fileName);
  const publicUrl = `/social-generated/${fileName}`;

  try {
    await fs.mkdir(OUT_DIR, { recursive: true });
    await fs.writeFile(filePath, Buffer.from(b64, 'base64'));
  } catch (err) {
    const msg = `Failed to write image: ${(err as Error).message}`;
    console.error('[ai-image]', msg);
    const id = await recordError(msg);
    return NextResponse.json({ error: msg, id }, { status: 500 });
  }

  try {
    const inserted = await query(
      `INSERT INTO social_ai_images (prompt, model, image_url, image_b64, used_in_post)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING id, prompt, image_url`,
      [prompt, GEMINI_IMAGE_MODEL, publicUrl, b64, post_id ?? null],
    );
    const row = inserted.rows[0];
    return NextResponse.json({
      id: row.id,
      image_url: row.image_url,
      prompt: row.prompt,
    });
  } catch (err) {
    const msg = `DB insert failed: ${(err as Error).message}`;
    console.error('[ai-image]', msg);
    return NextResponse.json({ error: msg, image_url: publicUrl }, { status: 500 });
  }
}