Files
ihaleist/server/user-store.mjs
T

349 lines
15 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 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`);
}
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.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 }) {
const id = crypto.randomUUID();
withDb((db) => db.prepare(
'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,ikn,olusturma) VALUES (?,?,?,?,?,?,?,?,?,?)'
).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, ikn || null, now()));
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';
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; }
});
}