refactor: update Dockerfile and authentication flow for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 15m6s

- Updated Dockerfile to create a cache directory for Next.js runtime, ensuring proper permissions and ownership.
- Enhanced the authentication flow by updating the magic link expiration time from 5 to 15 minutes, improving user experience and reducing login issues.
- Added user-friendly error handling for invalid or expired magic links, providing clear feedback on the login page.
- Adjusted email templates to reflect the new 15-minute validity period for magic links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-07 02:05:14 +03:00
parent 37baeb9172
commit be4b2c6000
19 changed files with 444 additions and 134 deletions

View File

@@ -1,7 +1,7 @@
{ {
"enabledPlugins": { "enabledPlugins": {
"superpowers@superpowers-marketplace": true, "superpowers@superpowers-marketplace": false,
"prd-development@pm-skills": true "prd-development@pm-skills": false
}, },
"extraKnownMarketplaces": { "extraKnownMarketplaces": {
"superpowers-marketplace": { "superpowers-marketplace": {

View File

@@ -1,7 +0,0 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.

View File

@@ -1,22 +0,0 @@
---
name: grilling
description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.
---
Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it.
Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round.
Each question should be formatted like so:
```
❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices>
➡️ <your recommended answer>
```
Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one.
Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. The _decisions_ are the user's — put each to them and wait.
The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding.

View File

@@ -69,10 +69,11 @@ COPY drizzle ./drizzle
COPY scripts/db-goc.mjs ./scripts/db-goc.mjs COPY scripts/db-goc.mjs ./scripts/db-goc.mjs
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
# SQLite veri dizini # SQLite veri dizini + Next'in runtime'da yazdığı image/fetch cache dizini
# (.next'in geri kalanı root'ta kalır; salt-okunur olması sorun değil)
RUN chmod +x /app/docker-entrypoint.sh && \ RUN chmod +x /app/docker-entrypoint.sh && \
mkdir -p /app/data && \ mkdir -p /app/data /app/.next/cache && \
chown -R nextjs:nodejs /app/data /app/seed chown -R nextjs:nodejs /app/data /app/seed /app/.next/cache
USER nextjs USER nextjs

Binary file not shown.

View File

@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { PixelCorner } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Girişini onayla",
robots: { index: false, follow: false },
};
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
function guvenliYol(deger: string | undefined, varsayilan: string): string {
return deger && deger.startsWith("/") && !deger.startsWith("//")
? deger
: varsayilan;
}
/**
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
*/
export default async function DogrulaPage({
searchParams,
}: {
searchParams: Promise<{
token?: string;
callbackURL?: string;
errorCallbackURL?: string;
newUserCallbackURL?: string;
}>;
}) {
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
await searchParams;
if (!token) redirect("/giris");
const callback = guvenliYol(callbackURL, "/");
const hataCallback = guvenliYol(
errorCallbackURL,
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
);
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
<p className="mt-2 text-sm text-slate-600">
Bu bağlantı senin için üretildi ve{" "}
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
</p>
<form
method="GET"
action="/api/auth/magic-link/verify"
className="mt-6"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="callbackURL" value={callback} />
<input
type="hidden"
name="errorCallbackURL"
value={hataCallback}
/>
<input
type="hidden"
name="newUserCallbackURL"
value={yeniKullaniciCallback}
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Giriş yap
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
<p className="mt-4 text-xs leading-relaxed text-slate-500">
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin butona
tıklanmadığı sürece hesabında hiçbir şey değişmez.
</p>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
</main>
</div>
);
}

View File

@@ -74,6 +74,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
const { error } = await authClient.signIn.magicLink({ const { error } = await authClient.signIn.magicLink({
email, email,
callbackURL, callbackURL,
// Geçersiz/süresi dolmuş token'da kullanıcı ham ?error=INVALID_TOKEN
// parametresiyle baş başa kalmasın: giriş sayfası bu değeri dostça bir
// mesaja çevirir, callback korunur (bkz. src/app/giris/page.tsx).
errorCallbackURL: `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callbackURL)}`,
}); });
setBusy(null); setBusy(null);
if (error) { if (error) {
@@ -93,7 +97,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
<p className="mt-1"> <p className="mt-1">
<span className="font-medium">{email}</span> adresine bir giriş <span className="font-medium">{email}</span> adresine bir giriş
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et
bağlantı 5 dakika geçerli. bağlantı 15 dakika geçerli.
</p> </p>
</div> </div>
<div className="flex items-center justify-center gap-4 text-sm"> <div className="flex items-center justify-center gap-4 text-sm">

View File

@@ -14,11 +14,15 @@ export const metadata: Metadata = {
export default async function GirisPage({ export default async function GirisPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ callback?: string }>; searchParams: Promise<{ callback?: string; hata?: string }>;
}) { }) {
const { callback } = await searchParams; const { callback, hata } = await searchParams;
const callbackURL = const callbackURL =
callback && callback.startsWith("/") ? callback : "/"; callback && callback.startsWith("/") ? callback : "/";
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
const baglantiHatasi = hata === "gecersiz-baglanti";
const session = await getSession(); const session = await getSession();
if (session) redirect(callbackURL); if (session) redirect(callbackURL);
@@ -33,6 +37,20 @@ export default async function GirisPage({
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8"> <main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative"> <div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{baglantiHatasi && (
<div
role="alert"
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
>
<p className="font-semibold">
Giriş bağlantısı geçersiz ya da süresi dolmuş
</p>
<p className="mt-1">
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
yeni bir bağlantı isteyebilirsin.
</p>
</div>
)}
<GirisBaslik funnel={funnel} /> <GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-slate-600">
{funnel ? ( {funnel ? (

View File

@@ -118,8 +118,8 @@ export default async function ListemPage({
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800" className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
> >
<Sparkles className="size-4 shrink-0" aria-hidden /> <Sparkles className="size-4 shrink-0" aria-hidden />
Paketin aktif 24 tercihinin tamamı, gerekçeler, riskler ve PDF Paketin aktif listendeki tercihlerin tamamı, gerekçeler, riskler ve
artık ık. PDF artık ık.
</div> </div>
) : null} ) : null}
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında {/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında

View File

@@ -18,12 +18,14 @@ flowchart TD
H --> S{"Havuzda 24'ten az program mı kaldı?"} H --> S{"Havuzda 24'ten az program mı kaldı?"}
S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir") S -- "evet" --> G("Filtreler adım adım gevşetilir<br/>önce il, sonra alan — listede açıkça belirtilir")
G --> H G --> H
S -- "hayır" --> Y("Yapay zekâ havuzdan tam 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar") H --> U("Havuz yine de dar kalırsa<br/>liste eldeki programlarla kurulur, dağılım havuza uyarlanır ve sana açıkça söylenir")
S -- "hayır" --> Y("Yapay zekâ havuzdan en fazla 24 tercih seçer<br/>havuzda olmayan bölümü yazamaz, her satıra gerekçe yazar")
U --> Y
Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir") Y --> R("Risk notu ve trend özeti<br/>gerçek sıralama geçmişinden hesaplanır, yapay zekâ yorumu değildir")
R --> L("24 tercihlik liste") R --> L("En fazla 24 tercihlik liste")
L --> L1("üstte 46 hayal") L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
L --> L2("ortada 1214 dengeli") L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
L --> L3("sonda 57 garanti") L --> L3("sonda garanti — hedef 6, havuz elverdiğince")
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
@@ -31,7 +33,7 @@ flowchart TD
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46 classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
class H,Y havuz class H,Y havuz
class G uyari class G,U uyari
class L1 hayal class L1 hayal
class L2 dengeli class L2 dengeli
class L3 garanti`; class L3 garanti`;
@@ -40,14 +42,15 @@ export function HuniSemasi() {
return ( return (
<ElCizimiSema <ElCizimiSema
tanim={SEMA} tanim={SEMA}
ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, sonra yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır." ariaLabel="Sihirbaz akış şeması: sıralamanla açılan programlar alan, il ve devlet/vakıf adımlarıyla aday havuzuna daraltılır; havuz 24'ün altına düşerse filtreler gevşetilir, yine de dar kalırsa liste eldeki programlarla kurulur ve dağılım havuza uyarlanır; yapay zekâ havuzdan üstte hayal, ortada dengeli, sonda garanti olacak şekilde en fazla 24 tercih seçip her satıra gerekçe yazar; risk notu ve trend özeti gerçek sıralama geçmişinden hesaplanır."
figcaption={ figcaption={
<> <>
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "} Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
<span className="font-medium text-slate-700"> <span className="font-medium text-slate-700">
dışına asla çıkmadan dışına asla çıkmadan
</span>{" "} </span>{" "}
kurar. kurar havuz dar kalırsa liste eldeki programlarla kurulur ve bunu
listende ıkça görürsün.
</> </>
} }
/> />

View File

@@ -150,7 +150,9 @@ export default function MeraklisinaPage() {
<strong>aday havuzu</strong>{" "} <strong>aday havuzu</strong>{" "}
olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz olur ve yapay zekâya öyle gider: ona boş bir sayfa değil, bu havuz
verilir. Aşağıdaki şema akışın tamamını gösterir havuz verilir. Aşağıdaki şema akışın tamamını gösterir havuz
24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği de dahil. 24&apos;ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
yetmezse listenin eldeki programlarla, sana ıkça belirtilerek
nasıl kurulduğu da dahil.
</p> </p>
<HuniSemasi /> <HuniSemasi />
</section> </section>

View File

@@ -47,13 +47,21 @@ export default async function YazdirPage() {
<header className="border-b-2 border-slate-900 pb-4"> <header className="border-b-2 border-slate-900 pb-4">
<h1 className="font-heading text-2xl font-bold"> <h1 className="font-heading text-2xl font-bold">
24 Tercihlik Kişisel Liste ve Risk Raporu {rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
</h1> </h1>
<p className="mt-1 text-sm text-slate-600"> <p className="mt-1 text-sm text-slate-600">
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
türü: {PUAN_TURLERI[params.tur]} türü: {PUAN_TURLERI[params.tur]}
{params.il ? ` · İl: ${params.il}` : ""} · {tarih} {params.il ? ` · İl: ${params.il}` : ""} · {tarih}
</p> </p>
{rapor.kapsam?.uyarlandi ? (
<p className="mt-1 text-sm text-slate-600">
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
</p>
) : null}
</header> </header>
<section className="mt-5"> <section className="mt-5">

View File

@@ -333,9 +333,18 @@ export function RaporListesi({
</p> </p>
) : null} ) : null}
{rapor.kapsam?.uyarlandi ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
dengeli · {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. Daha
fazla seçenek için alan veya il filtreni genişletebilirsin.
</p>
) : null}
{tekIl ? ( {tekIl ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Listenin 24 tercihi de tek şehirde ( Listenin {rapor.tercihler.length} tercihi de tek şehirde (
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için {tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
birkaç farklı şehir eklemeyi düşünebilirsin. birkaç farklı şehir eklemeyi düşünebilirsin.
</p> </p>

View File

@@ -75,6 +75,14 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
system: string; system: string;
kullanici: string; kullanici: string;
sema: T; sema: T;
// Sağlayıcıya giden json_schema bu şemadan üretilir (verilmezse sema).
// Lokal doğrulama HER ZAMAN sema ile yapılır: tel şeması sıkı uzunluk
// kısıtları taşıyabilir (grammar zorlaması), sağlayıcı bu kısıtları yok
// sayarsa çıktı yine gevşek şemayla kurtarılır. Not: strict json_schema'da
// minItems/maxItems bazı sağlayıcılarda 400 döndürebilir; mevcut sağlayıcı
// (deepseek) kabul ediyor — sağlayıcı değişiminde geri çekilme yolu
// wireSema'dan uzunluk kısıtlarını kaldırmaktır.
wireSema?: z.ZodType;
maxTokens: number; maxTokens: number;
}): Promise<z.infer<T> | null> { }): Promise<z.infer<T> | null> {
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da // Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
@@ -98,13 +106,16 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
json_schema: { json_schema: {
name: "yanit", name: "yanit",
strict: true, strict: true,
schema: jsonSemasi(opts.sema), schema: jsonSemasi(opts.wireSema ?? opts.sema),
}, },
}, },
}, },
{ signal: sinyal }, { signal: sinyal },
), ),
); );
// OpenRouter üst sağlayıcı arızasında HTTP 200 gövdesinde {"error": ...}
// dönebiliyor; choices o durumda yok — TypeError yerine null (retry yolu).
if (!Array.isArray(yanit.choices) || yanit.choices.length === 0) return null;
const metin = yanit.choices[0]?.message?.content; const metin = yanit.choices[0]?.message?.content;
if (!metin) return null; if (!metin) return null;
try { try {
@@ -147,7 +158,7 @@ export async function* sohbetAkisi(opts: {
); );
for await (const parca of akis) { for await (const parca of akis) {
// Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz // Yalnızca delta.content akar; varsa delta.reasoning kullanıcıya sızmaz
const metin = parca.choices[0]?.delta?.content; const metin = parca.choices?.[0]?.delta?.content;
if (metin) yield metin; if (metin) yield metin;
} }
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor // openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor

View File

@@ -5,6 +5,10 @@ import {
havuzOlustur, havuzOlustur,
havuzuKompaktJson, havuzuKompaktJson,
turLabel, turLabel,
dilimHedefiHesapla,
IDEAL_HEDEF,
type DilimHedef,
type Dilim,
type RaporParams, type RaporParams,
type AdayProgram, type AdayProgram,
} from "../rapor-havuzu"; } from "../rapor-havuzu";
@@ -27,26 +31,53 @@ const RaporSchema = z.object({
}); });
// Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu // Yapay zekâdan istenen küçük şema (kullanıcı kararı, 6 Ağu 2026): riskNotu
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; 5/13/6'lık // ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
// dilim iskeleti ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu // ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler
// ayrı listeler istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) // istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) zorlansın —
// zorlansın — reasoning kapalıyken model serbest dağılımı tutturamıyor // reasoning kapalıyken model serbest dağılımı tutturamıyor (ölçüldü: 13 hayal
// (ölçüldü: 13 hayal / 0 garanti dönmüştü). AI yalnızca seçer + gerekce yazar. // / 0 garanti dönmüştü). Uzunluklar havuza uyarlanmış hedeften gelir (havuzda
// dilim başına yeterli program olmayabiliyor; sabit 5/13/6 o durumda aritmetik
// olarak imkânsızdı ve üretim her seferinde patlıyordu). AI yalnızca seçer +
// gerekce yazar.
const AiTercihSchema = z.object({ const AiTercihSchema = z.object({
programId: z.string(), programId: z.string(),
gerekce: z.string(), gerekce: z.string(),
}); });
const AiRaporSchema = z.object({ /** Sağlayıcıya giden tel şeması: dilim uzunlukları hedefe sabitlenir. */
hayal: z.array(AiTercihSchema).length(5), function aiSemaWire(hedef: DilimHedef) {
dengeli: z.array(AiTercihSchema).length(13), return z.object({
garanti: z.array(AiTercihSchema).length(6), hayal: z.array(AiTercihSchema).length(hedef.hayal),
dengeli: z.array(AiTercihSchema).length(hedef.dengeli),
garanti: z.array(AiTercihSchema).length(hedef.garanti),
genelDegerlendirme: z.string(), genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()).min(2).max(3), uyarilar: z.array(z.string()).min(2).max(3),
}); });
}
// Lokal parse şeması: uzunluk kısıtsız. Sağlayıcı tel şemasındaki kısıtları
// yok sayarsa çıktı reddedilmez; normalize + kurtarma katmanı düzeltir.
const AiRaporGevsek = z.object({
hayal: z.array(AiTercihSchema),
dengeli: z.array(AiTercihSchema),
garanti: z.array(AiTercihSchema),
genelDegerlendirme: z.string(),
uyarilar: z.array(z.string()),
});
/**
* Listenin havuza uyarlanma bilgisi. uyarlandi=true ise dağılım ideal
* 5/13/6'dan sapmıştır (havuz yetersizdi) ve UI dürüst bir not gösterir.
* Eski kayıtlı raporlarda yok → opsiyonel.
*/
export type RaporKapsam = {
toplam: number;
dagilim: Record<Dilim, number>;
uyarlandi: boolean;
};
// uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel // uyarilar şemada zorunlu ama eski kayıtlı raporlarda yok → tipte opsiyonel
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & { export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
uyarilar?: string[]; uyarilar?: string[];
kapsam?: RaporKapsam;
// Render için havuzdan zenginleştirilmiş program bilgisi // Render için havuzdan zenginleştirilmiş program bilgisi
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok // efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
// siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku // siraGecmisi: [2021..2025] taban sıralamaları — deterministik trend oku
@@ -63,9 +94,13 @@ export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
>; >;
}; };
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen function sistemMetni(hedef: DilimHedef): string {
const adet = (d: Dilim) =>
hedef[d] === 0 ? "BOŞ liste [] döndür" : `TAM ${hedef[d]} program`;
const toplam = hedef.hayal + hedef.dengeli + hedef.garanti;
return `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi GERÇEK YÖK Atlas aday havuzundan adaya en uygun programları seçip üç listeyi
doldurmak: hayal (TAM 5 program), dengeli (TAM 13), garanti (TAM 6). doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}).
KURALLAR (kesin): KURALLAR (kesin):
- SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme. - SADECE havuzdaki programId'leri kullan; havuzda olmayan program ASLA önerme.
@@ -74,7 +109,7 @@ KURALLAR (kesin):
değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı. değeri "hayal" olan programları koy; dengeli ve garanti için de aynısı.
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini - Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
ve bölüm çeşitliliğini gözet. ve bölüm çeşitliliğini gözet.
- METİN DİYETİ: aday 24 satır okuyacak; gerekce TEK kısa cümle (en fazla ~15 - METİN DİYETİ: aday ${toplam} satır okuyacak; gerekce TEK kısa cümle (en fazla ~15
kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz kelime, Türkçe): bu programın bu aday için neden mantıklı olduğu. Dolgu söz
yok; ASLA "garanti", "kesin", "yüzde yüz" deme. yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma. - Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
@@ -82,9 +117,53 @@ KURALLAR (kesin):
buraya yazma. buraya yazma.
- uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle. - uyarilar: adaya 2-3 pratik, eyleme dönük uyarı; her biri tek kısa cümle.
(Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`; (Tercih sırası, risk notu ve trend özeti koddan hesaplanır; sen yazmazsın.)`;
}
export class RaporUretimHatasi extends Error {} export class RaporUretimHatasi extends Error {}
// Liste kurmak için gereken mutlak taban. Havuz bunun altındaysa hata ekranı
// meşrudur; üstündeyse "ne olursa olsun liste kurulur" (kullanıcı kararı,
// 7 Ağu 2026) — eksik dilimler uyarlanır ve UI'da dürüstçe belirtilir.
// Not: ACIK_SATIR=3 maskesiyle tam 3 satırlık liste fiilen açık iner;
// kabul edilen kenar durumu.
const MIN_LISTE = 3;
const DILIMLER: Dilim[] = ["hayal", "dengeli", "garanti"];
function kapsamUret(hedef: DilimHedef): RaporKapsam {
return {
toplam: hedef.hayal + hedef.dengeli + hedef.garanti,
dagilim: { ...hedef },
uyarlandi: DILIMLER.some((d) => hedef[d] !== IDEAL_HEDEF[d]),
};
}
type AiSecim = z.infer<typeof AiTercihSchema>;
/**
* AI çıktısını havuza göre normalize eder: havuzda olmayan id'ler ve
* tekrarlar atılır, her program havuzdaki GERÇEK dilimine yeniden atanır
* (modelin yanlış listeye koyması artık ret sebebi değil, düzeltilir).
*/
function normalizeEt(
parsed: z.infer<typeof AiRaporGevsek>,
havuzMap: Map<string, AdayProgram>,
): Record<Dilim, AiSecim[]> {
const gruplar: Record<Dilim, AiSecim[]> = {
hayal: [],
dengeli: [],
garanti: [],
};
const gorulen = new Set<string>();
for (const t of [...parsed.hayal, ...parsed.dengeli, ...parsed.garanti]) {
const p = havuzMap.get(t.programId);
if (!p || gorulen.has(t.programId)) continue;
gorulen.add(t.programId);
gruplar[p.dilim].push(t);
}
return gruplar;
}
// --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra // --- Deterministik satır metinleri: risk ve trend gerçek YÖK Atlas sıra
// geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod // geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod
// kesin rakam verir ("45B düzeyinden 38B düzeyine indi"). // kesin rakam verir ("45B düzeyinden 38B düzeyine indi").
@@ -161,6 +240,9 @@ function mockRapor(
params: RaporParams, params: RaporParams,
revizyonNotu?: string, revizyonNotu?: string,
): RaporSonuc { ): RaporSonuc {
// Prod ile aynı hedef hesabı: mock, uyarlanmış dağılımı da birebir taklit eder
const hedef = dilimHedefiHesapla(havuz);
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0; const efektif = (p: AdayProgram) => p.sira2025 ?? p.sira2024 ?? 0;
const dilimden = (dilim: AdayProgram["dilim"], adet: number) => const dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
havuz havuz
@@ -168,18 +250,13 @@ function mockRapor(
.sort((a, b) => efektif(a) - efektif(b)) .sort((a, b) => efektif(a) - efektif(b))
.slice(0, adet); .slice(0, adet);
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
const secilen = [ const secilen = [
...dilimden("hayal", 5), ...dilimden("hayal", hedef.hayal),
...dilimden("dengeli", 13), ...dilimden("dengeli", hedef.dengeli),
...dilimden("garanti", 6), ...dilimden("garanti", hedef.garanti),
]; ];
for (const p of havuz) {
if (secilen.length >= 24) break;
if (!secilen.includes(p)) secilen.push(p);
}
const tercihler = secilen.slice(0, 24).map((p, i) => ({ const tercihler = secilen.map((p, i) => ({
programId: p.id, programId: p.id,
sira: i + 1, sira: i + 1,
dilim: p.dilim, dilim: p.dilim,
@@ -190,7 +267,7 @@ function mockRapor(
})); }));
const programlar: RaporSonuc["programlar"] = {}; const programlar: RaporSonuc["programlar"] = {};
for (const p of secilen.slice(0, 24)) { for (const p of secilen) {
programlar[p.id] = programBilgisi(p); programlar[p.id] = programBilgisi(p);
} }
@@ -198,14 +275,15 @@ function mockRapor(
tercihler, tercihler,
genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString( genelDegerlendirme: `(Dev mock — Yapay Zeka anahtarı tanımlı değil, bu rapor gerçek Yapay Zeka çıktısı değildir.) ${params.sira.toLocaleString(
"tr-TR", "tr-TR",
)}. sıra için hayal/dengeli/garanti dağılımlı 24 tercihlik örnek liste kuruldu.${ )}. sıra için hayal/dengeli/garanti dağılımlı ${toplamHedef} tercihlik örnek liste kuruldu.${
revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : "" revizyonNotu ? ` Revizyon isteğin not alındı: "${revizyonNotu}".` : ""
}`, }`,
uyarilar: [ uyarilar: [
"(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.", "(Dev mock) Tercih dönemi kapanmadan listeyi ÖSYM sistemine kendin gir.",
"(Dev mock) Garanti dilimindeki son 3 tercihi asla silme.", "(Dev mock) Garanti dilimindeki son tercihleri asla silme.",
], ],
programlar, programlar,
kapsam: kapsamUret(hedef),
}; };
} }
@@ -215,12 +293,18 @@ export async function raporUret(
oncekiListeOzeti?: string, oncekiListeOzeti?: string,
): Promise<RaporSonuc> { ): Promise<RaporSonuc> {
const { havuz, gevsetildi } = havuzOlustur(params); const { havuz, gevsetildi } = havuzOlustur(params);
if (havuz.length < 24) { if (havuz.length < MIN_LISTE) {
throw new RaporUretimHatasi( throw new RaporUretimHatasi(
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.", "Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
); );
} }
// Dilim hedefleri havuzun gerçek dağılımına uyarlanır: sabit 5/13/6 bazı
// sıralamalarda (ör. çok iyi derece → dengeli dilim boş) imkânsızdı ve
// üretim her seferinde doğrulamada patlıyordu.
const hedef = dilimHedefiHesapla(havuz);
const toplamHedef = hedef.hayal + hedef.dengeli + hedef.garanti;
const secimVar = const secimVar =
(params.kategoriler?.length ?? 0) > 0 || (params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 || (params.iller?.length ?? 0) > 0 ||
@@ -230,7 +314,7 @@ export async function raporUret(
const gevsetmeNotu: string[] = []; const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("il")) { if (gevsetildi.includes("il")) {
gevsetmeNotu.push( 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.", `Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi ${toplamHedef}'e tamamlamak için diğerlerini de kullan.`,
); );
} }
if (gevsetildi.includes("kategori")) { if (gevsetildi.includes("kategori")) {
@@ -266,61 +350,99 @@ export async function raporUret(
.join("\n\n"); .join("\n\n");
const havuzMap = new Map(havuz.map((p) => [p.id, p])); const havuzMap = new Map(havuz.map((p) => [p.id, p]));
const efektif = (id: string) => {
const p = havuzMap.get(id)!;
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
};
let sonuc: z.infer<typeof AiRaporSchema> | null = null; let secim: Record<Dilim, AiSecim[]> | null = null;
let sonSecim: Record<Dilim, AiSecim[]> | null = null;
let genel = "";
let uyarilar: string[] = [];
let sonHata = ""; let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) { for (let deneme = 0; deneme < 2; deneme++) {
const parsed = await yapilandirilmisUret({ const parsed = await yapilandirilmisUret({
system: SISTEM, system: sistemMetni(hedef),
kullanici: kullanici:
deneme === 0 deneme === 0
? kullaniciMesaji ? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`, : `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
sema: AiRaporSchema, sema: AiRaporGevsek,
wireSema: aiSemaWire(hedef),
// Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token; // Küçük şema (24 satır × gerekce + genel + uyarılar) ~1-1.5k token;
// reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser. // reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser.
maxTokens: 4000, maxTokens: 4000,
}); });
if (!parsed) { if (!parsed) {
// Dilim uzunlukları (5/13/6) şemada sabit: sayı tutmazsa da buraya düşer sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`;
sonHata = ıktı şemaya uymadı (hayal 5, dengeli 13, garanti 6 olmalı).";
continue; continue;
} }
// Halüsinasyon koruması: id'ler havuzda, benzersiz ve doğru dilimde olmalı // Halüsinasyon koruması normalize içinde: havuz-dışı id ve tekrarlar
const gruplar = [ // atılır, dilimler havuzdan yeniden atanır. Ret yalnızca sayı tutmayınca.
["hayal", parsed.hayal], const norm = normalizeEt(parsed, havuzMap);
["dengeli", parsed.dengeli], sonSecim = norm;
["garanti", parsed.garanti], genel = parsed.genelDegerlendirme;
] as const; uyarilar = parsed.uyarilar.slice(0, 3);
const idler = gruplar.flatMap(([, liste]) => liste.map((t) => t.programId)); if (DILIMLER.every((d) => norm[d].length === hedef[d])) {
const bilinmeyen = idler.filter((id) => !havuzMap.has(id)); secim = norm;
if (bilinmeyen.length > 0) {
sonHata = `Havuzda olmayan programId kullanıldı: ${bilinmeyen.slice(0, 3).join(", ")}.`;
continue;
}
if (new Set(idler).size !== idler.length) {
sonHata = "Aynı programId birden fazla kez kullanıldı; her program tek kez seçilmeli.";
continue;
}
const yanlisDilim = gruplar.flatMap(([dilim, liste]) =>
liste
.filter((t) => havuzMap.get(t.programId)!.dilim !== dilim)
.map((t) => `${t.programId} (${dilim} listesinde ama havuzdaki dilimi farklı)`),
);
if (yanlisDilim.length > 0) {
sonHata = `Programlar yanlış dilim listesine konmuş: ${yanlisDilim.slice(0, 3).join(", ")}. Havuzdaki "dilim" alanına birebir uy.`;
continue;
}
sonuc = parsed;
break; break;
} }
sonHata = `Dilim sayıları tutmadı (beklenen: hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti}; havuz-dışı ve tekrar eden id'ler sayılmaz). Havuzdaki "dilim" alanına ve adetlere birebir uy.`;
}
if (!sonuc) { if (!secim) {
// AI hiç kullanılabilir program seçemedi (parse iki kez null ya da tüm
// id'ler havuz-dışı): gerçek üretim hatası, hata ekranı meşru.
if (!sonSecim) {
throw new RaporUretimHatasi( throw new RaporUretimHatasi(
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.", "Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
); );
} }
const son = sonSecim;
const eldeki = son.hayal.length + son.dengeli.length + son.garanti.length;
if (eldeki === 0) {
throw new RaporUretimHatasi(
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
);
}
// Kurtarma ("ne olursa olsun liste kurulur"): fazla dilim en iyi taban
// sıralamalılar kalacak şekilde kırpılır, eksik dilim havuzun seçilmemiş
// programlarından deterministik tamamlanır. hedef[d] ≤ havuzdaki sayı
// olduğu için tamamlama her zaman yeterli aday bulur.
console.warn(
`[rapor] kurtarma: AI ${eldeki} kullanılabilir program döndü, hedef ${toplamHedef} (hayal ${son.hayal.length}/${hedef.hayal}, dengeli ${son.dengeli.length}/${hedef.dengeli}, garanti ${son.garanti.length}/${hedef.garanti})`,
);
const kullanilan = new Set(
DILIMLER.flatMap((d) => son[d].map((t) => t.programId)),
);
const kurtarilan: Record<Dilim, AiSecim[]> = {
hayal: [],
dengeli: [],
garanti: [],
};
for (const d of DILIMLER) {
const liste = [...son[d]]
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
.slice(0, hedef[d]);
if (liste.length < hedef[d]) {
const adaylar = havuz
.filter((p) => p.dilim === d && !kullanilan.has(p.id))
.sort((a, b) => efektif(a.id) - efektif(b.id));
for (const p of adaylar) {
if (liste.length >= hedef[d]) break;
kullanilan.add(p.id);
liste.push({
programId: p.id,
gerekce:
"Sıralamana ve seçimlerine uygun olduğu için listene eklendi.",
});
}
}
kurtarilan[d] = liste;
}
secim = kurtarilan;
}
const programlar: RaporSonuc["programlar"] = {}; const programlar: RaporSonuc["programlar"] = {};
for (const p of havuz) { for (const p of havuz) {
@@ -328,19 +450,11 @@ export async function raporUret(
} }
// Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından // Sıra ve satır metinleri deterministik: dilim içi iyi taban sıralamasından
// kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira // kötüye dizilir (hayal -> dengeli -> garanti zaten bu yönde akar), sira
// 1-24 koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir. // koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
const efektif = (id: string) => { const nihai = secim;
const p = havuzMap.get(id)!; const tercihler = DILIMLER.flatMap((dilim) =>
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER; [...nihai[dilim]]
}; .sort((a, b) => efektif(a.programId) - efektif(b.programId))
const tercihler = (
[
["hayal", sonuc.hayal],
["dengeli", sonuc.dengeli],
["garanti", sonuc.garanti],
] as const
).flatMap(([dilim, liste]) =>
[...liste].sort((a, b) => efektif(a.programId) - efektif(b.programId))
.map((t) => ({ dilim, ...t })), .map((t) => ({ dilim, ...t })),
).map((t, i) => { ).map((t, i) => {
const p = havuzMap.get(t.programId)!; const p = havuzMap.get(t.programId)!;
@@ -355,9 +469,12 @@ export async function raporUret(
}); });
return { return {
tercihler, tercihler,
genelDegerlendirme: sonuc.genelDegerlendirme, genelDegerlendirme:
uyarilar: sonuc.uyarilar, genel.trim() ||
`Listen ${toplamHedef} tercihten oluşuyor; hayal, dengeli ve garanti dilimleri sıralamana göre dengelendi.`,
uyarilar: uyarilar.length > 0 ? uyarilar : undefined,
programlar, programlar,
kapsam: kapsamUret(hedef),
}; };
} }

View File

@@ -6,20 +6,37 @@ import { grantCredits, DENEME_KREDISI } from "./credits";
import { magicLinkEpostasi } from "./eposta"; import { magicLinkEpostasi } from "./eposta";
import { epostaGonder } from "./eposta-gonder"; import { epostaGonder } from "./eposta-gonder";
/**
* Verify URL'ini maile konacak onay sayfası URL'ine çevirir. Doğrulama linki
* GET'lendiği anda token'ı tüketen tek kullanımlık bir uçtur; mail güvenlik
* tarayıcıları (SafeLinks vb.) ve tarayıcı ön-yüklemeleri linki kullanıcıdan
* önce "ziyaret edip" token'ı yakıyordu. Maildeki link bu yüzden token'ı
* tüketmeyen /giris/dogrula sayfasına gider; verify ancak oradaki butonla
* tetiklenir.
*/
function onaySayfasiUrl(verifyUrl: string): string {
const kaynak = new URL(verifyUrl);
const hedef = new URL("/giris/dogrula", kaynak.origin);
hedef.search = kaynak.search;
return hedef.toString();
}
async function sendMagicLinkEmail(email: string, url: string) { async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
// Dev paneli "anında giriş" için son üretilen linki yakalar (bkz. // Dev paneli "anında giriş" için son üretilen linki yakalar (bkz.
// src/lib/dev/actions.ts) — prod'da asla çalışmaz. // src/lib/dev/actions.ts) — panel onay adımını atlayıp doğrudan verify
// URL'ine gittiği için ham link saklanır. Prod'da asla çalışmaz.
(globalThis as { __sonMagicLink?: string }).__sonMagicLink = url; (globalThis as { __sonMagicLink?: string }).__sonMagicLink = url;
} }
const mailLinki = onaySayfasiUrl(url);
if (!process.env.RESEND_API_KEY) { if (!process.env.RESEND_API_KEY) {
// Dev fallback: Resend anahtarı yoksa linki terminale yaz // Dev fallback: Resend anahtarı yoksa linki terminale yaz
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`); console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`);
return; return;
} }
// Gönderim hatası better-auth'a fırlatılır — aksi halde kullanıcı // 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. // "gönderildi" ekranını görür ama mail hiç çıkmaz.
await epostaGonder(email, magicLinkEpostasi(url)); await epostaGonder(email, magicLinkEpostasi(mailLinki));
} }
export const auth = betterAuth({ export const auth = betterAuth({
@@ -54,6 +71,10 @@ export const auth = betterAuth({
}, },
plugins: [ plugins: [
magicLink({ magicLink({
// Varsayılan 5 dk, mail gecikmesi + onay adımı için 15 dk'ya çıkarıldı.
// Süreyi değiştirirken e-posta metnini (src/lib/eposta.ts) ve giriş
// ekranındaki "gönderildi" mesajını (giris-form.tsx) da güncelle.
expiresIn: 900,
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
await sendMagicLinkEmail(email, url); await sendMagicLinkEmail(email, url);
}, },

View File

@@ -126,7 +126,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
</h1> </h1>
<p style="margin:0 0 24px;"> <p style="margin:0 0 24px;">
Aşağıdaki butona tıklayarak KolayTercih hesabına giriş yapabilirsin. Aşağıdaki butona tıklayarak KolayTercih hesabına giriş yapabilirsin.
Bağlantı <strong style="color:${LACIVERT};">5 dakika</strong> geçerli ve Bağlantı <strong style="color:${LACIVERT};">15 dakika</strong> geçerli ve
yalnızca bir kez kullanılabilir. yalnızca bir kez kullanılabilir.
</p> </p>
${buton(guvenliUrl, "Giriş yap")} ${buton(guvenliUrl, "Giriş yap")}
@@ -142,7 +142,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
return { return {
subject: "Merhaba, giriş bağlantını gönderdik", subject: "Merhaba, giriş bağlantını gönderdik",
html: kabuk({ html: kabuk({
onizleme: "Giriş bağlantın 5 dakika geçerli.", onizleme: "Giriş bağlantın 15 dakika geçerli.",
govde, govde,
baslikEtiketi: "Merhaba, giriş bağlantını gönderdik", baslikEtiketi: "Merhaba, giriş bağlantını gönderdik",
}), }),
@@ -152,7 +152,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
"Hesabına giriş yapmak için bu adresi tarayıcında aç:", "Hesabına giriş yapmak için bu adresi tarayıcında aç:",
url, url,
"", "",
"Bağlantı 5 dakika geçerli ve yalnızca bir kez kullanılabilir.", "Bağlantı 15 dakika geçerli ve yalnızca bir kez kullanılabilir.",
"Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.", "Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.",
].join("\n"), ].join("\n"),
}; };

View File

@@ -34,6 +34,46 @@ export interface HavuzSonuc {
const HEDEF = 24; const HEDEF = 24;
const LIMIT = 60; const LIMIT = 60;
export type DilimHedef = Record<Dilim, number>;
// Listenin ideal iskeleti: 5 hayal / 13 dengeli / 6 garanti = 24 tercih.
export const IDEAL_HEDEF: DilimHedef = { hayal: 5, dengeli: 13, garanti: 6 };
/**
* Havuzun gerçek dilim dağılımına uyarlanmış liste hedefi. İdeal 5/13/6'dan
* başlar; bir dilimde yeterli aday yoksa eksik, kapasitesi kalan dilimlere
* dağıtılır (öncelik: dengeli → garanti → hayal — omurga önce, sigorta sonra,
* en riskli dilim en son). Havuz 24'ün altındaysa hedef havuzun tamamıdır.
* Deterministiktir: aynı havuz her zaman aynı hedefi üretir.
*/
export function dilimHedefiHesapla(havuz: AdayProgram[]): DilimHedef {
const mevcut: DilimHedef = { hayal: 0, dengeli: 0, garanti: 0 };
for (const p of havuz) mevcut[p.dilim]++;
const toplamHedef = Math.min(HEDEF, havuz.length);
const hedef: DilimHedef = {
hayal: Math.min(IDEAL_HEDEF.hayal, mevcut.hayal),
dengeli: Math.min(IDEAL_HEDEF.dengeli, mevcut.dengeli),
garanti: Math.min(IDEAL_HEDEF.garanti, mevcut.garanti),
};
const oncelik: Dilim[] = ["dengeli", "garanti", "hayal"];
let eksik = toplamHedef - hedef.hayal - hedef.dengeli - hedef.garanti;
while (eksik > 0) {
let dagitildi = false;
for (const d of oncelik) {
if (eksik <= 0) break;
if (mevcut[d] > hedef[d]) {
hedef[d]++;
eksik--;
dagitildi = true;
}
}
if (!dagitildi) break;
}
return hedef;
}
// Prompt'a giden nihai havuz: dilim başına aday sayısı. Arama LIMIT ile geniş // Prompt'a giden nihai havuz: dilim başına aday sayısı. Arama LIMIT ile geniş
// yapılır (filtre/gevşetme zenginliği için), filtre sonrası buraya kırpılır — // yapılır (filtre/gevşetme zenginliği için), filtre sonrası buraya kırpılır —
// 20×3=60 program ≈ 8k token; 180 program 24k token tutuyor ve lokal/ücretsiz // 20×3=60 program ≈ 8k token; 180 program 24k token tutuyor ve lokal/ücretsiz

View File

@@ -48,5 +48,8 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1), kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
// Program adı, üniversite ve 20212025 taban sıralamaları ham veridir. // Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar, programlar: rapor.programlar,
// Kapsam (kaç program bulundu / dağılım uyarlandı mı) dürüstlük bilgisi:
// paketsiz kullanıcı da görür, maskelenmez.
kapsam: rapor.kapsam,
}; };
} }