import { eq, desc, sql } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { getSession } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { spendCreditForMessage, bedelsizMesajKaydet, grantCredits, krediBittiIsaretle, } from "@/lib/credits"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client"; import { kullanicininRaporu, sohbetGecmisiGetir } from "@/features/rapor/rapor-queries"; import { barajTablosu, type AracBaglami } from "@/lib/ai/araclar"; import { danismanAkisi } from "@/lib/ai/sohbet-akisi"; import { sistemPromptu, sohbetListeOzeti } from "@/lib/ai/danisman-prompt"; import { modelPenceresi, onayMesajiMi } from "@/lib/ai/sohbet-gecmisi"; import { profilGetir } from "@/lib/danisman-profil-db"; import { listeOzetiCikar } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import type { RaporParams } from "@/features/rapor/types/rapor"; const MAX_GECMIS = 12; /** Akış cevabının başlıkları; X-Kredi-Dusuldu'yu istemci bakiye için okur. */ const akisBasliklari = (bedelsiz: boolean) => ({ "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", "X-Kredi-Dusuldu": bedelsiz ? "0" : "1", }); const MAX_MESAJ_UZUNLUK = 2000; // Widget açıldığında geçmişi ve kredi bakiyesini döner export async function GET() { const session = await getSession(); if (!session) { return Response.json({ error: "Oturum gerekli." }, { status: 401 }); } const [mesajlar, kullanici] = await Promise.all([ sohbetGecmisiGetir(session.user.id), appDb.query.user.findFirst({ where: eq(schema.user.id, session.user.id), columns: { creditBalance: true }, }), ]); return Response.json({ mesajlar, kredi: kullanici?.creditBalance ?? 0, }); } // Kullanıcının sohbet geçmişini siler. Kredi iadesi YOK: harcanan krediler // ledger'da kalır, yalnızca mesajlar temizlenir (bir sonraki soruda danışman // sıfırdan başlar; liste/özet bağlamı prompt'tan geldiği için korunur). export async function DELETE() { const session = await getSession(); if (!session) { return Response.json({ error: "Oturum gerekli." }, { status: 401 }); } await appDb .delete(schema.chatMessages) .where(eq(schema.chatMessages.userId, session.user.id)); return Response.json({ ok: true }); } export async function POST(request: NextRequest) { // Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı // kalır (önce 401, sonra 400). const [session, hamBody] = await Promise.all([ getSession(), request.json().catch(() => null), ]); if (!session) { return Response.json({ error: "Oturum gerekli." }, { status: 401 }); } if (hamBody === null || typeof hamBody !== "object") { return Response.json({ error: "Geçersiz istek." }, { status: 400 }); } const body = hamBody as { message?: unknown; clientMessageId?: unknown }; const mesaj = typeof body.message === "string" ? body.message.trim() : ""; const clientMessageId = typeof body.clientMessageId === "string" ? body.clientMessageId : ""; if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) { return Response.json({ error: "Geçersiz mesaj." }, { status: 400 }); } // Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel. // Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın; // hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli. const [rapor, gecmisHam, kullanici, profil] = await Promise.all([ // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz) kullanicininRaporu(session.user.id), appDb .select() .from(schema.chatMessages) .where(eq(schema.chatMessages.userId, session.user.id)) .orderBy(desc(schema.chatMessages.createdAt), sql`rowid DESC`) .limit(MAX_GECMIS), appDb.query.user.findFirst({ where: eq(schema.user.id, session.user.id), columns: { hasPaket: true }, }), profilGetir(session.user.id), ]); if (!rapor?.result) { return Response.json( { error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" }, { status: 409 }, ); } const gecmis = gecmisHam.reverse(); // Profil onayı ("kaydedeyim mi?" → "evet") kredi harcamaz; iade de yok. // Yalnız geçmişteki EN SON mesaj danışmanın sorusuysa: aynı soruya art arda // gönderilen "evet"ler bedelsiz model çağrısı açmasın. const sonMesaj = gecmis.at(-1); const sonAsistan = sonMesaj?.role === "assistant" ? sonMesaj.content : undefined; const bedelsiz = onayMesajiMi(mesaj, sonAsistan); // Kredi düşümü stream'den önce — yetersizse hiç başlama const harcama = bedelsiz ? await bedelsizMesajKaydet({ userId: session.user.id, content: mesaj, clientMessageId }) : await spendCreditForMessage({ userId: session.user.id, content: mesaj, clientMessageId, }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(session.user.id); return Response.json( { error: "Kredin bitti.", code: "INSUFFICIENT" }, { status: 402 }, ); } return Response.json( { error: "Bu mesaj zaten işlendi.", code: "DUPLICATE" }, { status: 409 }, ); } const kullaniciMesajId = harcama.messageId!; // Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi // halde danışman kilitli listeyi sözle sızdırabilir. const kilitli = !kullanici?.hasPaket; const raporSonuc = rapor.result as RaporSonuc; const tamOzet = listeOzetiCikar( raporSonuc, (rapor.params as RaporParams).sira, { kimlik: true, gerekce: true }, ); const listeOzeti = sohbetListeOzeti(tamOzet, kilitli); const raporParams = rapor.params as RaporParams; const system = sistemPromptu({ params: raporParams, listeOzeti, kilitli, profil, barajlar: barajTablosu(), rapor: { genelDegerlendirme: raporSonuc.genelDegerlendirme, uyarilar: raporSonuc.uyarilar, kapsam: raporSonuc.kapsam ? { toplam: raporSonuc.kapsam.toplam, uyarlandi: raporSonuc.kapsam.uyarlandi } : undefined, }, }); // Araçların bağlamı: aday sırası/puan türü (arama süzgeci), profil // (profil_guncelle yazınca aynı tur içinde tazelenir) ve sihirbaz seçimleri // (uyum puanı "öne çıkan" cümleleri ve özel kontenjan eşlemesi için). const aracBaglami: AracBaglami = { userId: session.user.id, sira: raporParams.sira, tur: raporParams.tur, profil, oncelikler: raporParams.oncelikler ?? [], ozelDurum: raporParams.ozelDurum ?? [], sihirbazIlVar: (raporParams.iller?.length ?? 0) > 0 || Boolean(raporParams.il), }; // Dev'de API key yoksa danışman mock cevap stream'ler (prod'da asla) if (process.env.NODE_ENV !== "production" && !anahtarVar()) { const mockUserId = session.user.id; const cevap = `(Dev mock — Yapay Zeka anahtarı tanımlı değil.) Sorunu aldım: "${mesaj.slice(0, 120)}". ` + `Gerçek danışman burada listen ve seçimlerin bağlamında yanıt verecek. ` + `${SORUMLULUK_REDDI}`; const mockStream = createUIMessageStream({ execute: async ({ writer }) => { const id = crypto.randomUUID(); writer.write({ type: "start" }); writer.write({ type: "text-start", id }); for (const parca of cevap.split(/(?<= )/)) { writer.write({ type: "text-delta", id, delta: parca }); await new Promise((r) => setTimeout(r, 25)); } writer.write({ type: "text-end", id }); await appDb.insert(schema.chatMessages).values({ id: crypto.randomUUID(), userId: mockUserId, role: "assistant", content: cevap, createdAt: new Date(), }); writer.write({ type: "finish" }); }, }); return createUIMessageStreamResponse({ stream: mockStream, headers: akisBasliklari(bedelsiz), }); } if (!anahtarVar()) { if (!bedelsiz) { await grantCredits({ userId: session.user.id, delta: 1, reason: "refund", refId: kullaniciMesajId, }); } console.error("[soru] Yapay Zeka anahtarı tanımlı değil"); return Response.json( { error: "Danışman şu anda yanıt veremiyor; kredin iade edildi. Birazdan tekrar dener misin?", }, { status: 503 }, ); } // Cevapsız kalmış (iade edilmiş) sorular ve danışmanla başlayan pencere // modele bozuk sıra göndermesin const mesajlar = modelPenceresi(gecmis, mesaj); const userId = session.user.id; const cevabiKaydet = (metin: string) => appDb.insert(schema.chatMessages).values({ id: crypto.randomUUID(), userId, role: "assistant", content: metin, createdAt: new Date(), }); // Cevap AI SDK'nın UI mesaj akışıyla gider (lib/ai/sohbet-akisi.ts; // istemci: useChat). Kayıt ve kredi iadesi burada kalır. const stream = danismanAkisi({ system, mesajlar, aracBaglami, onTamam: async (metin) => { await cevabiKaydet(metin); }, // Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa yine // de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa yenilenince // kaybetmesin. onYarim: async (metin) => { if (metin) await cevabiKaydet(metin).catch(() => {}); if (!bedelsiz) { await grantCredits({ userId, delta: 1, reason: "refund", refId: kullaniciMesajId, }).catch(() => {}); } }, }); return createUIMessageStreamResponse({ stream, headers: akisBasliklari(bedelsiz), }); }