/** * Sunucu tarafı kullanıcı deposu — plan, abonelik, kullanıcı verisi, * API anahtarları ve belgeler. (Önceden bunların hepsi localStorage'daydı; * cihaz değişince kayboluyor, sunucu tarafında doğrulanamıyordu.) * * Kalıcı volume: server/data/users.sqlite */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; let DatabaseSync = null; try { ({ DatabaseSync } = await import('node:sqlite')); } catch { console.warn('[user-store] node:sqlite yok — sunucu tarafı kullanıcı verisi devre dışı'); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname, 'data'); const DB_PATH = path.join(DATA_DIR, 'users.sqlite'); export const BELGE_DIR = path.join(DATA_DIR, 'belgeler'); const SCHEMA = ` CREATE TABLE IF NOT EXISTS kullanicilar ( email TEXT PRIMARY KEY, ad TEXT, plan TEXT NOT NULL DEFAULT 'ucretsiz', plan_kaynak TEXT, -- 'stripe' | 'manuel' | 'deneme' plan_bitis TEXT, stripe_customer_id TEXT, stripe_sub_id TEXT, olusturma TEXT, son_giris TEXT ); CREATE INDEX IF NOT EXISTS idx_stripe_cus ON kullanicilar(stripe_customer_id); CREATE INDEX IF NOT EXISTS idx_stripe_sub ON kullanicilar(stripe_sub_id); -- localStorage yerine: favoriler, ekip, filtreler, ayarlar… CREATE TABLE IF NOT EXISTS kullanici_verisi ( email TEXT NOT NULL, anahtar TEXT NOT NULL, deger TEXT, guncelleme TEXT, PRIMARY KEY (email, anahtar) ); CREATE TABLE IF NOT EXISTS tender_state ( email TEXT NOT NULL, tender_key TEXT NOT NULL, data TEXT NOT NULL, snapshot TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, PRIMARY KEY (email, tender_key) ); CREATE TABLE IF NOT EXISTS api_anahtarlari ( id TEXT PRIMARY KEY, email TEXT NOT NULL, ad TEXT, onek TEXT NOT NULL, -- gösterim için ilk 12 karakter hash TEXT NOT NULL, -- sha256(anahtar) — ham anahtar SAKLANMAZ olusturma TEXT, son_kullanim TEXT, iptal INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_apikey_hash ON api_anahtarlari(hash); CREATE INDEX IF NOT EXISTS idx_apikey_email ON api_anahtarlari(email); CREATE TABLE IF NOT EXISTS belgeler ( id TEXT PRIMARY KEY, email TEXT NOT NULL, ad TEXT NOT NULL, kategori TEXT, boyut INTEGER NOT NULL, -- GERÇEK bayt mime TEXT, dosya TEXT NOT NULL, -- diskteki dosya adı son_kullanma TEXT, ikn TEXT, -- belge bir ihaleye aitse İKN/DTN olusturma TEXT ); CREATE INDEX IF NOT EXISTS idx_belge_email ON belgeler(email); `; export function available() { return Boolean(DatabaseSync); } export function openDb() { if (!DatabaseSync) return null; fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(BELGE_DIR, { recursive: true }); const db = new DatabaseSync(DB_PATH); try { db.exec('PRAGMA busy_timeout = 5000'); db.exec('BEGIN IMMEDIATE'); db.exec(SCHEMA); const columns = new Set(db.prepare('PRAGMA table_info(belgeler)').all().map((c) => c.name)); // Existing volumes predate these columns. Indexes must be created AFTER migration. for (const column of ['ikn', 'son_kullanma']) { if (!columns.has(column)) db.exec(`ALTER TABLE belgeler ADD COLUMN ${column} TEXT`); } if (!columns.has('tur')) db.exec("ALTER TABLE belgeler ADD COLUMN tur TEXT NOT NULL DEFAULT 'firma'"); db.exec('CREATE INDEX IF NOT EXISTS idx_belge_ikn ON belgeler(email, ikn)'); db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)') .run('20260906_document_columns', new Date().toISOString()); db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)') .run('20260906_document_type', new Date().toISOString()); db.exec('COMMIT'); } catch (error) { try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ } db.close(); throw error; } return db; } const now = () => new Date().toISOString(); const withDb = (fn) => { const db = openDb(); if (!db) throw new Error('Kullanıcı veri deposu kullanılamıyor.'); // Never report a failed write as a successful operation or a failed read as an empty account. try { return fn(db); } finally { db.close(); } }; // --- Kullanıcı / plan ------------------------------------------------------ export function ensureUser(email, ad = null) { return withDb((db) => { db.prepare(`INSERT INTO kullanicilar (email, ad, plan, olusturma, son_giris) VALUES (?,?, 'ucretsiz', ?, ?) ON CONFLICT(email) DO UPDATE SET son_giris=excluded.son_giris, ad=COALESCE(excluded.ad, kullanicilar.ad)`) .run(email, ad, now(), now()); return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email); }); } export function getUser(email) { return withDb((db) => db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email) || null); } /** Planı doğrudan ayarlar (webhook veya yönetici). */ export function setPlan(email, { plan, kaynak = 'stripe', bitis = null, customerId = null, subId = null, ad = null }) { return withDb((db) => { db.prepare(`INSERT INTO kullanicilar (email, ad, plan, plan_kaynak, plan_bitis, stripe_customer_id, stripe_sub_id, olusturma, son_giris) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(email) DO UPDATE SET plan=excluded.plan, plan_kaynak=excluded.plan_kaynak, plan_bitis=excluded.plan_bitis, stripe_customer_id=COALESCE(excluded.stripe_customer_id, kullanicilar.stripe_customer_id), stripe_sub_id=COALESCE(excluded.stripe_sub_id, kullanicilar.stripe_sub_id), ad=COALESCE(excluded.ad, kullanicilar.ad)`) .run(email, ad, plan, kaynak, bitis, customerId, subId, now(), now()); return db.prepare('SELECT * FROM kullanicilar WHERE email=?').get(email); }); } export function findByStripe({ customerId, subId }) { return withDb((db) => { if (subId) { const r = db.prepare('SELECT * FROM kullanicilar WHERE stripe_sub_id=?').get(subId); if (r) return r; } if (customerId) return db.prepare('SELECT * FROM kullanicilar WHERE stripe_customer_id=?').get(customerId) || null; return null; }); } // --- Kullanıcı verisi (localStorage yerine) -------------------------------- const IZINLI_ANAHTARLAR = new Set([ 'favori_kurumlar', 'ekip', 'saved_filters', 'reminder', 'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban', 'iptal_geri_bildirim', 'firma_profil', // yetkinlikler + uyum eşiği 'takip', // takipteki İKN'ler — zeyilname/sonuç izleme sunucuda çalışır 'gorevler', // ihaleden açılan hazırlık görevleri 'telegram', // { chatId, ad, baglanma } ]); export function getData(email, anahtar) { if (!IZINLI_ANAHTARLAR.has(anahtar)) return null; return withDb((db) => { const r = db.prepare('SELECT deger FROM kullanici_verisi WHERE email=? AND anahtar=?').get(email, anahtar); if (!r?.deger) return null; try { return JSON.parse(r.deger); } catch { return null; } }); } export function getAllData(email) { return withDb((db) => { const rows = db.prepare('SELECT anahtar, deger FROM kullanici_verisi WHERE email=?').all(email); const out = {}; for (const r of rows) { try { out[r.anahtar] = JSON.parse(r.deger); } catch { /* bozuk kayıt atlanır */ } } return out; }, {}); } export function setData(email, anahtar, deger) { if (!IZINLI_ANAHTARLAR.has(anahtar)) throw new Error(`Bilinmeyen veri anahtarı: ${anahtar}`); return withDb((db) => { db.prepare(`INSERT INTO kullanici_verisi (email, anahtar, deger, guncelleme) VALUES (?,?,?,?) ON CONFLICT(email, anahtar) DO UPDATE SET deger=excluded.deger, guncelleme=excluded.guncelleme`) .run(email, anahtar, JSON.stringify(deger ?? null), now()); return true; }, false); } // --- API anahtarları (gerçek, hash'li) ------------------------------------- const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'); /** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */ export function createApiKey(email, ad) { const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url'); const id = crypto.randomUUID(); withDb((db) => db.prepare( 'INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)' ).run(id, email, ad || 'API Anahtarı', ham.slice(0, 12), sha256(ham), now())); return { id, ad: ad || 'API Anahtarı', onek: ham.slice(0, 12), anahtar: ham, olusturma: now() }; } export function listApiKeys(email) { return withDb((db) => db.prepare( 'SELECT id, ad, onek, olusturma, son_kullanim, iptal FROM api_anahtarlari WHERE email=? AND iptal=0 ORDER BY olusturma DESC' ).all(email), []); } export function revokeApiKey(email, id) { return withDb((db) => db.prepare('UPDATE api_anahtarlari SET iptal=1 WHERE id=? AND email=?').run(id, email).changes > 0, false); } /** Ham anahtarı doğrular; geçerliyse sahibini döner. */ export function verifyApiKey(ham) { if (!ham || !String(ham).startsWith('ih_live_')) return null; return withDb((db) => { const r = db.prepare('SELECT * FROM api_anahtarlari WHERE hash=? AND iptal=0').get(sha256(ham)); if (!r) return null; db.prepare('UPDATE api_anahtarlari SET son_kullanim=? WHERE id=?').run(now(), r.id); return { email: r.email, id: r.id, ad: r.ad }; }); } // --- Belgeler (gerçek dosya, gerçek boyut) --------------------------------- export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma, ikn, tur = 'firma' }) { if (!['firma','ihale'].includes(tur)) throw new Error('Geçersiz belge türü.'); const id = crypto.randomUUID(); withDb((db) => db.prepare( 'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,ikn,olusturma,tur) VALUES (?,?,?,?,?,?,?,?,?,?,?)' ).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, ikn || null, now(),tur)); return id; } /** A rejected metadata insert must not leave private uploaded bytes on disk. */ export function saveDocumentFile(email, metadata, bytes) { fs.mkdirSync(BELGE_DIR, { recursive:true }); const dosya = crypto.randomUUID(); const destination = path.join(BELGE_DIR, dosya); fs.writeFileSync(destination, bytes, { flag:'wx', mode:0o600 }); try { return addDocument(email, { ...metadata, dosya, boyut:bytes.length }); } catch (error) { try { fs.unlinkSync(destination); } catch (cleanupError) { throw new AggregateError([error,cleanupError], 'Belge kaydedilemedi ve geçici dosya temizlenemedi.'); } throw error; } } export function listDocuments(email, { ikn } = {}) { const alanlar = 'id, ad, kategori, boyut, mime, son_kullanma, ikn, olusturma, dosya, tur'; return withDb((db) => (ikn ? db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? AND ikn=? ORDER BY olusturma DESC`).all(email, ikn) : db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? ORDER BY olusturma DESC`).all(email) ).map(({dosya,...record}) => ({...record, erisilebilir: fs.existsSync(path.join(BELGE_DIR,dosya))})), []); } export function getDocument(email, id) { return withDb((db) => db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id, email) || null); } /** Belge üst verisi: son kullanma / kategori (süre takibi için). */ export function updateDocument(email, id, patch = {}) { return withDb((db) => { const hasDate = Object.hasOwn(patch,'sonKullanma'); const hasCategory = Object.hasOwn(patch,'kategori'); const r = db.prepare(`UPDATE belgeler SET son_kullanma = CASE WHEN ? THEN ? ELSE son_kullanma END, kategori = CASE WHEN ? THEN ? ELSE kategori END WHERE id=? AND email=?`).run(Number(hasDate), patch.sonKullanma ?? null, Number(hasCategory), patch.kategori ?? null, id, email); return r.changes > 0; }, false); } /** Arka plan işleri için tüm kullanıcılar (zeyilname/sonuç izleme). */ export function listUsers() { return withDb((db) => db.prepare('SELECT email, ad FROM kullanicilar').all(), []); } export function deleteDocument(email, id) { const rec = getDocument(email, id); if (!rec) return false; try { fs.unlinkSync(path.join(BELGE_DIR, rec.dosya)); } catch (error) { if (error.code !== 'ENOENT') throw error; } return withDb((db) => db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id, email).changes > 0, false); } export function documentUsage(email) { return withDb((db) => { const r = db.prepare('SELECT COUNT(*) adet, COALESCE(SUM(boyut),0) bayt FROM belgeler WHERE email=?').get(email); return { adet: r?.adet ?? 0, bayt: r?.bayt ?? 0 }; }, { adet: 0, bayt: 0 }); } // Per-tender compare-and-swap prevents stale tabs from overwriting newer preparation. export function getTenderStates(email) { return withDb(db => Object.fromEntries(db.prepare('SELECT * FROM tender_state WHERE email=?').all(email) .map(r => [r.tender_key, { data: JSON.parse(r.data), snapshot: JSON.parse(r.snapshot), version: r.version }]))); } // Current per-tender state overrides legacy mirrored follow lists, including unfollow. export function getTrackedTenderKeys(email, states = getTenderStates(email)) { const legacy = getData(email, 'takip'); const keys = new Set((Array.isArray(legacy) ? legacy : []).map(String)); for (const [key, record] of Object.entries(states)) { if (record.data.takipte === true) keys.add(key); else if (record.data.takipte === false) keys.delete(key); } return [...keys]; } export function saveTenderState(email, key, patch, snapshot, expectedVersion) { const fields = new Set(['takipte','incelendi','kanbanStatus','atananPersonel','gorev','notes','checklist','bidDraft']); if (!key || key.length > 100 || !patch || typeof patch !== 'object' || Array.isArray(patch) || Object.keys(patch).some(k => !fields.has(k)) || !Number.isInteger(expectedVersion) || expectedVersion < 0) throw new Error('Geçersiz hazırlık kaydı.'); return withDb(db => { db.exec('BEGIN IMMEDIATE'); try { const row = db.prepare('SELECT * FROM tender_state WHERE email=? AND tender_key=?').get(email,key); if ((row?.version || 0) !== expectedVersion) { const error = new Error('Bu ihale başka bir sekmede değişti. Güncel kayıt yüklendi; değişikliğinizi tekrar uygulayın.'); error.kod = 'VERSION_CONFLICT'; throw error; } const record = { data: {...(row ? JSON.parse(row.data) : {}), ...patch}, snapshot: row ? {...JSON.parse(row.snapshot), ...snapshot} : snapshot, version: expectedVersion + 1 }; db.prepare(`INSERT INTO tender_state VALUES (?,?,?,?,?,?) ON CONFLICT(email,tender_key) DO UPDATE SET data=excluded.data,snapshot=excluded.snapshot, version=excluded.version,updated_at=excluded.updated_at`) .run(email,key,JSON.stringify(record.data),JSON.stringify(record.snapshot || {}),record.version,now()); db.exec('COMMIT'); return record; } catch (error) { db.exec('ROLLBACK'); throw error; } }); }