Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s
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:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
45
src/lib/appdb/sema-guvence.ts
Normal file
45
src/lib/appdb/sema-guvence.ts
Normal 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
|
||||
)
|
||||
`);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
36
src/lib/eposta-gonder.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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
85
src/lib/harita-pinler.ts
Normal 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 };
|
||||
}
|
||||
87
src/lib/kredi-hatirlatma.ts
Normal file
87
src/lib/kredi-hatirlatma.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
83
src/lib/tadimlik-havuzu.ts
Normal file
83
src/lib/tadimlik-havuzu.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user