Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s

- Added new scripts for database operations and temporary production in package.json.
- Integrated KaydetBannerLazy component into the layout for improved user notifications.
- Enhanced API error handling in the soru route to mark credit exhaustion.
- Updated the IletisimPage for better button styling and user experience.
- Refactored ListemPage to streamline user flow and improve session handling.
- Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
bilalgursen
2026-08-06 02:27:42 +03:00
parent 488845ae1f
commit e62c6f07df
77 changed files with 4132 additions and 1659 deletions

View File

@@ -19,11 +19,18 @@ export type OlayAdi =
| "liste_olusturuldu"
| "liste_revize"
| "program_eklendi"
| "netler_goruntulendi"
| "danisman_sorusu"
| "katalog_arama"
| "odeme_baslatildi";
| "odeme_baslatildi"
| "hazir_panel_goruntulendi"
| "giris_cta_tiklandi"
| "kilit_goruntulendi"
| "kredi_bitti"
| "tadimlik_goruntulendi"
| "kaydet_banner_goruntulendi";
type Ozellikler = Record<string, string | number>;
export type Ozellikler = Record<string, string | number>;
interface RybbitApi {
event: (ad: string, ozellikler?: Ozellikler) => void;

View File

@@ -18,6 +18,13 @@ export const user = sqliteTable("user", {
image: text("image"),
creditBalance: integer("credit_balance").notNull().default(0),
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
// Kredi-bitti hatırlatma durum makinesi (kuruldu → gönderildi):
// ilk INSUFFICIENT anı; kredi yüklenince sıfırlanır (hatırlatma iptal).
krediBittiAt: integer("kredi_bitti_at", { mode: "timestamp" }),
// Hatırlatma e-postası bir kez gönderilir, bu alan hiç sıfırlanmaz.
krediHatirlatmaGonderildiAt: integer("kredi_hatirlatma_gonderildi_at", {
mode: "timestamp",
}),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
@@ -145,3 +152,47 @@ export const reports = sqliteTable("reports", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori ("genel" = kategorisiz fallback).
// Anonim istek LLM'e asla gitmez; bu tablo salt-okunur servis edilir.
// NOT: Yeni tablo/kolon eklerken sema-guvence.ts'teki DDL de güncellenmeli.
export const tadimlikHavuzu = sqliteTable(
"tadimlik_havuzu",
{
id: text("id").primaryKey(),
kovaSlug: text("kova_slug").notNull(),
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
kategoriSlug: text("kategori_slug").notNull(),
programId: text("program_id").notNull(),
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
gerekce: text("gerekce").notNull(),
riskNotu: text("risk_notu").notNull(),
trendOzeti: text("trend_ozeti").notNull(),
// Üretim anındaki program snapshot'ı (isim, universite, il, unitur,
// efektifSira, siraGecmisi) — render yokatlas sorgusu gerektirmesin diye.
program: text("program", { mode: "json" }).notNull(),
// Üretimde kullanılan temsili aday sırası (kovanın orta noktası).
ornekSira: integer("ornek_sira").notNull(),
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
},
(t) => [
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug),
],
);
// Manuel liste + sihirbaz profili — girişte localStorage'dan merge edilir
// (çakışmada client kazanır, 24 sınırı korunur). reports ile aynı upsert deseni.
export const savedLists = sqliteTable("saved_lists", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id),
// ManuelTercih[] (≤24, sıra korunur)
items: text("items", { mode: "json" }).notNull(),
// TercihProfili | null
profil: text("profil", { mode: "json" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -0,0 +1,45 @@
import { sql } from "drizzle-orm";
import { appDb } from "./index";
/**
* Prod volume'ündeki app.db'ye sonradan eklenen tabloları güvenceye alır.
*
* Projede drizzle migration akışı yok: şema lokalde `pnpm db:push` ile
* uygulanıp data/app.db seed olarak taşınıyor; ama docker-entrypoint.sh
* app.db'yi yalnızca volume BOŞKEN kopyalar — mevcut prod volume'üne yeni
* tablolar başka türlü gelmez. Burası sunucu açılışında (instrumentation)
* bir kez koşar; yalnızca additive DDL içerir, idempotenttir.
*
* NOT: schema.ts'e tablo/kolon eklendiğinde buradaki DDL de güncellenmeli.
*/
export async function semayiGuvenceyeAl(): Promise<void> {
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS tadimlik_havuzu (
id TEXT PRIMARY KEY,
kova_slug TEXT NOT NULL,
tur TEXT NOT NULL,
kategori_slug TEXT NOT NULL,
program_id TEXT NOT NULL,
dilim TEXT NOT NULL,
gerekce TEXT NOT NULL,
risk_notu TEXT NOT NULL,
trend_ozeti TEXT NOT NULL,
program TEXT NOT NULL,
ornek_sira INTEGER NOT NULL,
uretim_at INTEGER NOT NULL
)
`);
await appDb.run(
sql`CREATE UNIQUE INDEX IF NOT EXISTS tadimlik_anahtar ON tadimlik_havuzu (kova_slug, tur, kategori_slug)`,
);
await appDb.run(sql`
CREATE TABLE IF NOT EXISTS saved_lists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES user(id),
items TEXT NOT NULL,
profil TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}

View File

@@ -4,6 +4,7 @@ import { magicLink } from "better-auth/plugins";
import { appDb, schema } from "./appdb";
import { grantCredits, DENEME_KREDISI } from "./credits";
import { magicLinkEpostasi } from "./eposta";
import { epostaGonder } from "./eposta-gonder";
async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV !== "production") {
@@ -16,25 +17,9 @@ async function sendMagicLinkEmail(email: string, url: string) {
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);
const { subject, html, text } = magicLinkEpostasi(url);
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
to: email,
subject,
html,
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
// istemcilerde bağlantının okunabilir kalmasını sağlar.
text,
});
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
// doğrulanmamış domain/kota hatasında kullanıcı "gönderildi" ekranını
// görür ama mail hiç çıkmaz — fırlatarak better-auth'a hata döndürüyoruz.
if (error) {
console.error(`[giris] Magic link gönderilemedi (${email}):`, error);
throw new Error(`Resend: ${error.name} — ${error.message}`);
}
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı
// "gönderildi" ekranını görür ama mail hiç çıkmaz.
await epostaGonder(email, magicLinkEpostasi(url));
}
export const auth = betterAuth({

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, sql } from "drizzle-orm";
import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
const { user, creditLedger, chatMessages } = schema;
@@ -139,6 +139,8 @@ export async function grantCredits(opts: {
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
...(opts.setHasPaket ? { hasPaket: true } : {}),
// Kredi gelince bekleyen kredi-bitti hatırlatması iptal olur
krediBittiAt: null,
updatedAt: new Date(),
})
.where(eq(user.id, opts.userId));
@@ -150,3 +152,26 @@ export async function grantCredits(opts: {
throw err;
}
}
/**
* Kredi-bitti anını işaretler (48 saatlik hatırlatma e-postasının saati).
* Yalnızca ilk bitişte yazar (kolon doluysa no-op) ve hatırlatma zaten
* gönderildiyse yeniden kurmaz — e-posta ömür boyu 1 kez. Hata yolunda
* çağrıldığı için asla throw etmez.
*/
export async function krediBittiIsaretle(userId: string): Promise<void> {
try {
await appDb
.update(user)
.set({ krediBittiAt: new Date() })
.where(
and(
eq(user.id, userId),
isNull(user.krediBittiAt),
isNull(user.krediHatirlatmaGonderildiAt),
),
);
} catch (err) {
console.error(`[kredi] kredi-bitti işaretlenemedi (${userId}):`, err);
}
}

View File

@@ -189,6 +189,47 @@ export function searchByRank(
};
}
/** netler tablosunun bir yılı: yerleşen son kişinin ders bazlı netleri. */
export type ProgramNetSatiri = {
yil: number;
puanTuru: string | null;
tabanPuan: number | null;
obp: number | null;
tytTrkNet: number | null;
tytSosNet: number | null;
tytMatNet: number | null;
tytFenNet: number | null;
aytMatNet: number | null;
aytFizNet: number | null;
aytKimNet: number | null;
aytBioNet: number | null;
aytTdeNet: number | null;
aytTrh1Net: number | null;
aytCog1Net: number | null;
aytTrh2Net: number | null;
aytCog2Net: number | null;
aytDinNet: number | null;
aytFelNet: number | null;
ydtYdilNet: number | null;
};
/**
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
*/
export function netlerGetir(programId: string): ProgramNetSatiri[] {
return getDb()
.prepare(
`SELECT yil, puanTuru, tabanPuan, obp,
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
aytDinNet, aytFelNet, ydtYdilNet
FROM netler WHERE program_id = ? ORDER BY yil DESC`
)
.all(programId) as ProgramNetSatiri[];
}
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];

36
src/lib/eposta-gonder.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { EpostaIcerik } from "./eposta";
/**
* Ortak e-posta göndericisi (Resend). Şablonlar eposta.ts'te, gönderim
* burada — magic link (auth.ts) ve kredi hatırlatması aynı yoldan çıkar.
* RESEND_API_KEY yoksa dev fallback: içerik terminale yazılır.
*/
export async function epostaGonder(
to: string,
icerik: EpostaIcerik,
): Promise<void> {
if (!process.env.RESEND_API_KEY) {
console.log(
`\n[eposta] (dev) ${to} adresine gönderilecekti — ${icerik.subject}\n${icerik.text}\n`,
);
return;
}
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
to,
subject: icerik.subject,
html: icerik.html,
// Düz metin alternatifi spam puanını düşürür ve HTML'i engelleyen
// istemcilerde içeriğin okunabilir kalmasını sağlar.
text: icerik.text,
});
// Resend SDK hata fırlatmaz, { data, error } döndürür. Kontrol etmezsek
// doğrulanmamış domain/kota hatası sessizce yutulur — fırlatarak çağıran
// tarafın (better-auth, hatırlatma taraması) haberdar olmasını sağlıyoruz.
if (error) {
console.error(`[eposta] Gönderilemedi (${to}):`, error);
throw new Error(`Resend: ${error.name} — ${error.message}`);
}
}

