Files
kolaytercih/src/app/api/soru/route.ts
bilalgursen cee6200237 feat: kimlik doğrulama, ödeme, kredi ve AI rapor sihirbazı ekle
- better-auth ile giriş/oturum yönetimi (src/lib/auth, giris sayfası)
- iyzico ödeme entegrasyonu ve paket satın alma akışı
- kredi sistemi ve uygulama veritabanı şeması (drizzle)
- AI destekli rapor üretimi ve tercih sihirbazı
- rapor yazdırma sayfası ve site header/kullanıcı menüsü

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 02:13:39 +03:00

238 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq, desc, asc } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
import { getAnthropic, SOHBET_MODEL, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db";
const MAX_GECMIS = 12;
const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
const { params } = opts;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
const parcalar = [
`Sen KolayTercih'in YKS tercih danışmanısın. Türkçe, samimi ama profesyonel konuş.
Aday az önce sana verilen 24'lük tercih listesini oluşturdu. Görevin: adayın bu
liste ve seçimleri BAĞLAMINDA sorularını (bölüm kıyaslama, şehir, risk, kontenjan,
iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
KURALLAR:
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
programlara referans verebilirsin.
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
- Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır.
- Tercih dışı konularda (ödev, kod, genel sohbet) kibarca reddet ve tercih
konusuna dön.
- Kesin konuşmaktan kaçın: "${SORUMLULUK_REDDI}"`,
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
];
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
})}`,
);
}
return parcalar.join("\n\n");
}
// 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([
appDb
.select({
id: schema.chatMessages.id,
role: schema.chatMessages.role,
content: schema.chatMessages.content,
})
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(asc(schema.chatMessages.createdAt))
.limit(100),
appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { creditBalance: true },
}),
]);
return Response.json({
mesajlar,
kredi: kullanici?.creditBalance ?? 0,
});
}
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
}
let body: { message?: unknown; clientMessageId?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
}
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 });
}
// Soru sormak için önce liste oluşturulmuş olmalı
const rapor = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
});
if (!rapor?.result) {
return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
{ status: 409 },
);
}
// Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın
const gecmis = (
await appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS)
).reverse();
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
clientMessageId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
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!;
const system = sistemPromptu({
params: rapor.params as RaporParams,
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
});
let client;
try {
client = getAnthropic();
} catch {
await grantCredits({
userId: session.user.id,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
});
return Response.json(
{ error: "AI altyapısı yapılandırılmadı." },
{ status: 503 },
);
}
const mesajlar = [
...gecmis.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user" as const, content: mesaj },
];
const encoder = new TextEncoder();
const userId = session.user.id;
let stream;
try {
stream = client.messages.stream({
model: SOHBET_MODEL,
max_tokens: 1024,
system,
messages: mesajlar,
});
} catch {
await grantCredits({ userId, delta: 1, reason: "refund", refId: kullaniciMesajId });
return Response.json({ error: "AI'ya ulaşılamadı." }, { status: 502 });
}
const readable = new ReadableStream({
async start(controller) {
let tamamlandi = false;
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(event.delta.text));
}
}
const final = await stream.finalMessage();
const metin = final.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
tamamlandi = true;
controller.close();
} catch (err) {
if (!tamamlandi) {
// Hiç çıktı üretilmeden hata olduysa krediyi iade et
await grantCredits({
userId,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
}
console.error("[soru] stream hatası:", err);
controller.error(err);
}
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}