433 lines
20 KiB
JavaScript
433 lines
20 KiB
JavaScript
/**
|
||
* 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; }
|
||
});
|
||
}
|