Compare commits

...

20 Commits

Author SHA1 Message Date
bilalgursen
9bd448bd16 fix(kredi): kullanıcı başına üretim kilidi — çifte harcama kapanır
All checks were successful
Deploy / deploy (push) Successful in 10m52s
credit_ledger.ref_id benzersizliği yalnız AYNI denemenin tekrarını koruyordu
(ağda kaybolan cevap → aynı requestId ile retry). requestId istemcide her
mount'ta yeniden üretildiği için "aynı kullanıcı üretimi iki kez başlattı"
durumu hiç yakalanmıyordu: üretim sürerken (20-60 sn) ikinci sekmede açılan
/listem?uret=1 ya da geri→ileri ikinci bir ListeUretici monte edip 3 krediyi
ikinci kez yakıyordu. Karşılığında tek rapor çıkıyordu, çünkü reports.user_id
benzersiz ve ikinci üretim birincinin üstüne yazıyor. Bakiyesi yetmeyen
deneme kullanıcısı ise akışın ortasında "kredi yetersiz" ekranı görüyordu.

- report_locks (user_id birincil anahtar): ikinci INSERT benzersizlik
  ihlaliyle düşer, bu da "üretim sürüyor" demektir. Kilit kredi
  harcamasından ÖNCE alınır, finally'de bırakılır.
