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>
234 lines
8.9 KiB
TypeScript
234 lines
8.9 KiB
TypeScript
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(),
|
||
});
|