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>
This commit is contained in:
21
src/lib/ai/client.ts
Normal file
21
src/lib/ai/client.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
// Model seçimi (bilinçli): rapor tek seferlik ve yüksek değerli -> Opus 4.8;
|
||||
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
|
||||
export const RAPOR_MODEL = "claude-opus-4-8";
|
||||
export const SOHBET_MODEL = "claude-haiku-4-5";
|
||||
|
||||
const globalForAi = globalThis as unknown as { __anthropic?: Anthropic };
|
||||
|
||||
export function getAnthropic(): Anthropic {
|
||||
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
throw new Error("AI_KEY_MISSING");
|
||||
}
|
||||
if (!globalForAi.__anthropic) {
|
||||
globalForAi.__anthropic = new Anthropic();
|
||||
}
|
||||
return globalForAi.__anthropic;
|
||||
}
|
||||
|
||||
export const SORUMLULUK_REDDI =
|
||||
"KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine dayanır ancak yerleşme garantisi verilmez. Nihai tercih sorumluluğu adaya aittir.";
|
||||
179
src/lib/ai/rapor.ts
Normal file
179
src/lib/ai/rapor.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { z } from "zod";
|
||||
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
|
||||
import { getAnthropic, RAPOR_MODEL } from "./client";
|
||||
import {
|
||||
havuzOlustur,
|
||||
havuzuKompaktJson,
|
||||
turLabel,
|
||||
type RaporParams,
|
||||
type AdayProgram,
|
||||
} from "../rapor-havuzu";
|
||||
import { secimOzeti } from "../sihirbaz";
|
||||
|
||||
const TercihSchema = z.object({
|
||||
programId: z.string(),
|
||||
sira: z.number().int(),
|
||||
dilim: z.enum(["hayal", "dengeli", "garanti"]),
|
||||
gerekce: z.string(),
|
||||
riskNotu: z.string(),
|
||||
trendOzeti: z.string(),
|
||||
});
|
||||
|
||||
const RaporSchema = z.object({
|
||||
tercihler: z.array(TercihSchema),
|
||||
genelDegerlendirme: z.string(),
|
||||
});
|
||||
|
||||
export type RaporSonuc = z.infer<typeof RaporSchema> & {
|
||||
// Render için havuzdan zenginleştirilmiş program bilgisi
|
||||
programlar: Record<
|
||||
string,
|
||||
{ isim: string; universite: string; il: string | null; unitur: string | null }
|
||||
>;
|
||||
};
|
||||
|
||||
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
|
||||
GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli
|
||||
bir liste kurmak.
|
||||
|
||||
KURALLAR (kesin):
|
||||
- SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme.
|
||||
- Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun.
|
||||
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
|
||||
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
|
||||
(küçük taban sıralaması = daha zor bölüm üstte).
|
||||
- gerekce: bu programın bu aday için neden mantıklı olduğu (1-2 cümle, Türkçe).
|
||||
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin",
|
||||
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
|
||||
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban
|
||||
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor").
|
||||
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
|
||||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||||
- genelDegerlendirme: listenin genel stratejisi + adaya 2-3 pratik uyarı (Türkçe).`;
|
||||
|
||||
export class RaporUretimHatasi extends Error {}
|
||||
|
||||
export async function raporUret(
|
||||
params: RaporParams,
|
||||
revizyonNotu?: string,
|
||||
oncekiListeOzeti?: string,
|
||||
): Promise<RaporSonuc> {
|
||||
const { havuz, gevsetildi } = havuzOlustur(params);
|
||||
if (havuz.length < 24) {
|
||||
throw new RaporUretimHatasi(
|
||||
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
|
||||
);
|
||||
}
|
||||
|
||||
const secimVar =
|
||||
(params.kategoriler?.length ?? 0) > 0 ||
|
||||
(params.iller?.length ?? 0) > 0 ||
|
||||
(params.oncelikler?.length ?? 0) > 0 ||
|
||||
(params.universiteTipi && params.universiteTipi !== "farketmez");
|
||||
|
||||
const gevsetmeNotu: string[] = [];
|
||||
if (gevsetildi.includes("il")) {
|
||||
gevsetmeNotu.push(
|
||||
"Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi 24'e tamamlamak için diğerlerini de kullan.",
|
||||
);
|
||||
}
|
||||
if (gevsetildi.includes("kategori")) {
|
||||
gevsetmeNotu.push(
|
||||
"Adayın seçtiği ilgi alanlarında yeterli program yoktu; havuza yakın alanlardan program eklendi. Seçili alanlara öncelik ver.",
|
||||
);
|
||||
}
|
||||
|
||||
const client = getAnthropic();
|
||||
const havuzJson = havuzuKompaktJson(havuz);
|
||||
const kullaniciMesaji = [
|
||||
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
|
||||
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
|
||||
kategoriler: params.kategoriler ?? [],
|
||||
iller: params.iller ?? [],
|
||||
universiteTipi: params.universiteTipi ?? "farketmez",
|
||||
oncelikler: params.oncelikler ?? [],
|
||||
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
|
||||
: null,
|
||||
params.notlar ? `Adayın ek notları: ${params.notlar}` : null,
|
||||
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
|
||||
revizyonNotu
|
||||
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
|
||||
: null,
|
||||
`ADAY HAVUZU (yalnızca buradan seç): ${havuzJson}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const gecerliIdler = new Set(havuz.map((p) => p.id));
|
||||
|
||||
let sonuc: z.infer<typeof RaporSchema> | null = null;
|
||||
let sonHata = "";
|
||||
for (let deneme = 0; deneme < 2; deneme++) {
|
||||
const response = await client.messages.parse({
|
||||
model: RAPOR_MODEL,
|
||||
max_tokens: 16000,
|
||||
thinking: { type: "adaptive" },
|
||||
system: SISTEM,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
deneme === 0
|
||||
? kullaniciMesaji
|
||||
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
|
||||
},
|
||||
],
|
||||
output_config: { format: zodOutputFormat(RaporSchema) },
|
||||
});
|
||||
|
||||
const parsed = response.parsed_output;
|
||||
if (!parsed) {
|
||||
sonHata = "Çıktı şemaya uymadı.";
|
||||
continue;
|
||||
}
|
||||
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe
|
||||
const bilinmeyen = parsed.tercihler.filter(
|
||||
(t) => !gecerliIdler.has(t.programId),
|
||||
);
|
||||
if (parsed.tercihler.length !== 24 || bilinmeyen.length > 0) {
|
||||
sonHata =
|
||||
parsed.tercihler.length !== 24
|
||||
? `Liste ${parsed.tercihler.length} öğe içeriyor, tam 24 olmalı.`
|
||||
: `Havuzda olmayan programId kullanıldı: ${bilinmeyen
|
||||
.map((t) => t.programId)
|
||||
.slice(0, 3)
|
||||
.join(", ")}.`;
|
||||
continue;
|
||||
}
|
||||
sonuc = parsed;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!sonuc) {
|
||||
throw new RaporUretimHatasi(
|
||||
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||||
);
|
||||
}
|
||||
|
||||
const programlar: RaporSonuc["programlar"] = {};
|
||||
for (const p of havuz) {
|
||||
programlar[p.id] = {
|
||||
isim: p.isim,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
unitur: p.unitur,
|
||||
};
|
||||
}
|
||||
return { ...sonuc, programlar };
|
||||
}
|
||||
|
||||
export function listeOzetiCikar(rapor: RaporSonuc): string {
|
||||
return rapor.tercihler
|
||||
.map(
|
||||
(t) =>
|
||||
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"} — ${rapor.programlar[t.programId]?.universite ?? "?"}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export type { AdayProgram };
|
||||
22
src/lib/appdb/index.ts
Normal file
22
src/lib/appdb/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createClient, type Client } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Lokal: file:./data/app.db — deploy: libsql://... + auth token.
|
||||
// db.ts'teki better-sqlite3 bağlantısı gibi hot-reload'a karşı globalThis'te saklanır.
|
||||
|
||||
const globalForAppDb = globalThis as unknown as { __appDbClient?: Client };
|
||||
|
||||
function getClient(): Client {
|
||||
if (!globalForAppDb.__appDbClient) {
|
||||
const url = process.env.APP_DB_URL ?? "file:./data/app.db";
|
||||
globalForAppDb.__appDbClient = createClient({
|
||||
url,
|
||||
authToken: process.env.APP_DB_AUTH_TOKEN,
|
||||
});
|
||||
}
|
||||
return globalForAppDb.__appDbClient;
|
||||
}
|
||||
|
||||
export const appDb = drizzle(getClient(), { schema });
|
||||
export { schema };
|
||||
147
src/lib/appdb/schema.ts
Normal file
147
src/lib/appdb/schema.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
// ---- better-auth çekirdek tabloları (+ kredi alanları) ----
|
||||
|
||||
export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
image: text("image"),
|
||||
creditBalance: integer("credit_balance").notNull().default(0),
|
||||
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const session = sqliteTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export const verification = sqliteTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
// ---- Ürün tabloları ----
|
||||
|
||||
export const orders = sqliteTable(
|
||||
"orders",
|
||||
{
|
||||
// cuid — iyzico conversationId olarak da kullanılır
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
product: text("product", { enum: ["paket", "topup"] }).notNull(),
|
||||
amountKurus: integer("amount_kurus").notNull(),
|
||||
credits: integer("credits").notNull(),
|
||||
status: text("status", { enum: ["pending", "paid", "failed"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
iyzicoToken: text("iyzico_token"),
|
||||
iyzicoPaymentId: text("iyzico_payment_id"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
paidAt: integer("paid_at", { mode: "timestamp" }),
|
||||
},
|
||||
(t) => [index("orders_user").on(t.userId)],
|
||||
);
|
||||
|
||||
export const creditLedger = sqliteTable(
|
||||
"credit_ledger",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
delta: integer("delta").notNull(),
|
||||
reason: text("reason", {
|
||||
enum: [
|
||||
"trial_grant",
|
||||
"purchase",
|
||||
"topup",
|
||||
"chat_message",
|
||||
"refund",
|
||||
"report_generate",
|
||||
"report_revision",
|
||||
],
|
||||
}).notNull(),
|
||||
refId: text("ref_id"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("ledger_reason_ref").on(t.reason, t.refId),
|
||||
index("ledger_user").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const chatMessages = sqliteTable(
|
||||
"chat_messages",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
role: text("role", { enum: ["user", "assistant"] }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
clientMessageId: text("client_message_id").unique(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
|
||||
);
|
||||
|
||||
export const reports = sqliteTable("reports", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => user.id),
|
||||
params: text("params", { mode: "json" }).notNull(),
|
||||
result: text("result", { mode: "json" }),
|
||||
revisionCount: integer("revision_count").notNull().default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
8
src/lib/auth-client.ts
Normal file
8
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { magicLinkClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient()],
|
||||
});
|
||||
71
src/lib/auth.ts
Normal file
71
src/lib/auth.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { magicLink } from "better-auth/plugins";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||
|
||||
async function sendMagicLinkEmail(email: string, url: string) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
||||
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
|
||||
return;
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
await resend.emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
|
||||
to: email,
|
||||
subject: "KolayTercih giriş bağlantın",
|
||||
html: `<p>Merhaba,</p><p>KolayTercih'e giriş yapmak için <a href="${url}">buraya tıkla</a>. Bağlantı 5 dakika geçerlidir.</p><p>Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(appDb, {
|
||||
provider: "sqlite",
|
||||
schema: {
|
||||
user: schema.user,
|
||||
session: schema.session,
|
||||
account: schema.account,
|
||||
verification: schema.verification,
|
||||
},
|
||||
}),
|
||||
user: {
|
||||
additionalFields: {
|
||||
creditBalance: { type: "number", defaultValue: 0, input: false },
|
||||
hasPaket: { type: "boolean", defaultValue: false, input: false },
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendMagicLinkEmail(email, url);
|
||||
},
|
||||
}),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (newUser) => {
|
||||
// refId = userId → UNIQUE(reason, refId) ile tek seferlik
|
||||
await grantCredits({
|
||||
userId: newUser.id,
|
||||
delta: DENEME_KREDISI,
|
||||
reason: "trial_grant",
|
||||
refId: newUser.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
149
src/lib/credits.ts
Normal file
149
src/lib/credits.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
|
||||
const { user, creditLedger, chatMessages } = schema;
|
||||
|
||||
export const URUNLER = {
|
||||
paket: { amountKurus: 29900, credits: 60, label: "Tercih Dönemi Paketi" },
|
||||
topup: { amountKurus: 12900, credits: 30, label: "+30 Kredi" },
|
||||
} as const;
|
||||
|
||||
export const DENEME_KREDISI = 5;
|
||||
|
||||
// Rapor üretimi/revizyonu başına düşen kredi
|
||||
export const RAPOR_KREDI = 3;
|
||||
|
||||
export type SpendResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
|
||||
|
||||
function newId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sabit tutarda kredi düşer + ledger kaydı — tek transaction, iade edilebilir.
|
||||
* Rapor üretimi ve revizyonu için (3 kredi). refId idempotency anahtarıdır:
|
||||
* aynı requestId ile ikinci çağrı DUPLICATE döner (çifte harcama olmaz).
|
||||
* Bakiye yetersizse hiçbir şey yazılmaz.
|
||||
*/
|
||||
export async function spendCredits(opts: {
|
||||
userId: string;
|
||||
amount: number;
|
||||
reason: "report_generate" | "report_revision";
|
||||
refId: string;
|
||||
}): Promise<SpendResult> {
|
||||
try {
|
||||
await appDb.transaction(async (tx) => {
|
||||
const res = await tx
|
||||
.update(user)
|
||||
.set({
|
||||
creditBalance: sql`${user.creditBalance} - ${opts.amount}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(user.id, opts.userId), gte(user.creditBalance, opts.amount)),
|
||||
);
|
||||
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
|
||||
await tx.insert(creditLedger).values({
|
||||
id: newId(),
|
||||
userId: opts.userId,
|
||||
delta: -opts.amount,
|
||||
reason: opts.reason,
|
||||
refId: opts.refId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
|
||||
if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 kredi düşer + ledger kaydı + kullanıcı mesajını persist eder — tek transaction.
|
||||
* Bakiye yetersizse hiçbir şey yazılmaz. clientMessageId unique → retry'da çifte
|
||||
* harcama yerine DUPLICATE döner.
|
||||
*/
|
||||
export async function spendCreditForMessage(opts: {
|
||||
userId: string;
|
||||
content: string;
|
||||
clientMessageId: string;
|
||||
}): Promise<SpendResult & { messageId?: string }> {
|
||||
const messageId = newId();
|
||||
try {
|
||||
await appDb.transaction(async (tx) => {
|
||||
const res = await tx
|
||||
.update(user)
|
||||
.set({
|
||||
creditBalance: sql`${user.creditBalance} - 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(user.id, opts.userId), gte(user.creditBalance, 1)));
|
||||
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
|
||||
await tx.insert(chatMessages).values({
|
||||
id: messageId,
|
||||
userId: opts.userId,
|
||||
role: "user",
|
||||
content: opts.content,
|
||||
clientMessageId: opts.clientMessageId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await tx.insert(creditLedger).values({
|
||||
id: newId(),
|
||||
userId: opts.userId,
|
||||
delta: -1,
|
||||
reason: "chat_message",
|
||||
refId: messageId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
});
|
||||
return { ok: true, messageId };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
|
||||
if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
|
||||
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
|
||||
*/
|
||||
export async function grantCredits(opts: {
|
||||
userId: string;
|
||||
delta: number;
|
||||
reason: "trial_grant" | "purchase" | "topup" | "refund";
|
||||
refId: string;
|
||||
setHasPaket?: boolean;
|
||||
}): Promise<{ granted: boolean }> {
|
||||
try {
|
||||
await appDb.transaction(async (tx) => {
|
||||
await tx.insert(creditLedger).values({
|
||||
id: newId(),
|
||||
userId: opts.userId,
|
||||
delta: opts.delta,
|
||||
reason: opts.reason,
|
||||
refId: opts.refId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
|
||||
...(opts.setHasPaket ? { hasPaket: true } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, opts.userId));
|
||||
});
|
||||
return { granted: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("UNIQUE")) return { granted: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
111
src/lib/db.ts
111
src/lib/db.ts
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { KATEGORILER, kategoriBul } from "./kategoriler";
|
||||
|
||||
export type Program = {
|
||||
id: string;
|
||||
@@ -62,20 +63,47 @@ const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
|
||||
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
|
||||
* demektir (hayal), büyükse daha güvenli demektir (garanti).
|
||||
*/
|
||||
export type UniturGrubu = "devlet" | "vakif";
|
||||
|
||||
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
|
||||
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
|
||||
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
|
||||
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
|
||||
if (grup === "vakif")
|
||||
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
|
||||
return { sql: "", args: [] };
|
||||
}
|
||||
|
||||
export function searchByRank(
|
||||
sira: number,
|
||||
turKey: PuanTuruKey,
|
||||
opts: { il?: string; limitPerBucket?: number } = {}
|
||||
opts: {
|
||||
il?: string;
|
||||
iller?: string[];
|
||||
uniturGrubu?: UniturGrubu;
|
||||
limitPerBucket?: number;
|
||||
} = {}
|
||||
): RankResults {
|
||||
const db = getDb();
|
||||
const tur = PUAN_TURLERI[turKey];
|
||||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||||
const limitPerBucket = opts.limitPerBucket ?? 12;
|
||||
const ilFiltre = opts.il ? "AND il = ?" : "";
|
||||
const ilArgs = opts.il ? [opts.il] : [];
|
||||
|
||||
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
|
||||
const illerListe = [
|
||||
...(opts.il ? [opts.il] : []),
|
||||
...(opts.iller ?? []),
|
||||
].filter((v, i, a) => v && a.indexOf(v) === i);
|
||||
const ilFiltre =
|
||||
illerListe.length > 0
|
||||
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
||||
: "";
|
||||
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
||||
const ilArgs = [...illerListe, ...unitur.args];
|
||||
|
||||
const base = `FROM programs
|
||||
WHERE tur = ? AND onlisans = ? ${ilFiltre} AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
|
||||
AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
|
||||
|
||||
// hayal: kullanıcıdan daha iyi taban sıralaması (sınıra en yakın olanlar önce)
|
||||
@@ -122,3 +150,78 @@ export function searchByRank(
|
||||
garanti: garanti as Program[],
|
||||
};
|
||||
}
|
||||
|
||||
export type SihirbazFacetleri = {
|
||||
kategoriler: { ad: string; adet: number }[];
|
||||
iller: { il: string; adet: number }[];
|
||||
uniturler: { grup: UniturGrubu; adet: number }[];
|
||||
};
|
||||
|
||||
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal alt sınırından
|
||||
// (sira*0.5) garanti üst sınırına (sira*3) kadar. Böylece 500B sıradaki aday Tıp
|
||||
// bubble'ı görmez; 5B sıradaki görür.
|
||||
export function rankWindowFacets(
|
||||
sira: number,
|
||||
turKey: PuanTuruKey
|
||||
): SihirbazFacetleri {
|
||||
const db = getDb();
|
||||
const tur = PUAN_TURLERI[turKey];
|
||||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||||
const altSinir = Math.round(sira * 0.5);
|
||||
const ustSinir = Math.round(sira * 3);
|
||||
|
||||
const base = `FROM programs
|
||||
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
|
||||
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
|
||||
|
||||
// isim bazında say → JS'te kategoriye çevir (bir isim çok kategoriye girebilir)
|
||||
const isimSayilari = db
|
||||
.prepare(`SELECT isim, COUNT(*) AS adet ${base} GROUP BY isim`)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
isim: string;
|
||||
adet: number;
|
||||
}[];
|
||||
|
||||
const kategoriSay = new Map<string, number>();
|
||||
for (const { isim, adet } of isimSayilari) {
|
||||
for (const k of kategoriBul(isim)) {
|
||||
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + adet);
|
||||
}
|
||||
}
|
||||
const kategoriler = KATEGORILER.map((ad) => ({
|
||||
ad,
|
||||
adet: kategoriSay.get(ad) ?? 0,
|
||||
}))
|
||||
.filter((k) => k.adet >= 3) // gürültüyü ele
|
||||
.sort((a, b) => b.adet - a.adet);
|
||||
|
||||
const iller = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT il, COUNT(*) AS adet ${base} AND il IS NOT NULL AND il <> ''
|
||||
GROUP BY il ORDER BY adet DESC LIMIT 15`
|
||||
)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
il: string;
|
||||
adet: number;
|
||||
}[]
|
||||
).filter((r) => r.adet > 0);
|
||||
|
||||
const uniturSatirlari = db
|
||||
.prepare(`SELECT unitur, COUNT(*) AS adet ${base} GROUP BY unitur`)
|
||||
.all(tur, onlisans, altSinir, ustSinir) as {
|
||||
unitur: string | null;
|
||||
adet: number;
|
||||
}[];
|
||||
let devlet = 0;
|
||||
let vakif = 0;
|
||||
for (const { unitur, adet } of uniturSatirlari) {
|
||||
if (unitur === "DEVLET") devlet += adet;
|
||||
else if (unitur) vakif += adet;
|
||||
}
|
||||
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
|
||||
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
|
||||
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
|
||||
|
||||
return { kategoriler, iller, uniturler };
|
||||
}
|
||||
|
||||
112
src/lib/iyzico.ts
Normal file
112
src/lib/iyzico.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import Iyzipay from "iyzipay";
|
||||
|
||||
// iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var.
|
||||
// Anahtarlar boşsa (henüz sandbox hesabı yoksa) çağrı anlaşılır bir hatayla düşer.
|
||||
|
||||
const globalForIyzi = globalThis as unknown as { __iyzipay?: Iyzipay };
|
||||
|
||||
function getClient(): Iyzipay {
|
||||
if (!process.env.IYZICO_API_KEY || !process.env.IYZICO_SECRET_KEY) {
|
||||
throw new Error("IYZICO_KEYS_MISSING");
|
||||
}
|
||||
if (!globalForIyzi.__iyzipay) {
|
||||
globalForIyzi.__iyzipay = new Iyzipay({
|
||||
apiKey: process.env.IYZICO_API_KEY,
|
||||
secretKey: process.env.IYZICO_SECRET_KEY,
|
||||
uri: process.env.IYZICO_BASE_URL ?? "https://sandbox-api.iyzipay.com",
|
||||
});
|
||||
}
|
||||
return globalForIyzi.__iyzipay;
|
||||
}
|
||||
|
||||
export interface CheckoutFormInitSonuc {
|
||||
status: string;
|
||||
token?: string;
|
||||
paymentPageUrl?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutFormSonuc {
|
||||
status: string;
|
||||
paymentStatus?: string; // "SUCCESS" beklenir
|
||||
conversationId?: string;
|
||||
paymentId?: string;
|
||||
paidPrice?: string | number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function initializeCheckoutForm(opts: {
|
||||
orderId: string;
|
||||
fiyatKurus: number;
|
||||
urunAdi: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
callbackUrl: string;
|
||||
buyerIp: string;
|
||||
}): Promise<CheckoutFormInitSonuc> {
|
||||
const price = (opts.fiyatKurus / 100).toFixed(2);
|
||||
// iyzico buyer bloğu zorunlu alanlar ister; dijital üründe adres sembolik
|
||||
const adres = {
|
||||
contactName: "KolayTercih Kullanıcısı",
|
||||
city: "Istanbul",
|
||||
country: "Turkey",
|
||||
address: "Dijital teslimat",
|
||||
};
|
||||
const request = {
|
||||
locale: Iyzipay.LOCALE.TR,
|
||||
conversationId: opts.orderId,
|
||||
price,
|
||||
paidPrice: price,
|
||||
currency: Iyzipay.CURRENCY.TRY,
|
||||
basketId: opts.orderId,
|
||||
paymentGroup: Iyzipay.PAYMENT_GROUP.PRODUCT,
|
||||
callbackUrl: opts.callbackUrl,
|
||||
enabledInstallments: [1],
|
||||
buyer: {
|
||||
id: opts.userId,
|
||||
name: "KolayTercih",
|
||||
surname: "Kullanıcısı",
|
||||
gsmNumber: "+905000000000",
|
||||
email: opts.email,
|
||||
identityNumber: "11111111111",
|
||||
registrationAddress: adres.address,
|
||||
ip: opts.buyerIp,
|
||||
city: adres.city,
|
||||
country: adres.country,
|
||||
},
|
||||
shippingAddress: adres,
|
||||
billingAddress: adres,
|
||||
basketItems: [
|
||||
{
|
||||
id: opts.orderId,
|
||||
name: opts.urunAdi,
|
||||
category1: "Dijital Hizmet",
|
||||
itemType: Iyzipay.BASKET_ITEM_TYPE.VIRTUAL,
|
||||
price,
|
||||
},
|
||||
],
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient().checkoutFormInitialize.create(
|
||||
request as never,
|
||||
(err: unknown, result: CheckoutFormInitSonuc) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function retrieveCheckoutForm(
|
||||
token: string,
|
||||
): Promise<CheckoutFormSonuc> {
|
||||
return new Promise((resolve, reject) => {
|
||||
getClient().checkoutForm.retrieve(
|
||||
{ locale: Iyzipay.LOCALE.TR, token } as never,
|
||||
(err: unknown, result: CheckoutFormSonuc) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
102
src/lib/kategoriler.ts
Normal file
102
src/lib/kategoriler.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Program adını (isim) insan-dostu ilgi kategorilerine eşler.
|
||||
// Sihirbazdaki bubble'lar ve aday havuzu filtresi buradan beslenir.
|
||||
// Kural: bir program birden fazla kategoriye girebilir; hiçbirine girmezse
|
||||
// "Diğer" sayılır (bubble olarak gösterilmez ama havuzdan asla dışlanmaz).
|
||||
|
||||
export const KATEGORILER = [
|
||||
"Mühendislik & Teknoloji",
|
||||
"Sağlık & Tıp",
|
||||
"Hukuk & Siyasal",
|
||||
"İktisat & İşletme",
|
||||
"Eğitim & Öğretmenlik",
|
||||
"Mimarlık & Tasarım",
|
||||
"Fen & Temel Bilimler",
|
||||
"Sosyal & Beşeri Bilimler",
|
||||
"Sanat, Medya & İletişim",
|
||||
"Psikoloji & Danışmanlık",
|
||||
"Turizm & Gastronomi",
|
||||
"Tarım & Doğa",
|
||||
] as const;
|
||||
|
||||
export type Kategori = (typeof KATEGORILER)[number];
|
||||
|
||||
// Sıra önemli değil; her kural bağımsız test edilir. Regexler Türkçe küçük harfe
|
||||
// çevrilmiş isim üzerinde çalışır (bkz. kategoriBul).
|
||||
const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
|
||||
{
|
||||
kategori: "Mühendislik & Teknoloji",
|
||||
kural:
|
||||
/mühendis|yazılım|bilgisayar|bilişim|elektrik|elektronik|mekatronik|makine|makina|otomotiv|inşaat|endüstri|metalurji|malzeme|maden|jeoloji|jeofizik|petrol|enerji|robot|yapay zeka|yapay zekâ|siber|ağ teknolojileri|kontrol|otomasyon|uçak|havacılık|uzay|gemi|deniz ulaştırma|teknoloji|teknolojisi|programcı|elektrik-elektronik|nükleer|tekstil müh/,
|
||||
},
|
||||
{
|
||||
kategori: "Sağlık & Tıp",
|
||||
kural:
|
||||
/\btıp\b|diş|hemşire|eczacı|fizyoterapi|beslenme|diyetetik|anestezi|görüntüleme|laboratuvar|dokümantasyon|acil|paramedik|optisyen|ameliyathane|sağlık|odyoloji|dil ve konuşma|ebelik|veteriner|biyomedikal|ortez|protez|diyaliz|patoloji|radyoterapi|perfüzyon|çocuk gelişimi|yaşlı bakım|podoloji|ağız ve diş|tıbbi/,
|
||||
},
|
||||
{
|
||||
kategori: "Hukuk & Siyasal",
|
||||
kural:
|
||||
/hukuk|adalet|siyaset|kamu yönetim|uluslararası ilişki|siyasal|güvenlik|ceza infaz|milletlerarası|diplomasi/,
|
||||
},
|
||||
{
|
||||
kategori: "İktisat & İşletme",
|
||||
kural:
|
||||
/işletme|iktisat|ekonomi|maliye|muhasebe|finans|bankacılık|sigortacılık|dış ticaret|lojistik|pazarlama|yönetim bilişim|insan kaynak|çalışma ekonomisi|uluslararası ticaret|bankacılık ve sigortacılık|halkla ilişkiler|reklamcılık|emlak|menkul|gümrük|büro yönetim|yönetici asistan/,
|
||||
},
|
||||
{
|
||||
kategori: "Eğitim & Öğretmenlik",
|
||||
kural:
|
||||
/öğretmen|öğretmenliği|eğitim|okul öncesi|rehberlik ve psikolojik|çocuk gelişimi|pedagoji|özel eğitim/,
|
||||
},
|
||||
{
|
||||
kategori: "Mimarlık & Tasarım",
|
||||
kural:
|
||||
/mimar|iç mimar|peyzaj|şehir ve bölge|tasarım|endüstriyel tasarım|moda|iç mekan/,
|
||||
},
|
||||
{
|
||||
kategori: "Fen & Temel Bilimler",
|
||||
kural:
|
||||
/matematik|fizik|kimya|biyoloji|istatistik|moleküler|genetik|biyoteknoloji|astronomi|aktüerya|fen bilim|bilim/,
|
||||
},
|
||||
{
|
||||
kategori: "Sosyal & Beşeri Bilimler",
|
||||
kural:
|
||||
/sosyoloji|tarih|coğrafya|felsefe|edebiyat|dil ve edebiyat|filoloji|arkeoloji|antropoloji|ilahiyat|din kültürü|dilbilim|sosyal hizmet|mütercim|çeviribilim|tercümanlık|türk dili|dünya dilleri|sanat tarihi|halkbilim|dinî/,
|
||||
},
|
||||
{
|
||||
kategori: "Sanat, Medya & İletişim",
|
||||
kural:
|
||||
/gazetecilik|iletişim|radyo|televizyon|sinema|görsel|grafik|müzik|resim|heykel|sahne|tiyatro|oyunculuk|fotoğraf|animasyon|seramik|güzel sanatlar|medya|yeni medya|halkla ilişkiler ve tanıtım|reklam|film|sanat ve tasarım/,
|
||||
},
|
||||
{
|
||||
kategori: "Psikoloji & Danışmanlık",
|
||||
kural: /psikoloji|psikolojik danışma|rehberlik ve psikolojik|davranış/,
|
||||
},
|
||||
{
|
||||
kategori: "Turizm & Gastronomi",
|
||||
kural:
|
||||
/turizm|gastronomi|mutfak|aşçılık|otel|seyahat|rekreasyon|konaklama|ikram|sivil havacılık kabin/,
|
||||
},
|
||||
{
|
||||
kategori: "Tarım & Doğa",
|
||||
kural:
|
||||
/ziraat|tarım|tarla|bahçe|bitki|hayvan|su ürünleri|gıda|orman|çevre|balıkçılık|peyzaj|tohum|toprak|zootekni/,
|
||||
},
|
||||
];
|
||||
|
||||
/** Verilen program adının girdiği tüm kategorileri döner (0..n). */
|
||||
export function kategoriBul(isim: string): Kategori[] {
|
||||
const lower = isim.toLocaleLowerCase("tr-TR");
|
||||
const sonuc: Kategori[] = [];
|
||||
for (const { kategori, kural } of KATEGORI_KURALLARI) {
|
||||
if (kural.test(lower)) sonuc.push(kategori);
|
||||
}
|
||||
return sonuc;
|
||||
}
|
||||
|
||||
/** Bir programın seçili kategorilerden en az biriyle eşleşip eşleşmediği. */
|
||||
export function kategoriEslesir(isim: string, secililer: string[]): boolean {
|
||||
if (secililer.length === 0) return true;
|
||||
const kats = kategoriBul(isim);
|
||||
return kats.some((k) => secililer.includes(k));
|
||||
}
|
||||
63
src/lib/odeme.ts
Normal file
63
src/lib/odeme.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, URUNLER } from "./credits";
|
||||
import { retrieveCheckoutForm } from "./iyzico";
|
||||
|
||||
const { orders } = schema;
|
||||
|
||||
/**
|
||||
* Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar.
|
||||
* Hem callback route'u hem /odeme/sonuc self-healing fallback'i bunu kullanır.
|
||||
* Dönen değer: siparişin son durumu.
|
||||
*/
|
||||
export async function odemeyiSonuclandir(
|
||||
orderId: string,
|
||||
): Promise<"paid" | "pending" | "failed" | "not_found"> {
|
||||
const order = await appDb.query.orders.findFirst({
|
||||
where: eq(orders.id, orderId),
|
||||
});
|
||||
if (!order) return "not_found";
|
||||
if (order.status === "paid") return "paid";
|
||||
if (!order.iyzicoToken) return order.status;
|
||||
|
||||
let sonuc;
|
||||
try {
|
||||
sonuc = await retrieveCheckoutForm(order.iyzicoToken);
|
||||
} catch {
|
||||
return order.status; // iyzico'ya ulaşılamadı; durumu değiştirme
|
||||
}
|
||||
|
||||
if (sonuc.status !== "success" || sonuc.paymentStatus !== "SUCCESS") {
|
||||
await appDb
|
||||
.update(orders)
|
||||
.set({ status: "failed" })
|
||||
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
|
||||
return "failed";
|
||||
}
|
||||
if (sonuc.conversationId && sonuc.conversationId !== order.id) {
|
||||
return order.status; // conversationId uyuşmazlığı: işleme
|
||||
}
|
||||
|
||||
// pending -> paid koşullu geçiş: 0 satır = başka istek zaten işledi
|
||||
const res = await appDb
|
||||
.update(orders)
|
||||
.set({
|
||||
status: "paid",
|
||||
iyzicoPaymentId: sonuc.paymentId ?? null,
|
||||
paidAt: new Date(),
|
||||
})
|
||||
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
|
||||
if (res.rowsAffected === 0) return "paid";
|
||||
|
||||
// UNIQUE(reason, refId) ikinci katman güvence
|
||||
await grantCredits({
|
||||
userId: order.userId,
|
||||
delta: order.credits,
|
||||
reason: order.product === "paket" ? "purchase" : "topup",
|
||||
refId: order.id,
|
||||
setHasPaket: order.product === "paket",
|
||||
});
|
||||
return "paid";
|
||||
}
|
||||
|
||||
export { URUNLER };
|
||||
133
src/lib/rapor-havuzu.ts
Normal file
133
src/lib/rapor-havuzu.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
searchByRank,
|
||||
type Program,
|
||||
type PuanTuruKey,
|
||||
type UniturGrubu,
|
||||
PUAN_TURLERI,
|
||||
} from "./db";
|
||||
import { kategoriEslesir } from "./kategoriler";
|
||||
|
||||
export type Dilim = "hayal" | "dengeli" | "garanti";
|
||||
|
||||
export interface AdayProgram extends Program {
|
||||
dilim: Dilim;
|
||||
}
|
||||
|
||||
export interface RaporParams {
|
||||
sira: number;
|
||||
tur: PuanTuruKey;
|
||||
// Sihirbaz seçimleri (yeni akış)
|
||||
kategoriler?: string[];
|
||||
iller?: string[];
|
||||
universiteTipi?: UniturGrubu | "farketmez";
|
||||
oncelikler?: string[];
|
||||
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
|
||||
il?: string;
|
||||
notlar?: string;
|
||||
}
|
||||
|
||||
export interface HavuzSonuc {
|
||||
havuz: AdayProgram[];
|
||||
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
|
||||
gevsetildi: ("kategori" | "il")[];
|
||||
}
|
||||
|
||||
const HEDEF = 24;
|
||||
const LIMIT = 60;
|
||||
|
||||
function dilimEkle(r: {
|
||||
hayal: Program[];
|
||||
dengeli: Program[];
|
||||
garanti: Program[];
|
||||
}): AdayProgram[] {
|
||||
return [
|
||||
...r.hayal.map((p) => ({ ...p, dilim: "hayal" as const })),
|
||||
...r.dengeli.map((p) => ({ ...p, dilim: "dengeli" as const })),
|
||||
...r.garanti.map((p) => ({ ...p, dilim: "garanti" as const })),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
|
||||
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
|
||||
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
|
||||
* her zaman kurulabilsin.
|
||||
*/
|
||||
export function havuzOlustur(params: RaporParams): HavuzSonuc {
|
||||
const uniturGrubu =
|
||||
params.universiteTipi && params.universiteTipi !== "farketmez"
|
||||
? params.universiteTipi
|
||||
: undefined;
|
||||
const kategoriler = params.kategoriler ?? [];
|
||||
const iller = [
|
||||
...(params.il ? [params.il] : []),
|
||||
...(params.iller ?? []),
|
||||
].filter((v, i, a) => v && a.indexOf(v) === i);
|
||||
|
||||
const kategoriFiltre = (havuz: AdayProgram[]) =>
|
||||
kategoriler.length > 0
|
||||
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler))
|
||||
: havuz;
|
||||
|
||||
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
|
||||
const tamHam = dilimEkle(
|
||||
searchByRank(params.sira, params.tur, {
|
||||
iller: iller.length > 0 ? iller : undefined,
|
||||
uniturGrubu,
|
||||
limitPerBucket: LIMIT,
|
||||
}),
|
||||
);
|
||||
const tamFiltreli = kategoriFiltre(tamHam);
|
||||
if (tamFiltreli.length >= HEDEF) {
|
||||
return { havuz: tamFiltreli, gevsetildi: [] };
|
||||
}
|
||||
|
||||
// 2. Kategori gevşet: aynı illerdeki kategori-dışı programlarla doldur
|
||||
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
|
||||
return { havuz: tamHam, gevsetildi: ["kategori"] };
|
||||
}
|
||||
|
||||
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
|
||||
const gevsetildi: ("kategori" | "il")[] = [];
|
||||
if (iller.length > 0) gevsetildi.push("il");
|
||||
const ilsizHam = dilimEkle(
|
||||
searchByRank(params.sira, params.tur, {
|
||||
uniturGrubu,
|
||||
limitPerBucket: LIMIT,
|
||||
}),
|
||||
);
|
||||
const ilsizFiltreli = kategoriFiltre(ilsizHam);
|
||||
if (ilsizFiltreli.length >= HEDEF) {
|
||||
return { havuz: ilsizFiltreli, gevsetildi };
|
||||
}
|
||||
|
||||
// 4. Hem il hem kategori gevşet: elde ne varsa
|
||||
if (kategoriler.length > 0) gevsetildi.push("kategori");
|
||||
return { havuz: ilsizHam, gevsetildi };
|
||||
}
|
||||
|
||||
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */
|
||||
export function havuzuKompaktJson(havuz: AdayProgram[]): string {
|
||||
return JSON.stringify(
|
||||
havuz.map((p) => ({
|
||||
id: p.id,
|
||||
isim: p.isim,
|
||||
uni: p.universite,
|
||||
unitur: p.unitur,
|
||||
il: p.il,
|
||||
dilim: p.dilim,
|
||||
sira: {
|
||||
y2025: p.sira2025,
|
||||
y2024: p.sira2024,
|
||||
y2023: p.sira2023,
|
||||
y2022: p.sira2022,
|
||||
},
|
||||
kontenjan2025: p.kontenjan2025,
|
||||
yerlesen2025: p.yerlesen2025,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function turLabel(tur: PuanTuruKey): string {
|
||||
return PUAN_TURLERI[tur];
|
||||
}
|
||||
35
src/lib/session.ts
Normal file
35
src/lib/session.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "./auth";
|
||||
import { appDb, schema } from "./appdb";
|
||||
|
||||
/** İstek başına tek session sorgusu (React cache). */
|
||||
export const getSession = cache(async () => {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
});
|
||||
|
||||
/** Oturum yoksa /giris'e yönlendirir; callback ile geri dönüş desteklenir. */
|
||||
export async function verifySession(callbackPath?: string) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
const target = callbackPath
|
||||
? `/giris?callback=${encodeURIComponent(callbackPath)}`
|
||||
: "/giris";
|
||||
redirect(target);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Kredi/paket alanları dahil güncel kullanıcı satırı (session cache'ine güvenme). */
|
||||
export const getCurrentUser = cache(async () => {
|
||||
const session = await getSession();
|
||||
if (!session) return null;
|
||||
const rows = await appDb
|
||||
.select()
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, session.user.id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
});
|
||||
108
src/lib/sihirbaz.ts
Normal file
108
src/lib/sihirbaz.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// Sonuç sayfasındaki sihirbaz modalının seçim şeması, doğrulaması ve prompt özeti.
|
||||
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
|
||||
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
|
||||
|
||||
import { KATEGORILER } from "./kategoriler";
|
||||
import type { UniturGrubu } from "./db";
|
||||
|
||||
export const ONCELIKLER = [
|
||||
"İş garantisi",
|
||||
"Yüksek maaş potansiyeli",
|
||||
"Sevdiğim işi yapmak",
|
||||
"Akademik kariyer",
|
||||
"Yurtdışı imkânı",
|
||||
"Prestijli üniversite",
|
||||
"Kolay yerleşme",
|
||||
] as const;
|
||||
|
||||
export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> = {
|
||||
devlet: "Devlet",
|
||||
vakif: "Vakıf (burslu olursa)",
|
||||
farketmez: "Fark etmez",
|
||||
};
|
||||
|
||||
export type UniversiteTipi = UniturGrubu | "farketmez";
|
||||
|
||||
// Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
|
||||
// dönene kadar burada bekler.
|
||||
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
|
||||
|
||||
export interface SihirbazSecimleri {
|
||||
kategoriler: string[];
|
||||
iller: string[];
|
||||
universiteTipi: UniversiteTipi;
|
||||
oncelikler: string[];
|
||||
}
|
||||
|
||||
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
|
||||
|
||||
/**
|
||||
* Client'tan gelen seçimleri güvene alır. Kategoriler KATEGORILER allowlist'ine
|
||||
* göre süzülür; iller serbest string (yalnızca sanity — DB IN filtresi zaten
|
||||
* eşleşmeyeni yok sayar), en fazla 5 il; öncelikler ONCELIKLER'e göre süzülür.
|
||||
* En az bir kategori zorunlu.
|
||||
*/
|
||||
export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
|
||||
if (typeof girdi !== "object" || girdi === null) return null;
|
||||
const p = girdi as Record<string, unknown>;
|
||||
|
||||
const kategoriler = Array.isArray(p.kategoriler)
|
||||
? [
|
||||
...new Set(
|
||||
p.kategoriler.filter(
|
||||
(x): x is string =>
|
||||
typeof x === "string" &&
|
||||
(KATEGORILER as readonly string[]).includes(x),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
if (kategoriler.length === 0) return null;
|
||||
|
||||
const iller = Array.isArray(p.iller)
|
||||
? [
|
||||
...new Set(
|
||||
p.iller.filter(
|
||||
(x): x is string => typeof x === "string" && x.trim().length > 0,
|
||||
),
|
||||
),
|
||||
].slice(0, 5)
|
||||
: [];
|
||||
|
||||
const universiteTipi =
|
||||
typeof p.universiteTipi === "string" &&
|
||||
UNI_TIPLERI.includes(p.universiteTipi as UniversiteTipi)
|
||||
? (p.universiteTipi as UniversiteTipi)
|
||||
: "farketmez";
|
||||
|
||||
const oncelikler = Array.isArray(p.oncelikler)
|
||||
? [
|
||||
...new Set(
|
||||
p.oncelikler.filter(
|
||||
(x): x is string =>
|
||||
typeof x === "string" &&
|
||||
(ONCELIKLER as readonly string[]).includes(x),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
return { kategoriler, iller, universiteTipi, oncelikler };
|
||||
}
|
||||
|
||||
/** Prompt'a enjekte edilecek Türkçe özet. */
|
||||
export function secimOzeti(secimler: SihirbazSecimleri): string {
|
||||
const satirlar = [
|
||||
`- İlgi alanları: ${secimler.kategoriler.join(", ")}`,
|
||||
];
|
||||
if (secimler.iller.length > 0) {
|
||||
satirlar.push(`- Tercih ettiği iller: ${secimler.iller.join(", ")}`);
|
||||
}
|
||||
satirlar.push(
|
||||
`- Üniversite tipi tercihi: ${UNIVERSITE_TIPI_ETIKET[secimler.universiteTipi]}`,
|
||||
);
|
||||
if (secimler.oncelikler.length > 0) {
|
||||
satirlar.push(`- Öncelikleri: ${secimler.oncelikler.join(", ")}`);
|
||||
}
|
||||
return satirlar.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user