Files
kolaytercih/src/lib/appdb/schema.ts
bilalgursen 9bd448bd16
All checks were successful
Deploy / deploy (push) Successful in 10m52s
fix(kredi): kullanıcı başına üretim kilidi — çifte harcama kapanır
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

234 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
sqliteTable,
text,
integer,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
// ---- better-auth çekirdek tabloları (+ kredi alanları) ----
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.notNull()
.default(false),
image: text("image"),
creditBalance: integer("credit_balance").notNull().default(0),
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
// Kredi-bitti hatırlatma durum makinesi (kuruldu → gönderildi):
// ilk INSUFFICIENT anı; kredi yüklenince sıfırlanır (hatırlatma iptal).
krediBittiAt: integer("kredi_bitti_at", { mode: "timestamp" }),
// Hatırlatma e-postası bir kez gönderilir, bu alan hiç sıfırlanmaz.
krediHatirlatmaGonderildiAt: integer("kredi_hatirlatma_gonderildi_at", {
mode: "timestamp",
}),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});
// ---- Ürün tabloları ----
export const orders = sqliteTable(
"orders",
{
// cuid — iyzico conversationId olarak da kullanılır
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
product: text("product", { enum: ["paket", "topup"] }).notNull(),
amountKurus: integer("amount_kurus").notNull(),
credits: integer("credits").notNull(),
status: text("status", { enum: ["pending", "paid", "failed"] })
.notNull()
.default("pending"),
iyzicoToken: text("iyzico_token"),
// Gömülü ödeme formu script'i (checkoutFormContent) — /odeme/[siparis]
// sayfası yenilendiğinde yeniden initialize gerekmesin diye saklanır;
// sipariş sonuçlanınca temizlenir.
iyzicoFormContent: text("iyzico_form_content"),
iyzicoPaymentId: text("iyzico_payment_id"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
paidAt: integer("paid_at", { mode: "timestamp" }),
},
(t) => [
index("orders_user").on(t.userId),
// Callback ve webhook siparişi token'dan bulur; UNIQUE aynı zamanda bir
// token'ın iki siparişe bağlanmasını da imkânsız kılar (SQLite'ta çoklu
// NULL serbesttir, token'sız pending siparişler etkilenmez).
uniqueIndex("orders_iyzico_token").on(t.iyzicoToken),
],
);
export const creditLedger = sqliteTable(
"credit_ledger",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
delta: integer("delta").notNull(),
reason: text("reason", {
enum: [
"trial_grant",
"purchase",
"topup",
"chat_message",
"refund",
"report_generate",
"report_revision",
],
}).notNull(),
refId: text("ref_id"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [
uniqueIndex("ledger_reason_ref").on(t.reason, t.refId),
index("ledger_user").on(t.userId),
],
);
export const chatMessages = sqliteTable(
"chat_messages",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(),
clientMessageId: text("client_message_id").unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
);
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id),
params: text("params", { mode: "json" }).notNull(),
result: text("result", { mode: "json" }),
revisionCount: integer("revision_count").notNull().default(0),
createdAt: integer("created_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ı.
// Anahtar: sıra kovası × puan türü × kategori × üniversite tipi
// ("genel" = kategorisiz/tipsiz fallback). Anonim istek LLM'e asla gitmez;
// bu tablo salt-okunur servis edilir.
// NOT: Yeni tablo/kolon eklerken `pnpm db:generate` ile migration üretilmeli;
// prod şeması açılışta yalnızca drizzle/ altındaki migration'larla güncellenir.
export const tadimlikHavuzu = sqliteTable(
"tadimlik_havuzu",
{
id: text("id").primaryKey(),
kovaSlug: text("kova_slug").notNull(),
tur: text("tur", { enum: ["say", "ea", "soz", "dil", "tyt"] }).notNull(),
kategoriSlug: text("kategori_slug").notNull(),
// Sihirbazdaki devlet/vakıf tercihine uyan varyant; "genel" = tip
// filtresi olmayan satır (tipsiz kullanıcı bunu görür).
tip: text("tip", { enum: ["genel", "devlet", "vakif"] })
.notNull()
.default("genel"),
programId: text("program_id").notNull(),
dilim: text("dilim", { enum: ["hayal", "dengeli", "garanti"] }).notNull(),
gerekce: text("gerekce").notNull(),
riskNotu: text("risk_notu").notNull(),
trendOzeti: text("trend_ozeti").notNull(),
// Üretim anındaki program snapshot'ı (isim, universite, il, unitur,
// efektifSira, siraGecmisi) — render yokatlas sorgusu gerektirmesin diye.
program: text("program", { mode: "json" }).notNull(),
// Üretimde kullanılan temsili aday sırası (kovanın orta noktası).
ornekSira: integer("ornek_sira").notNull(),
uretimAt: integer("uretim_at", { mode: "timestamp" }).notNull(),
},
(t) => [
uniqueIndex("tadimlik_anahtar").on(t.kovaSlug, t.tur, t.kategoriSlug, t.tip),
],
);
// Manuel liste + sihirbaz profili — girişte localStorage'dan merge edilir
// (çakışmada client kazanır, 24 sınırı korunur). reports ile aynı upsert deseni.
export const savedLists = sqliteTable("saved_lists", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id),
// ManuelTercih[] (≤24, sıra korunur)
items: text("items", { mode: "json" }).notNull(),
// TercihProfili | null
profil: text("profil", { mode: "json" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});