refactor: update Dockerfile and authentication flow for improved user experience
All checks were successful
Deploy / deploy (push) Successful in 15m6s
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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@superpowers-marketplace": true,
|
||||
"prd-development@pm-skills": true
|
||||
"superpowers@superpowers-marketplace": false,
|
||||
"prd-development@pm-skills": false
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"superpowers-marketplace": {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -69,10 +69,11 @@ COPY drizzle ./drizzle
|
||||
COPY scripts/db-goc.mjs ./scripts/db-goc.mjs
|
||||
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 && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R nextjs:nodejs /app/data /app/seed
|
||||
mkdir -p /app/data /app/.next/cache && \
|
||||
chown -R nextjs:nodejs /app/data /app/seed /app/.next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
102
src/app/giris/dogrula/page.tsx
Normal file
102
src/app/giris/dogrula/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,10 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
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);
|
||||
if (error) {
|
||||
@@ -93,7 +97,7 @@ export function GirisForm({ callbackURL }: { callbackURL: string }) {
|
||||
<p className="mt-1">
|
||||
<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ı 5 dakika geçerli.
|
||||
bağlantı 15 dakika geçerli.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4 text-sm">
|
||||
|
||||
@@ -14,11 +14,15 @@ export const metadata: Metadata = {
|
||||
export default async function GirisPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ callback?: string }>;
|
||||
searchParams: Promise<{ callback?: string; hata?: string }>;
|
||||
}) {
|
||||
const { callback } = await searchParams;
|
||||
const { callback, hata } = await searchParams;
|
||||
const callbackURL =
|
||||
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();
|
||||
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">
|
||||
<div className="relative">
|
||||
<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} />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{funnel ? (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||
Paketin aktif — 24 tercihinin tamamı, gerekçeler, riskler ve PDF
|
||||
artık açık.
|
||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||
PDF artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
|
||||
@@ -18,12 +18,14 @@ flowchart TD
|
||||
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")
|
||||
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")
|
||||
R --> L("24 tercihlik liste")
|
||||
L --> L1("üstte 4–6 hayal")
|
||||
L --> L2("ortada 12–14 dengeli")
|
||||
L --> L3("sonda 5–7 garanti")
|
||||
R --> L("En fazla 24 tercihlik liste")
|
||||
L --> L1("üstte hayal — hedef 5, havuz elverdiğince")
|
||||
L --> L2("ortada dengeli — hedef 13, havuz elverdiğince")
|
||||
L --> L3("sonda garanti — hedef 6, havuz elverdiğince")
|
||||
|
||||
classDef havuz fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a
|
||||
classDef uyari fill:#fffbeb,stroke:#f59e0b,color:#92400e
|
||||
@@ -31,7 +33,7 @@ flowchart TD
|
||||
classDef dengeli fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||
class H,Y havuz
|
||||
class G uyari
|
||||
class G,U uyari
|
||||
class L1 hayal
|
||||
class L2 dengeli
|
||||
class L3 garanti`;
|
||||
@@ -40,14 +42,15 @@ export function HuniSemasi() {
|
||||
return (
|
||||
<ElCizimiSema
|
||||
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={
|
||||
<>
|
||||
Her adım havuzu biraz daha daraltır; yapay zekâ listeyi bu havuzun{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
dışına asla çıkmadan
|
||||
</span>{" "}
|
||||
kurar.
|
||||
kurar — havuz dar kalırsa liste eldeki programlarla kurulur ve bunu
|
||||
listende açıkça görürsün.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -150,7 +150,9 @@ export default function MeraklisinaPage() {
|
||||
<strong>aday havuzu</strong>{" "}
|
||||
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
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği de dahil.
|
||||
24'ün altına düşerse filtrelerin nasıl gevşetildiği, yine de
|
||||
yetmezse listenin eldeki programlarla, sana açıkça belirtilerek
|
||||
nasıl kurulduğu da dahil.
|
||||
</p>
|
||||
<HuniSemasi />
|
||||
</section>
|
||||
|
||||
@@ -47,13 +47,21 @@ export default async function YazdirPage() {
|
||||
|
||||
<header className="border-b-2 border-slate-900 pb-4">
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
||||
türü: {PUAN_TURLERI[params.tur]}
|
||||
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
||||
</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>
|
||||
|
||||
<section className="mt-5">
|
||||
|
||||
@@ -333,9 +333,18 @@ export function RaporListesi({
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<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
|
||||
birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||
</p>
|
||||
|
||||
@@ -75,6 +75,14 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
|
||||
system: string;
|
||||
kullanici: string;
|
||||
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;
|
||||
}): Promise<z.infer<T> | null> {
|
||||
// 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: {
|
||||
name: "yanit",
|
||||
strict: true,
|
||||
schema: jsonSemasi(opts.sema),
|
||||
schema: jsonSemasi(opts.wireSema ?? opts.sema),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ 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;
|
||||
if (!metin) return null;
|
||||
try {
|
||||
@@ -147,7 +158,7 @@ export async function* sohbetAkisi(opts: {
|
||||
);
|
||||
for await (const parca of akis) {
|
||||
// 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;
|
||||
}
|
||||
// openai SDK akış ortasındaki abort'u yutup iteratörü NORMAL bitiriyor
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
havuzOlustur,
|
||||
havuzuKompaktJson,
|
||||
turLabel,
|
||||
dilimHedefiHesapla,
|
||||
IDEAL_HEDEF,
|
||||
type DilimHedef,
|
||||
type Dilim,
|
||||
type RaporParams,
|
||||
type AdayProgram,
|
||||
} 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
|
||||
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; 5/13/6'lık
|
||||
// dilim iskeleti ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu
|
||||
// ayrı listeler istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile)
|
||||
// zorlansın — reasoning kapalıyken model serbest dağılımı tutturamıyor
|
||||
// (ölçüldü: 13 hayal / 0 garanti dönmüştü). AI yalnızca seçer + gerekce yazar.
|
||||
// ve trendOzeti gerçek sıra geçmişinden deterministik üretilir; dilim iskeleti
|
||||
// ve tercih sırası da koddan gelir. Dilim başına SABİT uzunluklu ayrı listeler
|
||||
// istenir ki dağılım şema düzeyinde (grammar/JSON-schema ile) zorlansın —
|
||||
// reasoning kapalıyken model serbest dağılımı tutturamıyor (ölçüldü: 13 hayal
|
||||
// / 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({
|
||||
programId: z.string(),
|
||||
gerekce: z.string(),
|
||||
});
|
||||
const AiRaporSchema = z.object({
|
||||
hayal: z.array(AiTercihSchema).length(5),
|
||||
dengeli: z.array(AiTercihSchema).length(13),
|
||||
garanti: z.array(AiTercihSchema).length(6),
|
||||
/** Sağlayıcıya giden tel şeması: dilim uzunlukları hedefe sabitlenir. */
|
||||
function aiSemaWire(hedef: DilimHedef) {
|
||||
return z.object({
|
||||
hayal: z.array(AiTercihSchema).length(hedef.hayal),
|
||||
dengeli: z.array(AiTercihSchema).length(hedef.dengeli),
|
||||
garanti: z.array(AiTercihSchema).length(hedef.garanti),
|
||||
genelDegerlendirme: z.string(),
|
||||
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()).min(2).max(3),
|
||||
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
|
||||
export type RaporSonuc = Omit<z.infer<typeof RaporSchema>, "uyarilar"> & {
|
||||
uyarilar?: string[];
|
||||
kapsam?: RaporKapsam;
|
||||
// Render için havuzdan zenginleştirilmiş program bilgisi
|
||||
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
|
||||
// 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
|
||||
doldurmak: hayal (TAM 5 program), dengeli (TAM 13), garanti (TAM 6).
|
||||
doldurmak: hayal (${adet("hayal")}), dengeli (${adet("dengeli")}), garanti (${adet("garanti")}).
|
||||
|
||||
KURALLAR (kesin):
|
||||
- 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ı.
|
||||
- Seçerken adayın ilgi alanlarını ve illerini, kontenjan/yerleşen hareketini
|
||||
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
|
||||
yok; ASLA "garanti", "kesin", "yüzde yüz" deme.
|
||||
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
|
||||
@@ -82,9 +117,53 @@ KURALLAR (kesin):
|
||||
buraya yazma.
|
||||
- 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.)`;
|
||||
}
|
||||
|
||||
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
|
||||
// geçmişinden hesaplanır; model bu alanlarda jenerik dolgu yazıyordu, kod
|
||||
// kesin rakam verir ("45B düzeyinden 38B düzeyine indi").
|
||||
@@ -161,6 +240,9 @@ function mockRapor(
|
||||
params: RaporParams,
|
||||
revizyonNotu?: string,
|
||||
): 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 dilimden = (dilim: AdayProgram["dilim"], adet: number) =>
|
||||
havuz
|
||||
@@ -168,18 +250,13 @@ function mockRapor(
|
||||
.sort((a, b) => efektif(a) - efektif(b))
|
||||
.slice(0, adet);
|
||||
|
||||
// 5 hayal + 13 dengeli + 6 garanti; eksik dilim varsa havuzdan tamamla
|
||||
const secilen = [
|
||||
...dilimden("hayal", 5),
|
||||
...dilimden("dengeli", 13),
|
||||
...dilimden("garanti", 6),
|
||||
...dilimden("hayal", hedef.hayal),
|
||||
...dilimden("dengeli", hedef.dengeli),
|
||||
...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,
|
||||
sira: i + 1,
|
||||
dilim: p.dilim,
|
||||
@@ -190,7 +267,7 @@ function mockRapor(
|
||||
}));
|
||||
|
||||
const programlar: RaporSonuc["programlar"] = {};
|
||||
for (const p of secilen.slice(0, 24)) {
|
||||
for (const p of secilen) {
|
||||
programlar[p.id] = programBilgisi(p);
|
||||
}
|
||||
|
||||
@@ -198,14 +275,15 @@ function mockRapor(
|
||||
tercihler,
|
||||
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",
|
||||
)}. 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}".` : ""
|
||||
}`,
|
||||
uyarilar: [
|
||||
"(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,
|
||||
kapsam: kapsamUret(hedef),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,12 +293,18 @@ export async function raporUret(
|
||||
oncekiListeOzeti?: string,
|
||||
): Promise<RaporSonuc> {
|
||||
const { havuz, gevsetildi } = havuzOlustur(params);
|
||||
if (havuz.length < 24) {
|
||||
if (havuz.length < MIN_LISTE) {
|
||||
throw new RaporUretimHatasi(
|
||||
"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 =
|
||||
(params.kategoriler?.length ?? 0) > 0 ||
|
||||
(params.iller?.length ?? 0) > 0 ||
|
||||
@@ -230,7 +314,7 @@ export async function raporUret(
|
||||
const gevsetmeNotu: string[] = [];
|
||||
if (gevsetildi.includes("il")) {
|
||||
gevsetmeNotu.push(
|
||||
"Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi 24'e tamamlamak için diğerlerini de kullan.",
|
||||
`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")) {
|
||||
@@ -266,60 +350,98 @@ export async function raporUret(
|
||||
.join("\n\n");
|
||||
|
||||
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 = "";
|
||||
for (let deneme = 0; deneme < 2; deneme++) {
|
||||
const parsed = await yapilandirilmisUret({
|
||||
system: SISTEM,
|
||||
system: sistemMetni(hedef),
|
||||
kullanici:
|
||||
deneme === 0
|
||||
? kullaniciMesaji
|
||||
: `${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;
|
||||
// reasoning kapalıyken 4000 rahat bir tavan, kaçak uzun üretimi de keser.
|
||||
maxTokens: 4000,
|
||||
});
|
||||
|
||||
if (!parsed) {
|
||||
// Dilim uzunlukları (5/13/6) şemada sabit: sayı tutmazsa da buraya düşer
|
||||
sonHata = "Çıktı şemaya uymadı (hayal 5, dengeli 13, garanti 6 olmalı).";
|
||||
sonHata = `Çıktı şemaya uymadı (hayal ${hedef.hayal}, dengeli ${hedef.dengeli}, garanti ${hedef.garanti} olmalı).`;
|
||||
continue;
|
||||
}
|
||||
// Halüsinasyon koruması: id'ler havuzda, benzersiz ve doğru dilimde olmalı
|
||||
const gruplar = [
|
||||
["hayal", parsed.hayal],
|
||||
["dengeli", parsed.dengeli],
|
||||
["garanti", parsed.garanti],
|
||||
] as const;
|
||||
const idler = gruplar.flatMap(([, liste]) => liste.map((t) => t.programId));
|
||||
const bilinmeyen = idler.filter((id) => !havuzMap.has(id));
|
||||
if (bilinmeyen.length > 0) {
|
||||
sonHata = `Havuzda olmayan programId kullanıldı: ${bilinmeyen.slice(0, 3).join(", ")}.`;
|
||||
continue;
|
||||
// Halüsinasyon koruması normalize içinde: havuz-dışı id ve tekrarlar
|
||||
// atılır, dilimler havuzdan yeniden atanır. Ret yalnızca sayı tutmayınca.
|
||||
const norm = normalizeEt(parsed, havuzMap);
|
||||
sonSecim = norm;
|
||||
genel = parsed.genelDegerlendirme;
|
||||
uyarilar = parsed.uyarilar.slice(0, 3);
|
||||
if (DILIMLER.every((d) => norm[d].length === hedef[d])) {
|
||||
secim = norm;
|
||||
break;
|
||||
}
|
||||
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;
|
||||
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) {
|
||||
throw new RaporUretimHatasi(
|
||||
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
|
||||
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(
|
||||
"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"] = {};
|
||||
@@ -328,19 +450,11 @@ export async function raporUret(
|
||||
}
|
||||
// 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
|
||||
// 1-24 koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
||||
const efektif = (id: string) => {
|
||||
const p = havuzMap.get(id)!;
|
||||
return p.sira2025 ?? p.sira2024 ?? Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
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))
|
||||
// koddan atanır; riskNotu + trendOzeti gerçek veriden üretilir.
|
||||
const nihai = secim;
|
||||
const tercihler = DILIMLER.flatMap((dilim) =>
|
||||
[...nihai[dilim]]
|
||||
.sort((a, b) => efektif(a.programId) - efektif(b.programId))
|
||||
.map((t) => ({ dilim, ...t })),
|
||||
).map((t, i) => {
|
||||
const p = havuzMap.get(t.programId)!;
|
||||
@@ -355,9 +469,12 @@ export async function raporUret(
|
||||
});
|
||||
return {
|
||||
tercihler,
|
||||
genelDegerlendirme: sonuc.genelDegerlendirme,
|
||||
uyarilar: sonuc.uyarilar,
|
||||
genelDegerlendirme:
|
||||
genel.trim() ||
|
||||
`Listen ${toplamHedef} tercihten oluşuyor; hayal, dengeli ve garanti dilimleri sıralamana göre dengelendi.`,
|
||||
uyarilar: uyarilar.length > 0 ? uyarilar : undefined,
|
||||
programlar,
|
||||
kapsam: kapsamUret(hedef),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,37 @@ import { grantCredits, DENEME_KREDISI } from "./credits";
|
||||
import { magicLinkEpostasi } from "./eposta";
|
||||
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) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// 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;
|
||||
}
|
||||
const mailLinki = onaySayfasiUrl(url);
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
|
||||
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
|
||||
console.log(`\n[giris] Magic link for ${email}:\n${mailLinki}\n`);
|
||||
return;
|
||||
}
|
||||
// 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));
|
||||
await epostaGonder(email, magicLinkEpostasi(mailLinki));
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
@@ -54,6 +71,10 @@ export const auth = betterAuth({
|
||||
},
|
||||
plugins: [
|
||||
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 }) => {
|
||||
await sendMagicLinkEmail(email, url);
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
|
||||
</h1>
|
||||
<p style="margin:0 0 24px;">
|
||||
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.
|
||||
</p>
|
||||
${buton(guvenliUrl, "Giriş yap")}
|
||||
@@ -142,7 +142,7 @@ export function magicLinkEpostasi(url: string): EpostaIcerik {
|
||||
return {
|
||||
subject: "Merhaba, giriş bağlantını gönderdik",
|
||||
html: kabuk({
|
||||
onizleme: "Giriş bağlantın 5 dakika geçerli.",
|
||||
onizleme: "Giriş bağlantın 15 dakika geçerli.",
|
||||
govde,
|
||||
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ç:",
|
||||
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.",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
@@ -34,6 +34,46 @@ export interface HavuzSonuc {
|
||||
|
||||
const HEDEF = 24;
|
||||
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ş
|
||||
// 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
|
||||
|
||||
@@ -48,5 +48,8 @@ export function raporMaskele(rapor: RaporSonuc): MaskeliRapor {
|
||||
kilitliUyariSayisi: Math.max(0, (rapor.uyarilar?.length ?? 0) - 1),
|
||||
// Program adı, üniversite ve 2021–2025 taban sıralamaları ham veridir.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user