"use server"; import { and, eq, sql } from "drizzle-orm"; import { getSession, getCurrentUser } from "@/lib/session"; import { appDb, schema } from "@/lib/appdb"; import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db"; import { spendCredits, grantCredits, krediBittiIsaretle, RAPOR_KREDI, MAX_REVIZYON, } from "@/lib/credits"; import { raporUret, listeOzetiCikar, RaporUretimHatasi, type RaporSonuc, } from "@/lib/ai/rapor"; import type { RaporParams } from "@/lib/rapor-havuzu"; import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz"; import { raporMaskele } from "@/lib/rapor-maske"; // Modal yerinde render ettiği için action redirect etmez; sonucu döner. export type ListeSonuc = | { ok: true; rapor: RaporSonuc; params: RaporParams; revisionCount: number; kredi: number; hasPaket: boolean; } | { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" }; function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey { return ( Number.isFinite(sira) && sira >= 1 && sira <= 4_000_000 && tur in PUAN_TURLERI ); } function aiHataMesaji(err: unknown): string { if (err instanceof RaporUretimHatasi) return err.message; // AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi // hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala. if (err instanceof Error && /abort|timeout/i.test(err.name)) { return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?"; } if (err instanceof Error && err.message === "AI_KEY_MISSING") { // Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj console.error("[liste] Yapay Zeka anahtarı eksik — üretim başlatılamadı"); return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir."; } console.error("[liste] üretim hatası:", err); return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?"; } function paramsBirlestir( sira: number, tur: PuanTuruKey, secimler: SihirbazSecimleri, ): RaporParams { return { sira: Math.round(sira), tur, kategoriler: secimler.kategoriler, iller: secimler.iller, universiteTipi: secimler.universiteTipi, oncelikler: secimler.oncelikler, }; } export async function listeOlustur(input: { sira: number; tur: string; secimler: unknown; requestId: string; }): Promise { const session = await getSession(); if (!session) { return { ok: false, code: "AUTH", error: "Giriş yapmalısın." }; } const user = await getCurrentUser(); if (!user) { return { ok: false, code: "AUTH", error: "Giriş yapmalısın." }; } // Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi // burada yanmaya başlar). Paketsize dönen rapor maskelenir. if (!siraTurGecerli(input.sira, input.tur)) { return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." }; } const secimler = sihirbazDogrula(input.secimler); if (!secimler) { return { ok: false, code: "HATA", error: "En az bir ilgi alanı seçmelisin.", }; } if (!input.requestId || typeof input.requestId !== "string") { return { ok: false, code: "HATA", error: "Geçersiz istek." }; } const params = paramsBirlestir(input.sira, input.tur, secimler); // 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz) const harcama = await spendCredits({ userId: user.id, amount: RAPOR_KREDI, reason: "report_generate", refId: input.requestId, }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(user.id); return { ok: false, code: "KREDI", error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`, }; } // DUPLICATE: bu requestId zaten işlendi. Önceki deneme başarısız olup // iade edildiyse mevcut (eski) raporu yeniymiş gibi döndürme; taze bir // requestId ile yeniden denemesini iste. const iade = await appDb.query.creditLedger.findFirst({ where: and( eq(schema.creditLedger.reason, "refund"), eq(schema.creditLedger.refId, input.requestId), ), }); if (iade) { return { ok: false, code: "HATA", error: "Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?", }; } const mevcut = await appDb.query.reports.findFirst({ where: eq(schema.reports.userId, user.id), }); if (mevcut?.result) { const rapor = mevcut.result as RaporSonuc; return { ok: true, rapor: user.hasPaket ? rapor : raporMaskele(rapor), params: mevcut.params as RaporParams, revisionCount: mevcut.revisionCount, kredi: user.creditBalance, hasPaket: user.hasPaket, }; } return { ok: false, code: "HATA", error: "İstek tekrarlandı." }; } let sonuc: RaporSonuc; try { sonuc = await raporUret(params); } catch (err) { // Üretim başarısız → krediyi iade et await grantCredits({ userId: user.id, delta: RAPOR_KREDI, reason: "refund", refId: input.requestId, }).catch(() => {}); return { ok: false, code: "HATA", error: aiHataMesaji(err) }; } const now = new Date(); await appDb .insert(schema.reports) .values({ id: crypto.randomUUID(), userId: user.id, params, result: sonuc, revisionCount: 0, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: schema.reports.userId, set: { params, result: sonuc, revisionCount: 0, updatedAt: now }, }); return { ok: true, rapor: user.hasPaket ? sonuc : raporMaskele(sonuc), params, revisionCount: 0, kredi: user.creditBalance - RAPOR_KREDI, hasPaket: user.hasPaket, }; } export async function listeRevize(input: { feedback: string; requestId: string; }): Promise { const session = await getSession(); if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." }; const user = await getCurrentUser(); if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." }; if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." }; const feedback = (input.feedback ?? "").trim(); if (feedback.length < 5) { return { ok: false, code: "HATA", error: "Ne değişsin istediğini kısaca yazmalısın.", }; } if (!input.requestId) { return { ok: false, code: "HATA", error: "Geçersiz istek." }; } const rapor = await appDb.query.reports.findFirst({ where: eq(schema.reports.userId, user.id), }); if (!rapor?.result) { return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." }; } if (rapor.revisionCount >= MAX_REVIZYON) { return { ok: false, code: "HATA", error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`, }; } const params = rapor.params as RaporParams; const onceki = rapor.result as RaporSonuc; const harcama = await spendCredits({ userId: user.id, amount: RAPOR_KREDI, reason: "report_revision", refId: input.requestId, }); if (!harcama.ok) { if (harcama.error === "INSUFFICIENT") { await krediBittiIsaretle(user.id); return { ok: false, code: "KREDI", error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`, }; } // DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme // başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış // ama cevap istemciye ulaşmamıştır → güncel raporu döndür. const iade = await appDb.query.creditLedger.findFirst({ where: and( eq(schema.creditLedger.reason, "refund"), eq(schema.creditLedger.refId, input.requestId), ), }); if (iade) { return { ok: false, code: "HATA", error: "Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?", }; } const guncel = await appDb.query.reports.findFirst({ where: eq(schema.reports.userId, user.id), }); if (guncel?.result) { return { ok: true, rapor: guncel.result as RaporSonuc, params: guncel.params as RaporParams, revisionCount: guncel.revisionCount, kredi: user.creditBalance, hasPaket: true, }; } return { ok: false, code: "HATA", error: "İstek tekrarlandı." }; } let sonuc: RaporSonuc; try { sonuc = await raporUret( params, feedback.slice(0, 1000), listeOzetiCikar(onceki, params.sira), ); } catch (err) { await grantCredits({ userId: user.id, delta: RAPOR_KREDI, reason: "refund", refId: input.requestId, }).catch(() => {}); return { ok: false, code: "HATA", error: aiHataMesaji(err) }; } // Sayaç yalnızca başarılı üretimde artar await appDb .update(schema.reports) .set({ result: sonuc, revisionCount: sql`${schema.reports.revisionCount} + 1`, updatedAt: new Date(), }) .where(eq(schema.reports.id, rapor.id)); return { ok: true, rapor: sonuc, params, revisionCount: rapor.revisionCount + 1, kredi: user.creditBalance - RAPOR_KREDI, hasPaket: true, }; }