- Bayat kilit (süreç OOM'la öldü, deploy) 180 sn sonra devralınabilir;
  devir koşullu UPDATE olduğu için iki eşzamanlı devralmadan yalnız biri
  kazanır. Salım requestId koşullu: geç uyanan eski sahip devralanın
  kilidini silemez.
- Yeni SURUYOR kodu: istemci bunu "hata" sayıp taze requestId ile yeniden
  denerse çifte harcama kapısı yeniden açılırdı. Ekranda "Tekrar dene"
  bilerek yok; kullanıcı listeye yönlendiriliyor, kredi harcanmadı.
- DUPLICATE dalının "İstek tekrarlandı" HATA'sı da SURUYOR'a çevrildi
  (BACKLOG #1'deki ikinci çifte harcama yolu). Revizyon dalı değişmedi.

BACKLOG #1 · güvenlik denetimi G5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:54:21 +03:00
bilalgursen
bca1849eee fix(olcum): otomatik üretimin kaynağı gerçek yüzeyden gelir (G3)
/sonuc'taki otomatik üretim kaynağı sabit "giris_donusu" yazıyordu; oysa
?sihirbaz=1 üreten hiçbir yer giriş dönüşü değil — gerçek giriş dönüşü
/listem'e doğrudan iniyor, /sonuc'a uğramıyor. Hero, CTA formu, arama kartı,
+ kapısı, boş liste ekranı ve rapor-yok CTA'sı tek yanlış kovada
toplanıyordu, yani P2'nin ölçmek istediği soru ölçülmüyordu.

Etiket artık ?sihirbaz=1'i üreten tarafta kuruluyor ve /sonuc üzerinden
geçiriliyor; kapıdan gelenler tetikten türetiliyor. giris_donusu yalnız
gerçek callback yolunda kalıyor.

Güvenlik denetimi G3 · docs/ekip-raporlari/2026-09-22/07-guvenlik-denetimi.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:45:31 +03:00
bilalgursen
84fc88e230 merge(icerik/sezon-celiskisi): [Bilal] content(bant): sezon-dışı bant metni — B alternatifi
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:38:36 +03:00
bilalgursen
d720e1888f merge(funnel/listem-girisi): fix(giris): callback yalnız better-auth'un kabul ettiği yollarda takılır
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:38:36 +03:00
bilalgursen
001440a8a2 merge(funnel/arti-olu-ucu): content(funnel): kapının girişindeki vaat akışın gerçek bedelini söyler
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:38:36 +03:00
bilalgursen
faba6f485c merge(olcum/liste-uretim-basladi): feat(olcum): /listem?uret=1 yollarına kaynak parametresi
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:38:36 +03:00
bilalgursen
b922b466c9 content(funnel): kapının girişindeki vaat akışın gerçek bedelini söyler
Bu iki kapı hedef=sihirbaz'a çevrildiği için akış artık ücretsiz risk
rozetiyle bitmiyor; girişli kullanıcıda 3 kredilik üretime kadar gidiyor.
Vaat kapının girişinde bedeli de söylüyor (son butonda '(3 kredi)' ikinci
kez görünüyor).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:37:55 +03:00
bilalgursen
248a01042f fix(giris): callback yalnız better-auth'un kabul ettiği yollarda takılır
better-auth 1.6.23 callback'i kendi göreli-yol kalkanından geçiriyor ve \w
ASCII olduğu için /rehber/ösym ya da /bolum/t%C4%B1p gibi yolları 403
INVALID_CALLBACK_URL ile reddediyordu: kullanıcı o sayfalardan hiç giriş
yapamıyordu. Callback artık yalnız auth'un da kabul edeceği yollarda
takılıyor, gerisinde sade /giris'e düşülüyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:36:59 +03:00
bilalgursen
1364f8cb43 feat(olcum): /listem boş ekranından açılan kapı ayrı tetikle sayılır
Bu kapı huninin en sonunda açılıyor (giriş yaptı, listesi yok, profili de
yok). "diger" ile sayılınca arama kartı gibi genel yüzeylerle aynı kovaya
düşüyordu; ayrı değer bu dilimi görünür kılar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:33:01 +03:00
bilalgursen
4034f03a8b [Bilal] content(bant): sezon-dışı bant metni — B alternatifi
Yalnız `BANT_METNI["takvim-belirsiz"]` değişir. İki seçenek yan yana:

A (dalda duran, ana commit'te):
  "2027 tercihine hazırlık · 2026 yerleştirme verisi yüklü"
  Kısa, bant şeridine tek satırda sığar; ne beklendiğini söylemez.

B (bu commit):
  "2026 yerleştirme verileri yüklendi — 2027 tercih takvimi açıklanınca
   geri sayım burada başlar"
  Sayacın neden boş olduğunu açıklar ve geri döneceğini vaat eder;
  ~95 karakter, 375 px'te saat ikonunun yanında alt satıra taşar.

Cherry-pick edilmezse dal A ile tutarlı çalışır.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:32:23 +03:00
bilalgursen
419b617cab fix(listem): boş durum CTA'sı ana sayfaya atmaz, tercih kapısını açar
Profil yokken CTA kullanıcıyı "/"'a gönderiyordu: /listem'e kadar gelmiş
adayı ana sayfaya geri atmak huninin ortasında bağlam kaybı. Artık kapı
aynı sayfada açılıyor (hedef "sihirbaz", tanıtım adımı atlanıyor);
tamamlanınca mevcut yol işliyor — /sonuc?sihirbaz=1 → /listem?uret=1.

Profilli dalda hedefe kaynak etiketi eklendi (uret=1 ilk parametre kalır).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:21:46 +03:00
bilalgursen
7dc47ee1a9 fix(funnel): navbar giriş butonu kullanıcıyı bulunduğu sayfaya döndürür
Navbar'daki "Giriş yap" callback vermiyordu; giris-karti.tsx callback'siz
isteği "/"'a düşürdüğü için rehber ya da katalog okuyan ziyaretçi girişten
sonra ana sayfada uyanıyordu.

Yeni GirisLinki bileşeni usePathname ile bulunulan yolu callback yapıyor.
Callback bilerek sabit bir hedef (ör. /listem) değil: buton her sayfada
duruyor, sabit hedef bağlam kaybı olur; /listem?uret=1 yazılsaydı girişten
dönüşte üretim kendiliğinden başlar, kullanıcının ayrı onayı olmadan 3
kredi yanardı. Sorgu dizesi taşınmıyor — sorgu okuyan kanca tüm navbar'ı
Suspense zorunluluğuna sokardı.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:21:32 +03:00
bilalgursen
cb22faf628 feat(funnel): navbar'da kalıcı Yapay Zeka listesi girişi
Girişli kullanıcının /listem'e navbar'dan ulaşacak yolu yoktu: çekmece
butonu (Kendi Listem) manuel liste boşken tamamen gizleniyor, kredi pill'i
de /paket'e gidiyor. Girişli 14 oturumun 7'si /listem'i hiç görmedi.

Kredi pill'iyle birebir aynı sınıf setinde bir /listem girişi eklendi;
ikon ClipboardList (çekmecenin ListChecks'i ve kredinin Coins'i ile
karışmasın). Rapor var mı diye ek DB sorgusu yok — navbar'ın TTFB'si
bozulmasın, boş sayfada ListemBosCta karşılıyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:21:16 +03:00
bilalgursen
f734f489cc fix(ui): hero "Ücretsiz dene" butonuna kanonik sağ ok (AGENTS.md)
Kanonik CTA deseni: metin önce, sonunda `<ArrowRight className="size-4" />`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:21:02 +03:00
bilalgursen
3df74713d8 fix(icerik): kapanış CTA'sından sezon aciliyeti kalkar
"Tercih dönemi bitmeden yerini al." cümlesi her iki daldan da silindi:
üst bantta "tercih dönemi kapalı" yazarken aynı ekranda acele ettiren
bir çağrı duruyordu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:20:36 +03:00
bilalgursen
46efc7f86c fix(icerik): üst bant sezon dışında "sona erdi" demez, hazırlık çerçevesi kurar
2027 takvimi açıklanmadığı için bant artık "takvim-belirsiz" aşamasında:
geri sayım ve `role="timer"` hiç render edilmiyor (boş "--" sayaçları yok),
metin `BANT_METNI` üzerinden "2027 tercihine hazırlık · 2026 yerleştirme
verisi yüklü" oluyor. Yıl ve aralık `TERCIH_YILI`/`VERI_ARALIGI`'ndan
geliyor; takvim bilinmiyorsa `<time>` aralığı yerine veri penceresini
söyleyen düz satır duruyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:19:43 +03:00
bilalgursen
e6fbc62fe9 feat(olcum): /listem?uret=1 yollarına kaynak parametresi
Üretim denemesinin hangi yüzeyden başladığı ölçülebilsin diye dört yol
`&kaynak=` taşır: sihirbazın tamamlanması ve girişsiz callback'i
(sihirbaz), giriş dönüşündeki otomatik yönlendirme (giris_donusu), hazır
seçimler panelinin giriş CTA'sı (hazir_cta) ve paketi aktif kullanıcının
kartı (odeme_sonrasi).

Sabit href yerine listemUretHref(kaynak): `uret=1` her zaman ilk parametre
kalmalı, çünkü giris-karti.tsx:31 funnel metnini
callbackURL.startsWith("/listem?uret=1") ile seçiyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:18:24 +03:00
bilalgursen
54fb72d18c refactor(sezon): tercih takvimi tek kaynakta (lib/sezon.ts)
Üst banttaki elle gömülü 2026 başlangıç/bitiş damgaları `src/lib/sezon.ts`
içindeki `SEZON_TAKVIMI`ye taşındı. Aşama hesabı (`sezonAsamasi`) ve geri
sayım hedefi (`sezonHedefi`) artık oradan geliyor; `devam-ediyor` dalının
davranışı birebir aynı. 2027 takvimi ÖSYM tarafından açıklanmadığı için
`null` bırakıldı — tarih uydurulmadı.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:18:24 +03:00
bilalgursen
f60fbd135d feat(olcum): liste_uretim_basladi olayı — üretim denemesinin paydası
Bugüne kadar yalnız başarı (liste_olusturuldu) ölçülüyordu; kaç kişinin
üretimi denediği bilinmiyordu. /listem?uret=1 ile üretim gövdesi mount
olduğunda yeni bileşen `liste_uretim_basladi` atar.

Olay bilerek liste-uretici.tsx'e değil, listem-icerik.tsx'te ona kardeş
mount edilen yeni bir istemci bileşenine kondu (o dosya bu turda başka bir
işin altında). İkisi aynı `uret` dalında render edildiği için koşulları
birebir aynı.

`kaynak` URL'den, yani kullanıcı denetiminden geldiği için allowlist'ten
geçirilir; listede olmayan değer "bilinmiyor" raporlanır. Giriş
round-trip'inde değerin kaybolmaması için geriYol da kaynağı taşır —
`uret=1` her zaman ilk parametre kalır (giris-karti.tsx:31 sözleşmesi).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:18:15 +03:00
bilalgursen
22c139ad22 fix(funnel): + kapısını tamamlayan kullanıcı listeye/sonuca çıkar
Profilsiz + eklemesinin toast aksiyonu ve çekmecedeki "Sıralamamı gir"
butonu kapıyı hedefsiz açıyordu: üç adımı dolduran kullanıcı
profilTamamlandi'den null alıp "Tercihlerin kaydedildi." toast'ıyla
yerinde kalıyordu. İki çağrı yerinde hedef "sihirbaz" oldu — akış
/sonuc'ta devam ediyor (girişliyse üretim, girişsizse ücretsiz tablo).

tetik "arti_toast" olarak korundu; kapi_acildi serisi kırılmıyor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:17:24 +03:00
29 changed files with 1647 additions and 95 deletions

View File

@@ -0,0 +1,6 @@
CREATE TABLE `report_locks` (
`user_id` text PRIMARY KEY NOT NULL,
`request_id` text NOT NULL,
`started_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@@ -0,0 +1,966 @@
{
"version": "6",
"dialect": "sqlite",
"id": "79c73a04-96a6-4849-adf6-63a502c314d0",
"prevId": "5fbf03eb-6857-4ba1-b111-819e8aabe877",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_message_id": {
"name": "client_message_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"chat_messages_client_message_id_unique": {
"name": "chat_messages_client_message_id_unique",
"columns": [
"client_message_id"
],
"isUnique": true
},
"chat_user_created": {
"name": "chat_user_created",
"columns": [
"user_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {
"chat_messages_user_id_user_id_fk": {
"name": "chat_messages_user_id_user_id_fk",
"tableFrom": "chat_messages",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"credit_ledger": {
"name": "credit_ledger",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"delta": {
"name": "delta",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ref_id": {
"name": "ref_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_reason_ref": {
"name": "ledger_reason_ref",
"columns": [
"reason",
"ref_id"
],
"isUnique": true
},
"ledger_user": {
"name": "ledger_user",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"credit_ledger_user_id_user_id_fk": {
"name": "credit_ledger_user_id_user_id_fk",
"tableFrom": "credit_ledger",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"orders": {
"name": "orders",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"product": {
"name": "product",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_kurus": {
"name": "amount_kurus",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credits": {
"name": "credits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"iyzico_token": {
"name": "iyzico_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iyzico_form_content": {
"name": "iyzico_form_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"iyzico_payment_id": {
"name": "iyzico_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"orders_user": {
"name": "orders_user",
"columns": [
"user_id"
],
"isUnique": false
},
"orders_iyzico_token": {
"name": "orders_iyzico_token",
"columns": [
"iyzico_token"
],
"isUnique": true
}
},
"foreignKeys": {
"orders_user_id_user_id_fk": {
"name": "orders_user_id_user_id_fk",
"tableFrom": "orders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"report_locks": {
"name": "report_locks",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"report_locks_user_id_user_id_fk": {
"name": "report_locks_user_id_user_id_fk",
"tableFrom": "report_locks",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reports": {
"name": "reports",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"params": {
"name": "params",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revision_count": {
"name": "revision_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"reports_user_id_unique": {
"name": "reports_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"reports_user_id_user_id_fk": {
"name": "reports_user_id_user_id_fk",
"tableFrom": "reports",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"saved_lists": {
"name": "saved_lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"items": {
"name": "items",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"profil": {
"name": "profil",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"saved_lists_user_id_unique": {
"name": "saved_lists_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"saved_lists_user_id_user_id_fk": {
"name": "saved_lists_user_id_user_id_fk",
"tableFrom": "saved_lists",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tadimlik_havuzu": {
"name": "tadimlik_havuzu",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kova_slug": {
"name": "kova_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tur": {
"name": "tur",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kategori_slug": {
"name": "kategori_slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tip": {
"name": "tip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'genel'"
},
"program_id": {
"name": "program_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dilim": {
"name": "dilim",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"gerekce": {
"name": "gerekce",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"risk_notu": {
"name": "risk_notu",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"trend_ozeti": {
"name": "trend_ozeti",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"program": {
"name": "program",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ornek_sira": {
"name": "ornek_sira",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"uretim_at": {
"name": "uretim_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tadimlik_anahtar": {
"name": "tadimlik_anahtar",
"columns": [
"kova_slug",
"tur",
"kategori_slug",
"tip"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"credit_balance": {
"name": "credit_balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"has_paket": {
"name": "has_paket",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"kredi_bitti_at": {
"name": "kredi_bitti_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kredi_hatirlatma_gonderildi_at": {
"name": "kredi_hatirlatma_gonderildi_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -29,6 +29,13 @@
"when": 1786397951019, "when": 1786397951019,
"tag": "0003_furry_nekra", "tag": "0003_furry_nekra",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1790031029618,
"tag": "0004_shocking_caretaker",
"breakpoints": true
} }
] ]
} }

View File

@@ -20,12 +20,18 @@ export const metadata: Metadata = {
export default function ListemPage({ export default function ListemPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ acildi?: string; uret?: string }>; // kaynak: üretim denemesini hangi yüzeyin başlattığı (bkz.
// uretim-basladi-olayi.tsx). Doğrulaması okuyan tarafta, allowlist ile.
searchParams: Promise<{ acildi?: string; uret?: string; kaynak?: string }>;
}) { }) {
return ( return (
<Suspense fallback={<ListemIcerikSkeleton />}> <Suspense fallback={<ListemIcerikSkeleton />}>
{searchParams.then(({ acildi, uret }) => ( {searchParams.then(({ acildi, uret, kaynak }) => (
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} /> <ListemIcerik
uret={uret === "1"}
acildi={acildi === "1"}
kaynak={kaynak}
/>
))} ))}
</Suspense> </Suspense>
); );

View File

@@ -32,6 +32,7 @@ export default function SonucPage({
sira?: string; sira?: string;
tur?: string; tur?: string;
sihirbaz?: string; sihirbaz?: string;
kaynak?: string;
hazir?: string; hazir?: string;
il?: string | string[]; il?: string | string[];
tip?: string; tip?: string;
@@ -122,6 +123,7 @@ export default function SonucPage({
sira={sira} sira={sira}
tur={turKey} tur={turKey}
otomatikAc={params.sihirbaz === "1"} otomatikAc={params.sihirbaz === "1"}
kaynak={params.kaynak}
secimlerHazir={params.hazir === "1"} secimlerHazir={params.hazir === "1"}
/> />

View File

@@ -91,7 +91,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
return; return;
} }
if (profil) { if (profil) {
router.push(sonucHref({ sihirbaz: "1" })); router.push(sonucHref({ sihirbaz: "1", kaynak: "arama" }));
return; return;
} }
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam // Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam

View File

@@ -3,12 +3,20 @@
import { startTransition, useEffect, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"; import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react"; import { CalendarDays, Clock3 } from "lucide-react";
import {
const TERCIH_BASLANGICI = new Date("2026-07-29T00:00:00+03:00").getTime(); BANT_ARIA,
const TERCIH_BITISI = new Date("2026-08-10T23:59:59+03:00").getTime(); BANT_METNI,
SEZON_TAKVIMI,
sezonAsamasi,
sezonHedefi,
type SezonAraligi,
type SezonAsamasi,
} from "@/lib/sezon";
import { trTarih } from "@/lib/tarih";
import { TERCIH_YILI, VERI_ARALIGI } from "@/lib/veri-yillari";
type GeriSayim = { type GeriSayim = {
asama: "baslamadi" | "devam-ediyor" | "sona-erdi"; asama: SezonAsamasi;
gun: number; gun: number;
saat: number; saat: number;
dakika: number; dakika: number;
@@ -16,16 +24,9 @@ type GeriSayim = {
}; };
function geriSayimiHesapla(simdi: number): GeriSayim { function geriSayimiHesapla(simdi: number): GeriSayim {
const asama = const asama = sezonAsamasi(simdi);
simdi < TERCIH_BASLANGICI const hedef = sezonHedefi(asama);
? "baslamadi" const kalan = hedef === null ? 0 : Math.max(0, hedef - simdi);
: simdi <= TERCIH_BITISI
? "devam-ediyor"
: "sona-erdi";
const hedef =
asama === "baslamadi" ? TERCIH_BASLANGICI : TERCIH_BITISI;
const kalan = Math.max(0, hedef - simdi);
return { return {
asama, asama,
@@ -36,6 +37,27 @@ function geriSayimiHesapla(simdi: number): GeriSayim {
}; };
} }
/**
* "29 Temmuz – 10 Ağustos 2026": aralık aynı yıl içindeyse yıl yalnızca sonda
* yazılır. `trTarih` bilinçli olarak `Date`/`toLocale*` kullanmaz; sunucu ile
* tarayıcı saat dilimi farklı olsa da aynı metni üretir.
*/
function araligiBicimlendir(takvim: SezonAraligi) {
const baslangicIso = takvim.baslangic.slice(0, 10);
const bitisIso = takvim.bitis.slice(0, 10);
const yil = bitisIso.slice(0, 4);
const baslangic = trTarih(baslangicIso);
return {
baslangicIso,
bitisIso,
baslangicMetni: baslangicIso.startsWith(yil)
? baslangic.replace(` ${yil}`, "")
: baslangic,
bitisMetni: trTarih(bitisIso),
};
}
const BIRIMLER = [ const BIRIMLER = [
{ anahtar: "gun", etiket: "gün" }, { anahtar: "gun", etiket: "gün" },
{ anahtar: "saat", etiket: "sa" }, { anahtar: "saat", etiket: "sa" },
@@ -57,36 +79,50 @@ export function SiteTopBanner() {
return () => window.clearInterval(zamanlayici); return () => window.clearInterval(zamanlayici);
}, []); }, []);
const asamaMetni = // Takvim açıklanmamışsa aşama zamandan bağımsızdır; mount öncesinde de
geriSayim?.asama === "baslamadi" // bilinir, böylece boş sayaç bir an görünüp kaybolmaz. Takvim varsa sunucu
? "Başlamasına" // çıktısı eskisi gibi "devam-ediyor" varsayar, ilk tikte gerçeğine döner.
: geriSayim?.asama === "sona-erdi" const takvim = SEZON_TAKVIMI[TERCIH_YILI] ?? null;
? "Tercih dönemi sona erdi" const asama: SezonAsamasi =
: "Sona ermesine"; geriSayim?.asama ?? (takvim ? "devam-ediyor" : "takvim-belirsiz");
const hedef = sezonHedefi(asama);
const aralik = takvim ? araligiBicimlendir(takvim) : null;
return ( return (
<section <section
aria-label="2026 YKS tercih dönemi" aria-label={BANT_ARIA}
className="relative z-50 border-b border-primary-foreground/15 bg-primary text-primary-foreground print:hidden" className="relative z-50 border-b border-primary-foreground/15 bg-primary text-primary-foreground print:hidden"
> >
<div className="mx-auto flex min-h-12 max-w-6xl flex-wrap items-center justify-center gap-x-5 gap-y-1.5 px-4 py-2 sm:justify-between"> <div className="mx-auto flex min-h-12 max-w-6xl flex-wrap items-center justify-center gap-x-5 gap-y-1.5 px-4 py-2 sm:justify-between">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<CalendarDays className="size-4 shrink-0 opacity-80" aria-hidden /> <CalendarDays className="size-4 shrink-0 opacity-80" aria-hidden />
<p className="truncate text-sm font-semibold tracking-[-0.01em]"> <p className="truncate text-sm font-semibold tracking-[-0.01em]">
<span className="hidden sm:inline">2026 YKS tercih dönemi · </span> <span className="hidden sm:inline">{BANT_ARIA} · </span>
<time dateTime="2026-07-29">29 Temmuz</time> {aralik ? (
<span aria-hidden> – </span> <>
<time dateTime="2026-08-10">10 Ağustos 2026</time> <time dateTime={aralik.baslangicIso}>
{aralik.baslangicMetni}
</time>
<span aria-hidden> – </span>
<time dateTime={aralik.bitisIso}>{aralik.bitisMetni}</time>
</>
) : (
// Takvim yok: uydurma tarih yerine elimizdeki veriyi söyle.
<span>{VERI_ARALIGI} verisi</span>
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5" role="timer"> <div
className="flex items-center gap-2.5"
role={hedef !== null ? "timer" : undefined}
>
<div className="flex items-center gap-1.5 text-xs font-medium opacity-85"> <div className="flex items-center gap-1.5 text-xs font-medium opacity-85">
<Clock3 className="size-3.5" aria-hidden /> <Clock3 className="size-3.5" aria-hidden />
<span>{asamaMetni}</span> <span>{BANT_METNI[asama]}</span>
</div> </div>
{geriSayim?.asama !== "sona-erdi" ? ( {hedef !== null ? (
<dl <dl
className="flex items-baseline gap-1 font-mono text-xs tabular-nums" className="flex items-baseline gap-1 font-mono text-xs tabular-nums"
aria-label={ aria-label={

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
/**
* Navbar'ın "Giriş yap" butonu: giriş bitince kullanıcıyı **bulunduğu
* sayfaya** geri döndürür (`/giris?callback=<yol>`). Callback verilmezse
* `giris-karti.tsx` "/"'a düşürüyordu — rehber okuyan ziyaretçi girişten
* sonra ana sayfada uyanıyordu.
*
* Callback neden sabit bir hedef (ör. `/listem`) değil: buton her sayfada
* duruyor, sabit hedef bağlam kaybı olurdu; `/listem?uret=1` yazılsaydı
* girişten dönüşte üretim kendiliğinden başlar ve kullanıcının ayrı onayı
* olmadan 3 kredi yakardı.
*
* Callback yalnız pathname taşır: sorgu dizesini okuyan kanca tüm navbar'ı
* Suspense zorunluluğuna sokardı. `usePathname` bu ağaçta zaten var
* (`header-gate`), `UserNav` da site-header'da Suspense içinde render
* ediliyor — cacheComponents açıkken dinamik parametreli rotalarda gereken
* sınır hazır (next/dist/docs/.../use-pathname.md).
*/
/**
* better-auth (1.6.23) callback'i kendi göreli-yol kalkanından geçiriyor:
* `^\/(?!\/|\\|%2f|%5c)[\w\-.+/@]*(?:\?…)?$`. `\w` ASCII olduğu için
* `/rehber/ösym` ya da yüzde-kodlanmış `/bolum/t%C4%B1p` gibi yollar
* **403 INVALID_CALLBACK_URL** ile reddediliyor ve kullanıcı o sayfadan hiç
* giriş yapamıyor (main'de callback hep "/" olduğu için bu yol yoktu).
* Bu yüzden callback'i ancak auth katmanının da kabul edeceği yollarda
* takıyoruz; gerisinde sade /giris'e düşüyoruz — bağlam kaybı, kırık giriş
* akışına yeğdir. Kalkan daraltılırsa (tek yer) buradaki de gevşetilebilir.
*/
const AUTH_GUVENLI_YOL = /^\/(?!\/|\\)[\w\-./+@]*$/;
export function GirisLinki() {
const pathname = usePathname();
// /giris kendine callback veremez (döngü); pathname yoksa sade hedef.
const href =
pathname && !pathname.startsWith("/giris") && AUTH_GUVENLI_YOL.test(pathname)
? `/giris?callback=${encodeURIComponent(pathname)}`
: "/giris";
return (
<Button
asChild
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
>
<Link href={href}>Giriş yap</Link>
</Button>
);
}

View File

@@ -1,10 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import { Coins } from "lucide-react"; import { ClipboardList, Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button";
import { ListemButonu } from "@/features/liste/components/listem-butonu"; import { ListemButonu } from "@/features/liste/components/listem-butonu";
import { ListeSenkron } from "@/features/liste/components/liste-senkron"; import { ListeSenkron } from "@/features/liste/components/liste-senkron";
import { RybbitIdentify } from "@/components/rybbit-identify"; import { RybbitIdentify } from "@/components/rybbit-identify";
import { GirisLinki } from "./giris-linki";
import { SignOutButton } from "./sign-out-button"; import { SignOutButton } from "./sign-out-button";
/** /**
@@ -32,12 +32,7 @@ export async function UserNav() {
<RybbitIdentify kullaniciId={null} /> <RybbitIdentify kullaniciId={null} />
<ListeSenkron userId={null} /> <ListeSenkron userId={null} />
<ListemButonu /> <ListemButonu />
<Button <GirisLinki />
asChild
className="h-9 cursor-pointer rounded-full bg-slate-900 px-3.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-slate-700 sm:h-12 sm:px-7 sm:text-base"
>
<Link href="/giris">Giriş yap</Link>
</Button>
</div> </div>
); );
} }
@@ -47,6 +42,24 @@ export async function UserNav() {
<RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} /> <RybbitIdentify kullaniciId={u.id} eposta={u.email} isim={u.name} />
<ListeSenkron userId={u.id} /> <ListeSenkron userId={u.id} />
<ListemButonu /> <ListemButonu />
{/* Yapay Zeka raporunun kalıcı kapısı: girişli kullanıcı /listem'i
navbar'dan bulamıyordu (çekmece butonu manuel liste boşken hiç
görünmüyor). Rapor var mı diye DB'ye sorulmuyor — navbar'ın TTFB'si
bozulmasın; liste boşsa sayfadaki ListemBosCta karşılıyor.
Sınıf seti kredi pill'iyle birebir aynı (tek kaynak). */}
<Link
href="/listem"
aria-label="Yapay Zeka Listem"
title="Yapay Zeka'nın kurduğu 24 tercihlik listen"
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"
>
<ClipboardList className="size-3.5 text-primary sm:size-4" aria-hidden />
{/* ListemButonu:39 ile aynı görünürlük deseni: 375 px'te ve lg–xl
arasında metin gizli, ikon+aria-label anlamı taşıyor. */}
<span className="hidden sm:inline lg:hidden xl:inline">
Yapay Zeka Listem
</span>
</Link>
<Link <Link
href="/paket?kaynak=nav" href="/paket?kaynak=nav"
className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm" className="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:h-12 sm:gap-2 sm:px-5 sm:text-sm"

View File

@@ -280,7 +280,7 @@ export function ListeCekmecesi() {
</p> </p>
<p className="mt-1 truncate text-sm leading-relaxed text-white"> <p className="mt-1 truncate text-sm leading-relaxed text-white">
{riskEksik {riskEksik
? "Sıralamanı girince her seçimin riskini gösteririz." ? "Riskleri gösterelim; istersen 24'lük listeni de kur (3 kredi)."
: "Her seçimin yerleşme riski hazır — son kararı sen ver."} : "Her seçimin yerleşme riski hazır — son kararı sen ver."}
</p> </p>
</div> </div>
@@ -290,7 +290,10 @@ export function ListeCekmecesi() {
className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="shrink-0 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
onClick={() => { onClick={() => {
cekmeceKapat(); cekmeceKapat();
profilToplamaAc(null, true, "arti_toast"); // Hedef "sihirbaz": kapı kapanınca akış /sonuc'ta devam
// etsin (girişliyse üretim, girişsizse ücretsiz tablo) —
// kullanıcı çekmecenin kapandığı boşlukta kalmasın.
profilToplamaAc("sihirbaz", true, "arti_toast");
}} }}
> >
Sıralamamı gir Sıralamamı gir

View File

@@ -69,8 +69,12 @@ export function ProgramEkleButonu({
toast.success("Seçtiklerine eklendi", { toast.success("Seçtiklerine eklendi", {
id: PROFILSIZ_EKLEME_TOAST_ID, id: PROFILSIZ_EKLEME_TOAST_ID,
duration: 8000, duration: 8000,
// Vaat, akışın gerçek sonucuna eşit olmalı: bu kapı artık yalnız
// risk rozetiyle bitmiyor, girişli kullanıcıda 3 kredilik üretime
// kadar gidiyor (bkz. hedef "sihirbaz"). Bedeli kapının girişinde
// söylüyoruz; son butonda da "(3 kredi)" ikinci kez görünüyor.
description: description:
"Sıralamanı girersen bu programın risk seviyesini de gösteririz.", "Sıralamanı girersen bu programın risk seviyesini gösteririz; birkaç seçimle 24'lük listeni de kurabilirsin (3 kredi).",
action: { action: {
label: ( label: (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -78,8 +82,10 @@ export function ProgramEkleButonu({
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</span> </span>
), ),
// Tanıtım ekranını atla, doğrudan sıralama adımı // Tanıtım ekranını atla, doğrudan sıralama adımı. Hedef
onClick: () => profilToplamaAc(null, true, "arti_toast"), // "sihirbaz": kapı kapanınca kullanıcı yerinde kalmasın, akış
// /sonuc'ta devam etsin (girişliyse üretim, girişsizse tablo).
onClick: () => profilToplamaAc("sihirbaz", true, "arti_toast"),
}, },
}); });
} }

View File

@@ -52,6 +52,10 @@ export type KapiTetik =
| "arti_toast" | "arti_toast"
| "sonuc" | "sonuc"
| "sihirbaz" | "sihirbaz"
// Girişli ama listesiz kullanıcının /listem boş ekranındaki CTA'sı. Ayrı
// değer, çünkü bu kapı huninin en sonunda açılıyor: "giriş yaptı ama profili
// yok" dilimini arama kartı gibi genel yüzeylerden ayırt edebilmek gerek.
| "listem_bos"
| "duzenle" | "duzenle"
| "diger"; | "diger";
@@ -294,11 +298,24 @@ export function programEkleIste(opts: {
/** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner. /** Profil yazıldıktan sonra; bekleyen hedef varsa yönlendirme href'i döner.
* `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını * `girisli`, "sihirbaz" hedefinde üretimin otomatik başlayıp başlamayacağını
* belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */ * belirler (kapı bileşeni oturumu facet isteğiyle paralel çözer). */
/** Kapı tetiği → üretim denemesinin ölçüm etiketi. Yalnız `hedef="sihirbaz"`
* dalında kullanılır; listede olmayan tetik `bilinmiyor` sayılır (izinli
* değerlerin tek kaynağı: rapor/components/uretim-basladi-olayi.tsx). */
const KAYNAK_TETIKTEN: Partial<Record<KapiTetik, string>> = {
arti: "arti",
arti_toast: "arti",
listem_bos: "bos_durum",
sihirbaz: "sihirbaz",
sonuc: "sonuc_kapi",
};
export function profilTamamlandi( export function profilTamamlandi(
profil: TercihProfili, profil: TercihProfili,
girisli = false, girisli = false,
): string | null { ): string | null {
const duzenle = durum.duzenle; const duzenle = durum.duzenle;
// durum aşağıda sıfırlanıyor (tetik: "diger"); ölçüm etiketi için önce yakala
const tetik = durum.tetik;
riskleriYenile(profil); riskleriYenile(profil);
const bekleyen = durum.bekleyen; const bekleyen = durum.bekleyen;
const hedef = durum.bekleyenHedef; const hedef = durum.bekleyenHedef;
@@ -328,7 +345,15 @@ export function profilTamamlandi(
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta // Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1), // akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar. // girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
return sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }); //
// `kaynak`: girişli dalda /sonuc üretimi kendiliğinden başlatıyor, yani
// denemeyi başlatan yüzey bu kapıdır. Tetikten türetiliyor ki hero ya da
// arama kartıyla aynı kovaya düşmesin (bkz. uretim-basladi-olayi.tsx).
return sonucHref(
girisli
? { sihirbaz: "1", kaynak: KAYNAK_TETIKTEN[tetik] ?? "bilinmiyor" }
: { hazir: "1" },
);
} }
if (hedef === "sonuc") return sonucHref(); if (hedef === "sonuc") return sonucHref();
// Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır // Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır

View File

@@ -154,7 +154,7 @@ async function PaketCta() {
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok {/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */} de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" className="underline"> <Link href="/listem?uret=1&kaynak=odeme_sonrasi" className="underline">
listeni oluştur listeni oluştur
</Link> </Link>
</div> </div>

View File

@@ -21,7 +21,7 @@ export function RaporYokCta() {
asChild asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
<Link href={profil ? sonucHref({ sihirbaz: "1" }) : "/"}> <Link href={profil ? sonucHref({ sihirbaz: "1", kaynak: "rapor_yok" }) : "/"}>
24&apos;lük listeni oluştur 24&apos;lük listeni oluştur
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>

View File

@@ -25,6 +25,7 @@ export function HeroFocusButton() {
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
Ücretsiz dene Ücretsiz dene
<ArrowRight className="size-4" aria-hidden />
</Button> </Button>
); );
} }
@@ -41,8 +42,7 @@ export function KapanisCta() {
return ( return (
<> <>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90"> <p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun. Tercih Sıralaman kayıtlı — yapay zekâ danışmanın veriyle konuşsun.
dönemi bitmeden yerini al.
</p> </p>
<Button <Button
asChild asChild
@@ -61,8 +61,7 @@ export function KapanisCta() {
return ( return (
<> <>
<p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90"> <p className="mt-4 max-w-xl text-lg leading-relaxed text-white/90">
Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun. Tercih dönemi Sıralamanı gir, yapay zekâ danışmanın veriyle konuşsun.
bitmeden yerini al.
</p> </p>
<HeroFocusButton /> <HeroFocusButton />
</> </>

View File

@@ -44,22 +44,33 @@ const GECIS_SINIRI_MS = 6000;
// kesilmez, cevap gelirse akış normal devam eder. // kesilmez, cevap gelirse akış normal devam eder.
const UZUN_SURE_MS = 90_000; const UZUN_SURE_MS = 90_000;
const SAGLIK_YOKLAMA_MS = 5000;
/** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır. /** Sunucu ayakta mı (bkz. app/api/saglik). Ağ hatası ve 5xx "hayır" sayılır.
* AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı * AbortSignal.timeout Safari 16'da geldi; eski iPad'lerde yokluğu yoklamayı
* "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir. */ * "sunucu ölü" sanmaya çevirmesin diye varlığı denetlenir — ama o cihazlarda
* istek süresiz asılabilir ve yoklama artık MUTLU YOLDA (gezinmeden önce)
* koştuğu için asılması her başarılı üretimi kilitlerdi; zamanaşımı bu yüzden
* yarışla da kurulur. Kaybeden fetch'in sonradan reddetmesi `catch` ile
* yutulur, aksi hâlde sahipsiz reddetme olurdu. */
async function sunucuAyaktaMi(): Promise<boolean> { async function sunucuAyaktaMi(): Promise<boolean> {
try { const zamanasimiDestekli = typeof AbortSignal.timeout === "function";
const yanit = await fetch("/api/saglik", { const istek = fetch("/api/saglik", {
cache: "no-store", cache: "no-store",
signal: signal: zamanasimiDestekli
typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(SAGLIK_YOKLAMA_MS)
? AbortSignal.timeout(5000) : undefined,
: undefined, })
}); .then((yanit) => yanit.ok)
return yanit.ok; .catch(() => false);
} catch {
return false; if (zamanasimiDestekli) return istek;
} return Promise.race([
istek,
new Promise<boolean>((coz) =>
setTimeout(() => coz(false), SAGLIK_YOKLAMA_MS),
),
]);
} }
type Durum = type Durum =
@@ -70,6 +81,8 @@ type Durum =
| { ad: "geciliyor" } | { ad: "geciliyor" }
// Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502) // Liste kuruldu ve kaydedildi ama sunucu yanıt vermiyor (ters vekil 502)
| { ad: "sunucu-yok" } | { ad: "sunucu-yok" }
// Aynı kullanıcı için üretim başka bir sekmede/denemede zaten koşuyor
| { ad: "suruyor" }
| { ad: "kredi-yetersiz" } | { ad: "kredi-yetersiz" }
| { ad: "hata"; mesaj: string } | { ad: "hata"; mesaj: string }
| { ad: "profil-yok" }; | { ad: "profil-yok" };
@@ -128,6 +141,27 @@ export function ListeUretici({
tur: profil.tur, tur: profil.tur,
sira_kovasi: siraKovasi(profil.sira), sira_kovasi: siraKovasi(profil.sira),
}); });
// BİRİNCİL YOL KORUMASI (22 Eyl 2026). Emniyet supabındaki yoklama tek
// başına yetmiyor: router.replace'in RSC isteği ölü sunucuda 502'ye
// düşerse tam-sayfa gezinmeyi Next'in KENDİSİ yapıyor ve supap hiç
// çalışmıyor. Zincir (next 16.2.10):
// fetch-server-response.js — cevap flight değilse ya da !res.ok ise
// doMpaNavigation(url) → string döner
// segment-cache/navigation.js — typeof result === "string" →
// completeHardNavigation → pushRef.mpaNavigation = true
// app-router.js — mpaNavigation'da location.replace(canonicalUrl)
// Sonuç ters vekilin düz metin "Bad Gateway" gövdesinin tarayıcıya
// inmesi, WebKit'te de listem.txt olarak indirilmesiydi.
//
// Yoklama BİLEREK "geciliyor"dan önce: o durum 6 sn'lik supap
// zamanlayıcısını kuruyor, yoklamayı sonrasına koymak iki yolu
// yarıştırıp aynı ekranı iki kez yazdırırdı. Üretim ekranı birkaç
// milisaniye daha durur, kullanıcı için fark yok.
if (!(await sunucuAyaktaMi())) {
olay("liste_uretim_hata", { kod: "SUNUCUYA_ULASILAMIYOR" });
setDurum({ ad: "sunucu-yok" });
return;
}
setDurum({ ad: "geciliyor" }); setDurum({ ad: "geciliyor" });
// Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte // Next'in kendi gezinmesi: URL (?uret=1 düşer) ve sayfa ağacı birlikte
// güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK — // güncellenir. Burada ikinci bir yönlendirici aksiyonu (refresh) YOK —
@@ -142,6 +176,13 @@ export function ListeUretici({
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") { if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
olay("kredi_bitti"); olay("kredi_bitti");
setDurum({ ad: "kredi-yetersiz" }); setDurum({ ad: "kredi-yetersiz" });
} else if (sonuc.code === "SURUYOR") {
// Kredi HARCANMADI: sunucu kilidi krediden önce kontrol ediyor. Bu dalda
// yeniden denetmiyoruz — "Tekrar dene" taze bir requestId üretir ve
// kilit düştüğü anda ikinci bir üretim başlatır, yani tam kaçındığımız
// çifte harcama. Kullanıcıyı listeye yönlendiriyoruz; asıl üretim orada.
olay("liste_uretim_hata", { kod: "SURUYOR" });
setDurum({ ad: "suruyor" });
} else if (sonuc.code === "AUTH") { } else if (sonuc.code === "AUTH") {
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`); router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
} else { } else {
@@ -249,6 +290,8 @@ export function ListeUretici({
if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />; if (durum.ad === "sunucu-yok") return <SunucuYokEkrani />;
if (durum.ad === "suruyor") return <SuruyorEkrani />;
if (durum.ad === "hata") { if (durum.ad === "hata") {
return ( return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12"> <section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
@@ -327,6 +370,31 @@ function SunucuYokEkrani() {
); );
} }
/** Üretim başka bir sekmede/denemede sürüyor. Kredi burada HARCANMADI (kilit
* kredi harcamasından önce alınıyor, bkz. lib/uretim-kilidi.ts). Bilerek
* "Tekrar dene" yok: tekrar taze requestId üretip ikinci üretimi başlatırdı. */
function SuruyorEkrani() {
return (
<section className="mx-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h1 className="font-heading text-xl font-bold">Listen zaten hazırlanıyor</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Bunu başka bir sekmede başlatmışsın. İki kez kurmayalım — kredin bu
denemede harcanmadı. Hazır olunca listende görünecek.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">
Listeme git
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);
}
function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) { function UretimGovdesi({ uzunSurdu }: { uzunSurdu: boolean }) {
const mesaj = useUretimMesaji(); const mesaj = useUretimMesaji();

View File

@@ -4,12 +4,20 @@ import Link from "next/link";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor"; import { PagePixelDivider } from "@/components/pixel-decor";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili"; import {
profilToplamaAc,
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
// Her iki dalın butonu da aynı CTA: sınıf seti tek yerde dursun.
const CTA_SINIF = "mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600";
/** /**
* /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı * /listem boş hâlinin metni + CTA'sı: seçimleri zaten hazır olan adayı
* /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil * /sonuc üzerinden dolaştırmadan doğrudan üretime (?uret=1) alır; profil
* yoksa ana sayfadaki sıralama formuna yönlendirir. * yoksa kullanıcıyı ana sayfaya atmadan tercih kapısını **burada** açar.
* Kapı bitince `profilTamamlandi` "sihirbaz" hedefini /sonuc?sihirbaz=1'e
* çevirir, oradan /listem?uret=1'e dönülür — ayrı bir dal yok.
*/ */
export function ListemBosCta() { export function ListemBosCta() {
const profil = useTercihProfili(); const profil = useTercihProfili();
@@ -22,16 +30,24 @@ export function ListemBosCta() {
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."} : "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."}
</p> </p>
<PagePixelDivider seed={67} className="mx-auto mt-6" /> <PagePixelDivider seed={67} className="mx-auto mt-6" />
<Button {profil ? (
asChild <Button asChild size="lg" className={CTA_SINIF}>
size="lg" <Link href="/listem?uret=1&kaynak=bos_durum">
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600" Yapay Zeka listeni kur
> <ArrowRight className="size-4" aria-hidden />
<Link href={profil ? "/listem?uret=1" : "/"}> </Link>
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"} </Button>
) : (
<Button
type="button"
size="lg"
className={CTA_SINIF}
onClick={() => profilToplamaAc("sihirbaz", true, "listem_bos")}
>
Sıralamanı gir, Yapay Zeka listeni kur
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Button>
</Button> )}
</> </>
); );
} }

View File

@@ -20,6 +20,7 @@ import type { Mesaj } from "./sohbet-client";
import { ListemGovde } from "./listem-govde"; import { ListemGovde } from "./listem-govde";
import { ListemBosCta } from "./listem-bos-cta"; import { ListemBosCta } from "./listem-bos-cta";
import { ListeUretici } from "./liste-uretici"; import { ListeUretici } from "./liste-uretici";
import { UretimBasladiOlayi } from "./uretim-basladi-olayi";
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", { const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0, maximumFractionDigits: 0,
@@ -28,9 +29,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export async function ListemIcerik({ export async function ListemIcerik({
uret, uret,
acildi, acildi,
kaynak,
}: { }: {
uret: boolean; uret: boolean;
acildi: boolean; acildi: boolean;
/** Üretim denemesini başlatan yüzey (`?kaynak=`); doğrulaması
* UretimBasladiOlayi'nın allowlist'inde. */
kaynak?: string;
}) { }) {
// Tercihleri girili olan (profil çerezi duran) ziyaretçi girişten dönünce // Tercihleri girili olan (profil çerezi duran) ziyaretçi girişten dönünce
// "Henüz listen yok" boşluğuyla karşılaşmasın: callback ?uret=1 taşır, // "Henüz listen yok" boşluğuyla karşılaşmasın: callback ?uret=1 taşır,
@@ -39,7 +44,14 @@ export async function ListemIcerik({
const cerezProfili = cerezdenTercihProfili( const cerezProfili = cerezdenTercihProfili(
(await cookies()).get(PROFIL_CEREZ_ADI)?.value, (await cookies()).get(PROFIL_CEREZ_ADI)?.value,
); );
const geriYol = uret || cerezProfili ? "/listem?uret=1" : "/listem"; // `kaynak` giriş round-trip'inde korunur; yoksa girişten dönen herkes
// "bilinmiyor" okunurdu. `uret=1` HER ZAMAN ilk parametre kalmalı:
// giris-karti.tsx:31 funnel metnini `startsWith("/listem?uret=1")` ile
// seçiyor, sıra bozulursa "Seçimlerin hazır" metni kaybolur.
const geriYol =
uret || cerezProfili
? `/listem?uret=1${kaynak ? `&kaynak=${encodeURIComponent(kaynak)}` : ""}`
: "/listem";
const session = await verifySession(geriYol); const session = await verifySession(geriYol);
const [user, satir, sohbetGecmisi] = await Promise.all([ const [user, satir, sohbetGecmisi] = await Promise.all([
getCurrentUser(), getCurrentUser(),
@@ -71,11 +83,20 @@ export async function ListemIcerik({
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim {/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */} burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
{uret ? ( {uret ? (
<ListeUretici <>
hasPaket={Boolean(user.hasPaket)} {/* Denemenin paydası: üretim gövdesiyle kardeş, aynı koşulda
sunucuProfil={cerezProfili} mount olur (bkz. uretim-basladi-olayi.tsx). */}
bosDurum={bosDurum} <UretimBasladiOlayi
/> kaynak={kaynak ?? "bilinmiyor"}
paketli={Boolean(user.hasPaket)}
sunucuProfil={cerezProfili}
/>
<ListeUretici
hasPaket={Boolean(user.hasPaket)}
sunucuProfil={cerezProfili}
bosDurum={bosDurum}
/>
</>
) : ( ) : (
bosDurum bosDurum
)} )}

View File

@@ -0,0 +1,79 @@
"use client";
// Üretim denemesinin paydasını ölçen görünmez bileşen: /listem?uret=1 ile
// üretim gövdesi kurulduğunda `liste_uretim_basladi` atar. Bugüne kadar
// yalnız başarı (`liste_olusturuldu`) sayıldığı için "kaç kişi denedi, kaçı
// listeye ulaştı" sorusunun paydası yoktu.
//
// Olay bilerek üretimi başlatan `liste-uretici.tsx`'te değil, onun kardeşi
// olan bu dosyada duruyor (o dosya bu turda başka bir işin altında). İkisi de
// `listem-icerik.tsx`'te aynı `uret` dalında mount edildiğinden koşulları
// birebir aynı: biri mount oluyorsa diğeri de oluyor.
import { useEffect, useRef } from "react";
import { olay } from "@/lib/analitik";
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
/**
* `?kaynak=` sözleşmesinin izinli değerleri. Değer URL'den, yani kullanıcının
* denetiminden geliyor: allowlist olmadan Rybbit'e keyfi dizgi yazılabilir
* (kardinalite patlaması, panele enjekte edilen metin). Listede olmayan her
* şey `"bilinmiyor"`a düşer. Yeni bir üretim yüzeyi eklerken değerini önce
* buraya ekle, sonra href'e koy.
*/
const IZINLI_KAYNAKLAR = new Set([
"sihirbaz",
"hazir_cta",
// Gerçek giriş dönüşü: /giris callback'i doğrudan /listem'e iner.
"giris_donusu",
"bos_durum",
"odeme_sonrasi",
// /sonuc'ta ?sihirbaz=1 ile otomatik başlayan üretimin gerçek yüzeyleri.
// Eskiden hepsi "giris_donusu" sayılıyordu (güvenlik denetimi G3).
"hero",
"cta",
"arama",
"arti",
"sonuc_kapi",
"rapor_yok",
"bilinmiyor",
]);
export function UretimBasladiOlayi({
kaynak,
paketli,
sunucuProfil,
}: {
/** `?kaynak=` ham değeri; allowlist dışındaysa "bilinmiyor" raporlanır. */
kaynak: string;
paketli: boolean;
/** Profil çerezinden SSR'da çözülen yedek profil (bkz. listem-icerik). */
sunucuProfil: TercihProfili | null;
}) {
// Mount başına tek atış. StrictMode geliştirmede effect'i iki kez koşuyor;
// `liste-uretici.tsx`'teki `basladi` ref'iyle aynı desen. `olayBirKez`
// BİLEREK kullanılmıyor: o oturum başına tekilleştirir, oysa aynı oturumda
// ikinci deneme de paydaya girmeli.
const atildi = useRef(false);
useEffect(() => {
if (atildi.current) return;
atildi.current = true;
const kaynakGuvenli = IZINLI_KAYNAKLAR.has(kaynak) ? kaynak : "bilinmiyor";
olay("liste_uretim_basladi", {
kaynak: kaynakGuvenli,
paketli: paketli ? "evet" : "hayir",
// Üretimin gerçekten başlayabilmesinin koşulu: bu ifade
// `liste-uretici.tsx`'teki `tercihProfiliOku() ?? sunucuProfil`
// ifadesinin ikizidir — ikisi ayrı dosyada olduğu için elle eşit
// tutulmalı. O dosya açıldığında (bkz. backlog: olayın gerçek çağrı
// yerine taşınması) tek yere alınacak. Ham sıralama/seçim GÖNDERİLMEZ
// (bkz. lib/analitik.ts kuralı) — yalnız profilin varlığı.
profil: (tercihProfiliOku() ?? sunucuProfil) ? "var" : "yok",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

View File

@@ -1,6 +1,10 @@
"use server"; "use server";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import {
uretimKilidiAl,
uretimKilidiBirak,
} from "@/lib/uretim-kilidi";
import { getSession, getCurrentUser } from "@/lib/session"; import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { kullanicininRaporu } from "@/features/rapor/rapor-queries"; import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
@@ -35,7 +39,14 @@ export type ListeSonuc =
kredi: number; kredi: number;
hasPaket: boolean; hasPaket: boolean;
} }
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" }; | {
ok: false;
error: string;
// SURUYOR: aynı kullanıcı için üretim hâlihazırda koşuyor (ikinci sekme,
// geri→ileri). Ayrı kod, çünkü istemci bunu "hata" sayıp taze bir
// requestId ile yeniden denerse çifte harcama kapısı yeniden açılır.
code: "AUTH" | "PAKET" | "KREDI" | "SURUYOR" | "HATA";
};
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey { function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
return ( return (
@@ -115,12 +126,35 @@ export async function listeOlustur(input: {
const params = paramsBirlestir(input.sira, input.tur, secimler); const params = paramsBirlestir(input.sira, input.tur, secimler);
// Kullanıcı başına üretim kilidi — kredi harcamasından ÖNCE. requestId
// idempotansı yalnız aynı denemenin tekrarını koruyor; ikinci sekmede açılan
// /listem?uret=1 taze bir requestId üretip 3 krediyi ikinci kez yakıyordu.
const kilit = await uretimKilidiAl(user.id, input.requestId);
if (!kilit.ok) {
return {
ok: false,
code: "SURUYOR",
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
};
}
try {
return await listeOlusturKilitli(user, params, input.requestId);
} finally {
await uretimKilidiBirak(user.id, input.requestId);
}
}
async function listeOlusturKilitli(
user: NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>,
params: RaporParams,
requestId: string,
): Promise<ListeSonuc> {
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz) // 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
const harcama = await spendCredits({ const harcama = await spendCredits({
userId: user.id, userId: user.id,
amount: RAPOR_KREDI, amount: RAPOR_KREDI,
reason: "report_generate", reason: "report_generate",
refId: input.requestId, refId: requestId,
}); });
if (!harcama.ok) { if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") { if (harcama.error === "INSUFFICIENT") {
@@ -137,7 +171,7 @@ export async function listeOlustur(input: {
const iade = await appDb.query.creditLedger.findFirst({ const iade = await appDb.query.creditLedger.findFirst({
where: and( where: and(
eq(schema.creditLedger.reason, "refund"), eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId), eq(schema.creditLedger.refId, requestId),
), ),
}); });
if (iade) { if (iade) {
@@ -160,7 +194,14 @@ export async function listeOlustur(input: {
hasPaket: user.hasPaket, hasPaket: user.hasPaket,
}; };
} }
return { ok: false, code: "HATA", error: "İstek tekrarlandı." }; // Aynı requestId harcanmış, iade yok, rapor da henüz yazılmamış → o deneme
// hâlâ uçuşta. Genel HATA dönmek istemciyi taze bir requestId üretip
// yeniden denemeye itiyordu (BACKLOG #1'deki ikinci çifte harcama yolu).
return {
ok: false,
code: "SURUYOR",
error: "Listen zaten hazırlanıyor. Bu sekmeyi kapatabilirsin.",
};
} }
let sonuc: RaporSonuc; let sonuc: RaporSonuc;
@@ -172,7 +213,7 @@ export async function listeOlustur(input: {
userId: user.id, userId: user.id,
delta: RAPOR_KREDI, delta: RAPOR_KREDI,
reason: "refund", reason: "refund",
refId: input.requestId, refId: requestId,
}).catch(() => {}); }).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) }; return { ok: false, code: "HATA", error: aiHataMesaji(err) };
} }

View File

@@ -169,7 +169,7 @@ export function CtaSiraForm({
const yeniProfil = { sira, tur, secimler }; const yeniProfil = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil); tercihProfiliYaz(yeniProfil);
setAcik(false); setAcik(false);
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" })); router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "cta" } : { hazir: "1" }));
} }
function acikDegisti(v: boolean) { function acikDegisti(v: boolean) {

View File

@@ -101,7 +101,7 @@ export function HeroForm() {
const profil = { sira, tur, secimler }; const profil = { sira, tur, secimler };
tercihProfiliYaz(profil); tercihProfiliYaz(profil);
setModalAcik(false); setModalAcik(false);
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" })); router.push(sonucHref(girisli ? { sihirbaz: "1", kaynak: "hero" } : { hazir: "1" }));
} }
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür. // Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.

View File

@@ -12,11 +12,14 @@ export async function SonucSihirbazi({
sira, sira,
tur, tur,
otomatikAc, otomatikAc,
kaynak,
secimlerHazir, secimlerHazir,
}: { }: {
sira: number; sira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
otomatikAc: boolean; otomatikAc: boolean;
/** ?sihirbaz=1'i üreten yüzey; otomatik üretimin ölçüm etiketi olur. */
kaynak?: string;
secimlerHazir: boolean; secimlerHazir: boolean;
}) { }) {
// Kullanıcının kayıtlı raporu var mı? Yalnızca CTA'nın hangi hâlde render // Kullanıcının kayıtlı raporu var mı? Yalnızca CTA'nın hangi hâlde render
@@ -35,6 +38,7 @@ export async function SonucSihirbazi({
girisliMi={Boolean(session)} girisliMi={Boolean(session)}
raporVar={Boolean(raporSatiri?.result)} raporVar={Boolean(raporSatiri?.result)}
otomatikAc={otomatikAc} otomatikAc={otomatikAc}
kaynak={kaynak}
secimlerHazir={secimlerHazir} secimlerHazir={secimlerHazir}
/> />
); );

View File

@@ -17,7 +17,14 @@ import { SihirbazModal } from "./sihirbaz-modal";
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz // Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir. // tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
const LISTEM_URET_HREF = "/listem?uret=1"; // `kaynak` denemenin hangi yüzeyden başladığını ölçer (bkz.
// rapor/components/uretim-basladi-olayi.tsx — izinli değerler orada).
// `uret=1` HER ZAMAN ilk parametre kalmalı: giris-karti.tsx:31 funnel
// metnini `callbackURL.startsWith("/listem?uret=1")` ile seçiyor, sıra
// bozulursa girişteki "Seçimlerin hazır" metni kaybolur.
function listemUretHref(kaynak: string) {
return `/listem?uret=1&kaynak=${kaynak}`;
}
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz // SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {}; const bosAbone = () => () => {};
@@ -51,6 +58,7 @@ export function SihirbazBolumu({
girisliMi, girisliMi,
raporVar, raporVar,
otomatikAc, otomatikAc,
kaynak,
secimlerHazir, secimlerHazir,
}: { }: {
sira: number; sira: number;
@@ -61,6 +69,7 @@ export function SihirbazBolumu({
raporVar: boolean; raporVar: boolean;
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */ /** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean; otomatikAc?: boolean;
kaynak?: string;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da, /** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin. */ render edilsin. */
@@ -182,10 +191,12 @@ export function SihirbazBolumu({
await modalCikisiniOynat(); await modalCikisiniOynat();
setElleAcik(false); setElleAcik(false);
if (!girisliMi) { if (!girisliMi) {
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`); router.push(
`/giris?callback=${encodeURIComponent(listemUretHref("sihirbaz"))}`,
);
return; return;
} }
router.push(LISTEM_URET_HREF); router.push(listemUretHref("sihirbaz"));
} }
/** /**
@@ -213,7 +224,11 @@ export function SihirbazBolumu({
if (otomatikGonder && !otomatikBasladi.current) { if (otomatikGonder && !otomatikBasladi.current) {
otomatikBasladi.current = true; otomatikBasladi.current = true;
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin // replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
router.replace(LISTEM_URET_HREF); // Etiket ?sihirbaz=1'i ÜRETEN yüzeyden gelir. Eskiden burası sabit
// "giris_donusu" yazıyordu; oysa gerçek giriş dönüşü /listem'e
// doğrudan iner, /sonuc'a hiç uğramaz — hero, katalog, arama, + ve
// ödeme-sonrası tek yanlış kovada toplanıyordu.
router.replace(listemUretHref(kaynak ?? "bilinmiyor"));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikGonder]); }, [otomatikGonder]);
@@ -242,7 +257,7 @@ export function SihirbazBolumu({
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar. // kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin. // Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar); const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`; const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("hazir_cta"))}`;
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref // Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur. // başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi"); const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");

View File

@@ -17,6 +17,12 @@ export type OlayAdi =
| "sihirbaz_adim" | "sihirbaz_adim"
| "sihirbaz_tamamlandi" | "sihirbaz_tamamlandi"
| "giris_denendi" | "giris_denendi"
// Üretim denemesinin paydası: /listem?uret=1 ile üretim gövdesi mount
// olduğunda atılır (sihirbaz, hazır panel CTA'sı, giriş dönüşü, boş durum
// CTA'sı ve ödeme sonrası kartı — hangisi olduğu `kaynak` özelliğinde).
// Bilerek `olayBirKez` KULLANILMAZ: aynı oturumdaki ikinci deneme de
// sayılmalı, yoksa payda başarıdan (liste_olusturuldu) küçük kalır.
| "liste_uretim_basladi"
| "liste_olusturuldu" | "liste_olusturuldu"
| "liste_guncellendi" | "liste_guncellendi"
| "liste_revize" | "liste_revize"

View File

@@ -163,6 +163,24 @@ export const reports = sqliteTable("reports", {
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
}); });
// Kullanıcı başına üretim kilidi. `credit_ledger.ref_id` benzersizliği yalnız
// AYNI denemenin tekrarını (ağda kaybolan cevap) koruyor; requestId istemcide
// her mount'ta yeniden üretildiği için "aynı kullanıcı üretimi iki kez
// başlattı" durumunu hiç yakalamıyordu: ikinci sekme ya da geri→ileri, ikinci
// bir ListeUretici monte edip 3 krediyi ikinci kez yakıyordu (tek rapor,
// çünkü reports.user_id benzersiz ve üstüne yazılıyor).
// userId birincil anahtar → ikinci INSERT benzersizlik ihlaliyle düşer, bu da
// "üretim sürüyor" demektir. startedAt bayat kilitleri (süreç öldü, konteyner
// yeniden başladı) devralmak için var.
export const reportLocks = sqliteTable("report_locks", {
userId: text("user_id")
.primaryKey()
.references(() => user.id),
/** Kilidi tutan deneme; salım yalnız sahibi tarafından yapılabilsin diye. */
requestId: text("request_id").notNull(),
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
});
// Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları. // Anonim /sonuc tadımlığı: batch üretilmiş tek tercih satırları.
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi // Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez; // ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;

View File

@@ -21,7 +21,7 @@ export const MAX_REVIZYON = 2;
* mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız * mesaj "Failed query: …" olur, "UNIQUE" yalnız `cause` zincirinde kalır. Yalnız
* üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu. * üst mesaja bakmak idempotent no-op yerine hatayı yukarı fırlatıyordu.
*/ */
function uniqueIhlaliMi(err: unknown): boolean { export function uniqueIhlaliMi(err: unknown): boolean {
for (let e: unknown = err, i = 0; e != null && i < 5; i++) { for (let e: unknown = err, i = 0; e != null && i < 5; i++) {
const h = e as { const h = e as {
message?: unknown; message?: unknown;

86
src/lib/sezon.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Tercih sezonu takvimi — TEK KAYNAK. 2027 takvimi açıklanınca buraya yazılır;
* başka hiçbir yer değişmez. Saf: yalnız `veri-yillari` sabitlerini okur, yan
* etkisi yok — client, server ve betikler aynı dosyayı kullanabilir.
*/
import { BASLIK_YILI, TERCIH_YILI } from "@/lib/veri-yillari";
/** Bir sezonun ÖSYM takvimi. ISO 8601, Türkiye saati (+03:00) ofsetiyle. */
export type SezonAraligi = {
readonly baslangic: string;
readonly bitis: string;
};
/**
* Yıl → tercih dönemi aralığı. `null` = takvim henüz açıklanmadı; tarih
* UYDURULMAZ, sezon "takvim-belirsiz" davranışına düşer.
*/
export const SEZON_TAKVIMI: Readonly<Record<number, SezonAraligi | null>> = {
2026: {
baslangic: "2026-07-29T00:00:00+03:00",
bitis: "2026-08-10T23:59:59+03:00",
},
// ÖSYM 2027 tercih takvimini açıklamadı (22 Eyl 2026 itibarıyla).
2027: null,
};
export type SezonAsamasi =
| "takvim-belirsiz"
| "baslamadi"
| "devam-ediyor"
| "sona-erdi";
/** Verilen anda sezonun hangi aşamada olduğu. */
export function sezonAsamasi(
simdi: number,
yil: number = TERCIH_YILI,
): SezonAsamasi {
const takvim = SEZON_TAKVIMI[yil] ?? null;
if (!takvim) {
return "takvim-belirsiz";
}
const baslangic = new Date(takvim.baslangic).getTime();
const bitis = new Date(takvim.bitis).getTime();
return simdi < baslangic
? "baslamadi"
: simdi <= bitis
? "devam-ediyor"
: "sona-erdi";
}
/**
* Geri sayımın hedef zaman damgası: dönem başlamadıysa başlangıç, sürüyorsa
* bitiş. `null` dönen aşamalarda (takvim belirsiz ya da dönem kapandı) sayılacak
* bir şey yoktur — çağıran geri sayımı hiç render etmemelidir.
*/
export function sezonHedefi(
asama: SezonAsamasi,
yil: number = TERCIH_YILI,
): number | null {
const takvim = SEZON_TAKVIMI[yil] ?? null;
if (!takvim) {
return null;
}
if (asama === "baslamadi") {
return new Date(takvim.baslangic).getTime();
}
return asama === "devam-ediyor" ? new Date(takvim.bitis).getTime() : null;
}
/** Üst bandın saat ikonunun yanındaki metin — aşama başına tek kaynak. */
export const BANT_METNI: Record<SezonAsamasi, string> = {
"takvim-belirsiz": `${BASLIK_YILI} yerleştirme verileri yüklendi — ${TERCIH_YILI} tercih takvimi açıklanınca geri sayım burada başlar`,
baslamadi: "Başlamasına",
"devam-ediyor": "Sona ermesine",
"sona-erdi": `${TERCIH_YILI} tercihleri tamamlandı`,
};
/** Üst bandın `aria-label`'ı. */
export const BANT_ARIA = `${TERCIH_YILI} YKS tercih dönemi`;

77
src/lib/uretim-kilidi.ts Normal file
View File

@@ -0,0 +1,77 @@
import { and, eq, lt } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { uniqueIhlaliMi } from "./credits";
const { reportLocks } = schema;
/**
* Bayat kilit eşiği. Tek Yapay Zeka çağrısının sunucu sınırı 60 sn ve üretim
* iki denemeye kadar gidebiliyor (bkz. lib/ai/cagri.ts), üstüne havuz SQL'i ve
* DB yazımı biniyor. 180 sn bu üst sınırın rahatça üstünde: bu kadar eski bir
* kilit ancak süreç öldüyse (OOM, deploy) kalmıştır ve devralınabilir.
*/
const BAYAT_MS = 180_000;
export type KilitSonuc =
| { ok: true }
/** Aynı kullanıcı için taze bir üretim zaten koşuyor. */
| { ok: false; error: "SURUYOR" };
/**
* Kullanıcı başına üretim kilidini alır.
*
* Neden var: `credit_ledger.ref_id` benzersizliği yalnız AYNI denemenin
* tekrarını koruyor (ağda kaybolan cevap → aynı requestId ile retry). Oysa
* `requestId` istemcide her mount'ta yeniden üretiliyor, yani ikinci sekmede
* açılan `/listem?uret=1` ya da geri→ileri ikinci bir üretim başlatıp 3 krediyi
* ikinci kez yakıyordu — tek rapor karşılığında (reports.user_id benzersiz).
*
* Kilit krediden ÖNCE alınır: harcama yapılmadan reddedilir.
*/
export async function uretimKilidiAl(
userId: string,
requestId: string,
): Promise<KilitSonuc> {
const simdi = new Date();
try {
await appDb
.insert(reportLocks)
.values({ userId, requestId, startedAt: simdi });
return { ok: true };
} catch (err) {
if (!uniqueIhlaliMi(err)) throw err;
}
// Kilit var. Yalnız bayatsa devral — koşullu UPDATE olduğu için iki eşzamanlı
// devralma denemesinden yalnız biri satırı günceller (rowsAffected ile ayırt
// edilir), yani yarış çifte harcamaya dönüşemez.
const devir = await appDb
.update(reportLocks)
.set({ requestId, startedAt: simdi })
.where(
and(
eq(reportLocks.userId, userId),
lt(reportLocks.startedAt, new Date(simdi.getTime() - BAYAT_MS)),
),
);
if (devir.rowsAffected > 0) return { ok: true };
return { ok: false, error: "SURUYOR" };
}
/**
* Kilidi bırakır. `requestId` koşulu şart: bayat kilidi devralan başka bir
* deneme varken, geç uyanan eski sahip onun kilidini silmemeli.
*/
export async function uretimKilidiBirak(
userId: string,
requestId: string,
): Promise<void> {
await appDb
.delete(reportLocks)
.where(
and(eq(reportLocks.userId, userId), eq(reportLocks.requestId, requestId)),
)
// Salım hiçbir zaman asıl akışı kırmamalı: kilit en kötü ihtimalle
// BAYAT_MS sonra kendiliğinden devralınabilir hâle gelir.
.catch(() => {});
}