Files
ihaleist/server/user-store.mjs
T

433 lines
20 KiB
JavaScript
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.
/**
* 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 document_text_reviews (
document_id TEXT NOT NULL, email TEXT NOT NULL, revision INTEGER NOT NULL,
file_hash TEXT NOT NULL, text_hash TEXT NOT NULL, corrected_text TEXT NOT NULL,
review_note TEXT NOT NULL, reviewed_at TEXT NOT NULL,
PRIMARY KEY (document_id,email,revision)
);
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'");
if (!columns.has('isleme')) db.exec('ALTER TABLE belgeler ADD COLUMN isleme TEXT');
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.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
.run('20260906_document_processing', new Date().toISOString());
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
.run('20260906_document_text_reviews', new Date().toISOString());
db.exec('CREATE TABLE IF NOT EXISTS document_file_cleanup (dosya TEXT PRIMARY KEY, queued_at TEXT NOT NULL)');
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)').run('20260906_document_file_cleanup', now());
db.exec('COMMIT');
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
db.close();
throw error;
}
// Cleanup is recoverable maintenance; a failure cannot undo committed deletion.
try { cleanupDocumentFiles(db); } catch { /* durable queue retries on the next database open */ }
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; }
});
}
const dataRevision = raw => raw == null ? null : crypto.createHash('sha256').update(raw).digest('hex');
export function getDataSnapshot(email) {
return withDb(db=>{
const rows=db.prepare('SELECT anahtar,deger FROM kullanici_verisi WHERE email=?').all(email);
const veri={},revisions={};
for(const row of rows){
revisions[row.anahtar]=dataRevision(row.deger);
try{veri[row.anahtar]=JSON.parse(row.deger);}catch{/* invalid data remains absent */}
}
return {veri,revisions};
},{veri:{},revisions:{}});
}
export function getAllData(email) { return getDataSnapshot(email).veri; }
export function setData(email, anahtar, deger, expectedRevision=undefined) {
if (!IZINLI_ANAHTARLAR.has(anahtar)) throw new Error(`Bilinmeyen veri anahtarı: ${anahtar}`);
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const old=db.prepare('SELECT deger FROM kullanici_verisi WHERE email=? AND anahtar=?').get(email,anahtar);
if(expectedRevision!==undefined && expectedRevision!==dataRevision(old?.deger)) {
const error=new Error('Bu kayıt başka bir sekmede veya cihazda değişti. Değişikliklerinizi bir yere kopyalayıp sayfayı yenileyin; güncel kayıt üzerinde tekrar düzenleyin.');
error.kod='VERSION_CONFLICT';throw error;
}
const raw=JSON.stringify(deger ?? null);
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, raw, now());
db.exec('COMMIT');
return expectedRevision===undefined ? true : dataRevision(raw);
} catch(error) {db.exec('ROLLBACK');throw error;}
}, 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, { limit = null } = {}) {
if (ad != null && typeof ad !== 'string') throw new Error('Anahtar adı metin olmalı.');
const name = ad?.trim() || 'API Anahtarı';
if (name.length > 120) throw new Error('Anahtar adı en fazla 120 karakter olabilir.');
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) throw new Error('Geçersiz anahtar kotası.');
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const count = db.prepare('SELECT COUNT(*) n FROM api_anahtarlari WHERE email=? AND iptal=0').get(email).n;
if (limit !== null && count >= limit) {
const error = new Error(`Planınızda en fazla ${limit} API anahtarı oluşturabilirsiniz. Kullanılmayan bir anahtarı iptal edin veya Planlar sekmesini kontrol edin.`);
error.kod = 'KOTA_ASILDI';
throw error;
}
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
const id = crypto.randomUUID(), olusturma = now();
db.prepare('INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)')
.run(id, email, name, ham.slice(0, 12), sha256(ham), olusturma);
db.exec('COMMIT');
return { id, ad:name, onek:ham.slice(0,12), anahtar:ham, olusturma };
} catch (error) { db.exec('ROLLBACK'); throw error; }
});
}
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, isleme';
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,isleme,...record}) => ({...record, isleme:isleme ? JSON.parse(isleme) : null, erisilebilir: fs.existsSync(path.join(BELGE_DIR,dosya))})), []);
}
export function setDocumentProcessing(email,id,processing) {
return withDb(db=>db.prepare('UPDATE belgeler SET isleme=? WHERE id=? AND email=?')
.run(JSON.stringify({...processing,updatedAt:now()}),id,email).changes>0);
}
export function reportDocumentReadError(email,id,expectedProcessing) {
const processing={status:'error',error:'Dosya metni okunamadı. Dosya biçimini ve bütünlüğünü kontrol edip yeniden deneyin.',updatedAt:now()};
return withDb(db=>db.prepare("UPDATE belgeler SET isleme=? WHERE id=? AND email=? AND tur='ihale' AND isleme IS ?")
.run(JSON.stringify(processing),id,email,expectedProcessing == null ? null : JSON.stringify(expectedProcessing)).changes>0);
}
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(), []);
}
function cleanupDocumentFiles(db) {
db.exec('BEGIN IMMEDIATE');
try {
for (const row of db.prepare('SELECT dosya FROM document_file_cleanup ORDER BY queued_at LIMIT 100').all()) {
// Legacy records may share a path. Never remove a still-referenced file.
if (db.prepare('SELECT 1 FROM belgeler WHERE dosya=? LIMIT 1').get(row.dosya)) continue;
const destination = path.resolve(BELGE_DIR,row.dosya);
if (path.dirname(destination) !== path.resolve(BELGE_DIR)) continue;
try { fs.unlinkSync(destination); }
catch (error) { if (error.code !== 'ENOENT') continue; }
db.prepare('DELETE FROM document_file_cleanup WHERE dosya=?').run(row.dosya);
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
}
export function deleteDocument(email, id) {
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const rec = db.prepare('SELECT dosya FROM belgeler WHERE id=? AND email=?').get(id,email);
if (!rec) { db.exec('COMMIT'); return false; }
db.prepare('INSERT OR IGNORE INTO document_file_cleanup (dosya,queued_at) VALUES (?,?)').run(rec.dosya,now());
db.prepare('DELETE FROM document_text_reviews WHERE document_id=? AND email=?').run(id,email);
db.prepare('DELETE FROM belgeler WHERE id=? AND email=?').run(id,email);
db.exec('COMMIT');
} catch(error) { db.exec('ROLLBACK'); throw error; }
// The document is now inaccessible. Unlink only after the durable commit.
try { cleanupDocumentFiles(db); } catch { /* retry through the durable queue */ }
return true;
});
}
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; }
});
}