View File

@@ -61,9 +61,12 @@ function baslik(): string {
function kabuk({
onizleme,
govde,
baslikEtiketi,
}: {
onizleme: string;
govde: string;
/** <title> içeriği — genelde konuyla aynı. */
baslikEtiketi: string;
}): string {
return `<!doctype html>
<html lang="tr">
@@ -71,7 +74,7 @@ function kabuk({
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Merhaba, giriş bağlantını gönderdik</title>
<title>${kacir(baslikEtiketi)}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,600&family=Cal+Sans&display=swap" rel="stylesheet">
</head>
@@ -141,6 +144,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
html: kabuk({
onizleme: "Giriş bağlantın 5 dakika geçerli.",
govde,
baslikEtiketi: "Merhaba, giriş bağlantını gönderdik",
}),
text: [
"Merhaba, giriş bağlantını gönderdik",
@@ -153,3 +157,49 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
].join("\n"),
};
}
/**
* Kredi bittikten 48 saat sonra giden tek seferlik hatırlatma. KVKK sınıfı
* netleşene dek yalnızca işlemsel dil: hesaptaki mevcut listeyi hatırlatır,
* ödeme ancak ikincil bir cümlede geçer (bkz. PRD İP-A / A2).
*/
export function krediHatirlatmaEpostasi(): EpostaIcerik {
const listemUrl = `${SITE_URL}/listem`;
const konu = "Listen seni bekliyor — açık satırların hazır";
const govde = `
<h1 style="margin:0 0 12px;font-family:${BASLIK_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
Listen seni bekliyor
</h1>
<p style="margin:0 0 24px;">
Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında
duruyor; gerekçeleri ve risk notlarıyla
<strong style="color:${LACIVERT};">açık satırların hazır</strong>.
Kaldığın yerden devam edebilirsin.
</p>
${buton(listemUrl, "Listeni gör")}
<p style="margin:24px 0 0;font-size:13px;">
Listenin tamamını açmak ya da danışmana soru sormak istersen kredi
yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.
</p>`;
return {
subject: konu,
html: kabuk({
onizleme: "Yapay zekânın kurduğu listen hesabında duruyor.",
govde,
baslikEtiketi: konu,
}),
text: [
"Listen seni bekliyor",
"",
"Yapay zekânın senin sıralamana göre kurduğu tercih listesi hesabında",
"duruyor; gerekçeleri ve risk notlarıyla açık satırların hazır.",
"",
"Listeni görmek için:",
listemUrl,
"",
"Listenin tamamını açmak ya da danışmana soru sormak istersen kredi",
"yükleyebilirsin — listen ve sohbet geçmişin olduğu gibi durur.",
].join("\n"),
};
}

85
src/lib/harita-pinler.ts Normal file
View File

@@ -0,0 +1,85 @@
// Rapordan Türkiye haritası pinleri türetir — /sonuc ve /listem ortak kullanır.
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { HaritaPin } from "@/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
export function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}

View File

@@ -0,0 +1,87 @@
import { and, eq, isNull, lt, lte } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { RAPOR_KREDI } from "./credits";
import { krediHatirlatmaEpostasi } from "./eposta";
import { epostaGonder } from "./eposta-gonder";
const { user } = schema;
// 48 saat sonra tek seferlik hatırlatma (PRD İP-A / A2). Env override'ları
// lokal testte akışı dakikalara sıkıştırmak için var.
const GECIKME_MS = Number(
process.env.KREDI_HATIRLATMA_GECIKME_MS ?? 48 * 60 * 60 * 1000,
);
const ARALIK_MS = Number(
process.env.KREDI_HATIRLATMA_ARALIK_MS ?? 60 * 60 * 1000,
);
/**
* Kredisi bitmiş ve 48 saattir dönmemiş kullanıcılara hatırlatma e-postası
* gönderir. Kredi yüklemek `krediBittiAt`'ı sıfırladığı (grantCredits) ve
* bakiye filtresi de burada olduğu için yalnızca hâlâ takılı olanlar seçilir.
* Gönderildi işareti e-postadan ÖNCE koşullu UPDATE ile atılır: yarışta
* yalnızca bir taraf `rowsAffected === 1` görür — çift gönderim imkânsız,
* Resend hatasında o kullanıcının hatırlatması yanar (bilinçli at-most-once).
*/
export async function krediHatirlatmaTaramasi(): Promise<void> {
const esik = new Date(Date.now() - GECIKME_MS);
const adaylar = await appDb
.select({ id: user.id, email: user.email })
.from(user)
.where(
and(
lte(user.krediBittiAt, esik),
isNull(user.krediHatirlatmaGonderildiAt),
lt(user.creditBalance, RAPOR_KREDI),
),
)
.limit(100);
for (const aday of adaylar) {
const res = await appDb
.update(user)
.set({ krediHatirlatmaGonderildiAt: new Date() })
.where(
and(eq(user.id, aday.id), isNull(user.krediHatirlatmaGonderildiAt)),
);
if (res.rowsAffected !== 1) continue; // başka instance kazandı
try {
await epostaGonder(aday.email, krediHatirlatmaEpostasi());
} catch (err) {
console.error(
`[hatirlatma] Kredi hatırlatması gönderilemedi (${aday.email}):`,
err,
);
}
}
}
/**
* Saatlik tarama zamanlayıcısı — instrumentation.ts'ten, sunucu başına bir
* kez çağrılır. globalThis guard'ı dev hot-reload'da çifte interval'i önler
* (appdb/index.ts deseni).
*/
export function krediHatirlatmaBaslat(): void {
const g = globalThis as unknown as { __krediHatirlatmaKuruldu?: boolean };
if (g.__krediHatirlatmaKuruldu) return;
g.__krediHatirlatmaKuruldu = true;
let calisiyor = false;
const tik = async () => {
if (calisiyor) return; // önceki tarama uzadıysa üst üste binme
calisiyor = true;
try {
await krediHatirlatmaTaramasi();
} catch (err) {
console.error("[hatirlatma] Tarama hatası:", err);
} finally {
calisiyor = false;
}
};
// Container yeniden başlarsa saatlik açıklık büyümesin diye kısa bir
// gecikmeyle açılış taraması da yapılır.
setTimeout(tik, 60_000);
setInterval(tik, ARALIK_MS);
}

View File

@@ -7,6 +7,12 @@ import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3;
/**
* Maske çıktısı: kilitli uyarıların yalnızca SAYISI iner, metinleri inmez.
* RaporSchema'ya (LLM çıktısı) eklenmez — bu alan yalnızca maskede doğar.
*/
export type MaskeliRapor = RaporSonuc & { kilitliUyariSayisi?: number };
const SAHTE_GEREKCE =
"Bu tercih için kişisel gerekçe paketle açılır.";
const SAHTE_RISK =
@@ -21,7 +27,7 @@ function degerlendirmeKirp(metin: string): string {
return `${cumleler.slice(0, 2).join(" ").trim()} …`;
}
export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
const tercihler = rapor.tercihler.map((t, i) =>
i < ACIK_SATIR
? t
@@ -36,8 +42,10 @@ export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
// Kişisel uyarılar ücretli katmandır; istemciye hiç inmez.
uyarilar: [],
// Teaser: ilk uyarı açık kalır, kalan uyarıların yalnızca sayısı iner —
// kilitli metinler istemciye hiç ulaşmaz.
uyarilar: rapor.uyarilar?.slice(0, 1) ?? [],
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
// Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
};

View File

@@ -33,6 +33,25 @@ export function bolumBazAdi(isim: string): string {
.trim();
}
/**
* /universite/[slug] sayfa slug'ı: harita.ts'teki uniAdiNormalize ile aynı
* kural (sondaki "(İL)" eki atılır). Katalog haritası DB'deki tüm universite
* değerlerinden kurulduğu için bu slug her zaman var olan bir sayfaya çıkar.
*/
export function uniSayfaSlug(ad: string): string {
return turkishSlugify(ad.replace(/\s*\([^)]*\)\s*$/, "").trim());
}
/**
* /bolum/[slug] sayfa slug'ı: katalog.ts'teki bolumSlugFromIsim ile aynı kural
* (bolumBazAdi + turkishSlugify). Bölüm haritası DB'deki tüm isim
* değerlerinden kurulduğu için bu slug da her zaman var olan bir sayfaya çıkar
* (bkz. liste-cekmecesi'ndeki bolumHref).
*/
export function bolumSayfaSlug(isim: string): string {
return turkishSlugify(bolumBazAdi(isim));
}
/** ALL-CAPS veritabanı adlarını tr-TR başlık düzenine çevirir (İSTANBUL → İstanbul). */
export function trBaslikDuzeni(s: string): string {
return s

View File

@@ -0,0 +1,83 @@
// Girişsiz yapay zekâ tadımlığının sunucu katmanı: batch üretilmiş
// (scripts/tadimlik-uret.ts) satırlar app.db'deki tadimlik_havuzu'ndan
// SEÇİLİR — anonim istek LLM'e asla gitmez, seçim her zaman sunucuda.
import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { turkishSlugify } from "./slug";
import type { PuanTuruKey } from "./db";
import type { Dilim } from "./rapor-havuzu";
import type { RaporSonuc } from "./ai/rapor";
/** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */
export const GENEL_KATEGORI = "genel";
/**
* Havuz anahtarındaki sıra kovaları. Eşikler analitik.ts'teki siraKovasi()
* ile birebir aynı tutulmalı (oradaki Türkçe etiketler DB anahtarı olamaz).
* temsiliSira: batch üretimde "bu kovadaki tipik aday" olarak kullanılan sıra.
*/
export const KOVALAR = [
{ slug: "0-10k", temsiliSira: 5_000, max: 10_000 },
{ slug: "10-50k", temsiliSira: 30_000, max: 50_000 },
{ slug: "50-100k", temsiliSira: 75_000, max: 100_000 },
{ slug: "100-250k", temsiliSira: 175_000, max: 250_000 },
{ slug: "250-500k", temsiliSira: 375_000, max: 500_000 },
{ slug: "500k-plus", temsiliSira: 750_000, max: Number.POSITIVE_INFINITY },
] as const;
export type Kova = (typeof KOVALAR)[number];
export function kovaBul(sira: number): Kova {
return KOVALAR.find((k) => sira <= k.max) ?? KOVALAR[KOVALAR.length - 1];
}
/** "Mühendislik & Teknoloji" → "muhendislik-teknoloji" (havuz anahtarı). */
export function kategoriSlugBul(kategori: string): string {
return turkishSlugify(kategori);
}
export interface TadimlikSatir {
programId: string;
dilim: Dilim;
gerekce: string;
riskNotu: string;
trendOzeti: string;
program: RaporSonuc["programlar"][string];
}
/**
* Kova × tür × kategori anahtarıyla tadımlık satırı seçer. Kategoriye özel
* satır yoksa "genel"e düşer; o da yoksa null — çağıran taraf bileşeni hiç
* render etmez (boş/generic tadımlık gösterilmez, PRD kuralı).
*/
export async function tadimlikSec(
sira: number,
tur: PuanTuruKey,
kategoriSlug?: string,
): Promise<TadimlikSatir | null> {
const kova = kovaBul(sira);
const sluglar = [
...new Set([kategoriSlug ?? GENEL_KATEGORI, GENEL_KATEGORI]),
];
for (const slug of sluglar) {
const satir = await appDb.query.tadimlikHavuzu.findFirst({
where: and(
eq(schema.tadimlikHavuzu.kovaSlug, kova.slug),
eq(schema.tadimlikHavuzu.tur, tur),
eq(schema.tadimlikHavuzu.kategoriSlug, slug),
),
});
if (satir) {
return {
programId: satir.programId,
dilim: satir.dilim,
gerekce: satir.gerekce,
riskNotu: satir.riskNotu,
trendOzeti: satir.trendOzeti,
program: satir.program as RaporSonuc["programlar"][string],
};
}
}
return null;
}