- 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>
238 lines
7.4 KiB
TypeScript
238 lines
7.4 KiB
TypeScript
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",
|
||
},
|
||
});
|
||
}
|