import {createHash,randomUUID} from 'node:crypto'; /** * Otomatik e-posta bildirimi abonelikleri (SQLite). * Kullanıcı kendi filtre kriterlerini kaydeder; zamanlayıcı yeni eşleşen * ihaleleri bulup özet e-posta gönderir. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; let DatabaseSync = null; try { ({ DatabaseSync } = await import('node:sqlite')); } catch { console.warn('[notify-store] node:sqlite yok — otomatik bildirim 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, 'notify.sqlite'); function parseArrayJson(raw, label) { try { const value = JSON.parse(raw || '[]'); if (!Array.isArray(value)) throw new Error('dizi değil'); return value; } catch { throw new Error(`${label} verisi bozuk; bildirim işlemi durduruldu.`); } } const SCHEMA = ` CREATE TABLE IF NOT EXISTS abonelikler ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, ad TEXT, sehir TEXT, sektor TEXT, alim_turu TEXT, anahtar TEXT, kurumlar TEXT, -- JSON: favori kurum listesi konular TEXT, -- JSON: konu başlıkları (kullanıcı seçer) aktif INTEGER NOT NULL DEFAULT 1, sonGonderim TEXT, sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri olusturma TEXT, criteria_key TEXT NOT NULL, UNIQUE(email, criteria_key) ); CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email); CREATE TABLE IF NOT EXISTS notification_outbox(id TEXT PRIMARY KEY,subscription_id INTEGER NOT NULL,payload TEXT NOT NULL,tender_ids TEXT NOT NULL,state TEXT NOT NULL,first_attempt INTEGER,lease TEXT,lease_until INTEGER,error TEXT,provider_id TEXT); CREATE TABLE IF NOT EXISTS notification_outbox_reviews(id TEXT PRIMARY KEY,actor TEXT NOT NULL,action TEXT NOT NULL,note TEXT NOT NULL,provider_id TEXT,reviewed_at TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS outbox_active ON notification_outbox(subscription_id) WHERE state IN ('pending','sending','uncertain'); `; export function available() { return Boolean(DatabaseSync); } function open({ create = true } = {}) { if (!DatabaseSync) return null; if (!fs.existsSync(DB_PATH)) { if (!create) return null; fs.mkdirSync(DATA_DIR, { recursive: true, mode:0o700 }); } const db = new DatabaseSync(DB_PATH); try { fs.chmodSync(DB_PATH,0o600); db.exec('PRAGMA busy_timeout=5000'); db.exec('BEGIN IMMEDIATE'); try { db.exec(SCHEMA); const columns=db.prepare('PRAGMA table_info(abonelikler)').all().map(column=>column.name); if(!columns.includes('konular'))db.exec('ALTER TABLE abonelikler ADD COLUMN konular TEXT'); if(!columns.includes('criteria_key')) { const rows=db.prepare('SELECT * FROM abonelikler ORDER BY id').all(); const sequence=db.prepare("SELECT seq FROM sqlite_sequence WHERE name='abonelikler'").get()?.seq || 0; db.exec('ALTER TABLE abonelikler RENAME TO abonelikler_legacy'); db.exec(SCHEMA); const names=['id','email','ad','sehir','sektor','alim_turu','anahtar','kurumlar','konular','aktif','sonGonderim','sonGorulenler','olusturma','criteria_key']; const insert=db.prepare(`INSERT INTO abonelikler (${names.join(',')}) VALUES (${names.map(()=>'?').join(',')})`); const used=new Set(); for(const row of rows) { const key=subscriptionKey({sehir:row.sehir,sektor:row.sektor,alimTuru:row.alim_turu,anahtar:row.anahtar, konular:JSON.parse(row.konular || '[]'),kurumlar:JSON.parse(row.kurumlar || '[]')}); const identity=JSON.stringify([row.email,key]); // Preserve legacy duplicates and their delivery history; do not discard records. row.criteria_key=used.has(identity)?key+':legacy:'+row.id:key;used.add(identity); insert.run(...names.map(name=>row[name] ?? null)); } db.exec('DROP TABLE abonelikler_legacy'); db.exec('CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email)'); const current=db.prepare("SELECT seq FROM sqlite_sequence WHERE name='abonelikler'").get(); if(current)db.prepare("UPDATE sqlite_sequence SET seq=? WHERE name='abonelikler'").run(Math.max(sequence,current.seq)); else db.prepare('INSERT INTO sqlite_sequence(name,seq) VALUES (?,?)').run('abonelikler',sequence); } db.exec('COMMIT'); }catch(error){db.exec('ROLLBACK');throw error;} return db; }catch(error){db.close();throw error;} } export function subscriptionKey(criteria) { const normal=value=>String(value || '').trim().toLocaleLowerCase('tr-TR'); const set=values=>{if(!Array.isArray(values))throw new Error('Abonelik konu/kurum listesi geçersiz.');return [...new Set(values.map(normal))].sort();}; return createHash('sha256').update(JSON.stringify([ normal(criteria.sehir),normal(criteria.sektor),normal(criteria.alimTuru),normal(criteria.anahtar),set(criteria.konular || []),set(criteria.kurumlar || []) ])).digest('hex'); } const bosaNull = (v) => (v == null || v === '' ? '' : String(v).trim()); export function subscribe(email, kriter = {}, ad = null) { if(!kriter||typeof kriter!=='object'||Array.isArray(kriter))throw new Error('Geçersiz bildirim kriterleri.'); for(const key of ['konular','kurumlar'])if(kriter[key]!==undefined&&(!Array.isArray(kriter[key])||kriter[key].length>100||kriter[key].some(value=>typeof value!=='string'||!value.trim()||value.length>300)))throw new Error('Geçersiz konu veya kurum listesi.'); for(const key of ['sehir','sektor','alimTuru','anahtar'])if(kriter[key]!==undefined&&(typeof kriter[key]!=='string'||kriter[key].length>300))throw new Error('Geçersiz filtre değeri.'); const db = open(); if (!db) throw new Error('Bildirim deposu kullanılamıyor'); try { db.prepare(` INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, konular, aktif, olusturma, criteria_key) VALUES (?,?,?,?,?,?,?,?,1,?,?) ON CONFLICT(email, criteria_key) DO UPDATE SET aktif=1, kurumlar=excluded.kurumlar, konular=excluded.konular, ad=excluded.ad `).run( email, ad, bosaNull(kriter.sehir), bosaNull(kriter.sektor), bosaNull(kriter.alimTuru), bosaNull(kriter.anahtar), JSON.stringify((kriter.kurumlar || []).map(value=>value.trim())), JSON.stringify((kriter.konular || []).map(value=>value.trim())), new Date().toISOString(), subscriptionKey(kriter) ); return list(email); } finally { db.close(); } } export function list(email) { const db = open({ create: false }); if (!db) return []; try { const rows = email ? db.prepare('SELECT * FROM abonelikler WHERE email=? ORDER BY id DESC').all(email) : db.prepare('SELECT * FROM abonelikler WHERE aktif=1 ORDER BY id').all(); return rows.map((r) => ({ id: r.id, email: r.email, ad: r.ad, sehir: r.sehir || null, sektor: r.sektor || null, alimTuru: r.alim_turu || null, anahtar: r.anahtar || null, kurumlar: (() => { try { return JSON.parse(r.kurumlar || '[]'); } catch { return []; } })(), konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(), aktif: Boolean(r.aktif), sonGonderim: r.sonGonderim, initialized: r.sonGorulenler !== null, deliveryPending:!!db.prepare("SELECT id FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(r.id), deliveryStatus:db.prepare("SELECT state,error FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(r.id) || null, sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(), })); } finally { db.close(); } } export function remove(id, email) { const db = open({ create: false }); if (!db) return false; try { db.exec('BEGIN IMMEDIATE'); try { const r=db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id,email); if(r.changes)db.prepare("UPDATE notification_outbox SET state='cancelled' WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").run(id); db.exec('COMMIT');return r.changes>0; }catch(error){db.exec('ROLLBACK');throw error;} } finally { db.close(); } } function recordSeen(id,ihaleIdleri,sent) { const db=open({create:false}); if(!db)throw new Error('Bildirim deposu kullanılamıyor'); try { db.exec('BEGIN IMMEDIATE'); const row=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(id); if(!row)throw new Error('Abonelik artık yok.'); const seen=[...new Set([...parseArrayJson(row.sonGorulenler, 'Görülen ihale'),...ihaleIdleri])]; db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=CASE WHEN ? THEN ? ELSE sonGonderim END WHERE id=?') .run(JSON.stringify(seen),sent?1:0,new Date().toISOString(),id); db.exec('COMMIT'); }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;} finally{db.close();} } export const markSent=(id,ids)=>recordSeen(id,ids,true); export const markBaseline=(id,ids)=>recordSeen(id,ids,false); export function reserveDelivery(subscriptionId,payload,tenderIds,{clock=Date.now(),force=false}={}) { const db=open(); try { db.exec('BEGIN IMMEDIATE'); const subscription=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=? AND aktif=1').get(subscriptionId); if(!subscription){db.exec('COMMIT');return null;} let row=db.prepare("SELECT * FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(subscriptionId); if(!row){ const seen=new Set(parseArrayJson(subscription.sonGorulenler, 'Görülen ihale')); if(!payload||!tenderIds.length||(!force&&tenderIds.some(id=>seen.has(id)))){db.exec('COMMIT');return null;} const id=randomUUID(); db.prepare("INSERT INTO notification_outbox(id,subscription_id,payload,tender_ids,state) VALUES (?,?,?,?,'pending')").run(id,subscriptionId,JSON.stringify(payload),JSON.stringify(tenderIds)); row=db.prepare('SELECT * FROM notification_outbox WHERE id=?').get(id); } if(row.state==='uncertain'){db.exec('COMMIT');return {blocked:true};} if(row.first_attempt!==null&&clock-row.first_attempt>=23*3600000){ db.prepare("UPDATE notification_outbox SET state='uncertain',error=? WHERE id=?").run('Gönderim sonucu belirsiz; sağlayıcı kaydı kontrol edilmeden tekrar gönderilmez.',row.id); db.exec('COMMIT');return {blocked:true}; } if(row.state==='sending'&&row.lease_until>clock){db.exec('COMMIT');return null;} const lease=randomUUID(); db.prepare("UPDATE notification_outbox SET state='sending',first_attempt=COALESCE(first_attempt,?),lease=?,lease_until=? WHERE id=?").run(clock,lease,clock+120000,row.id); db.exec('COMMIT');return {id:row.id,lease,payload:JSON.parse(row.payload),tenderIds:JSON.parse(row.tender_ids)}; }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();} } export function acceptDelivery(delivery,providerId) { if(typeof providerId!=='string'||!providerId)throw new Error('Sağlayıcı gönderim kimliği gerekli.'); const db=open(); try { db.exec('BEGIN IMMEDIATE'); const row=db.prepare("SELECT * FROM notification_outbox WHERE id=? AND lease=? AND state='sending'").get(delivery.id,delivery.lease); if(!row)throw new Error('Gönderim kaydı artık etkin değil.'); const subscription=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(row.subscription_id); if(!subscription)throw new Error('Abonelik artık yok.'); const seen=[...new Set([...parseArrayJson(subscription.sonGorulenler, 'Görülen ihale'),...parseArrayJson(row.tender_ids, 'İhale')])]; db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=? WHERE id=?').run(JSON.stringify(seen),new Date().toISOString(),row.subscription_id); db.prepare("UPDATE notification_outbox SET state='sent',provider_id=?,error=NULL WHERE id=?").run(providerId,row.id); db.exec('COMMIT'); }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();} } export function failDelivery(delivery,error) { const db=open(); try{return db.prepare("UPDATE notification_outbox SET state='pending',error=? WHERE id=? AND lease=? AND state='sending'").run(String(error?.message || error),delivery.id,delivery.lease).changes>0;} finally{db.close();} } /** Stop replay without altering the pinned request or acknowledging delivery. */ export function holdDelivery(delivery,reason) { const db=open(); try{return db.prepare("UPDATE notification_outbox SET state='uncertain',error=? WHERE id=? AND lease=? AND state='sending'") .run(String(reason),delivery.id,delivery.lease).changes>0;} finally{db.close();} } export function deliveryQueue() { const db=open(); try{return db.prepare("SELECT o.*,r.actor,r.action,r.note,r.reviewed_at FROM notification_outbox o LEFT JOIN notification_outbox_reviews r ON r.id=o.id WHERE o.state IN ('pending','sending','uncertain','sent_manual','skipped') ORDER BY o.first_attempt DESC LIMIT 50").all().map(row=>{ const payload=JSON.parse(row.payload); return {id:row.id,subscriptionId:row.subscription_id,to:payload.to,subject:payload.subject,state:row.state,error:row.error, firstAttempt:row.first_attempt,providerId:row.provider_id,review:row.action?{actor:row.actor,action:row.action,note:row.note,at:row.reviewed_at}:null}; });}finally{db.close();} } export function resolveDelivery(id,actor,input) { if(typeof id!=='string'||!id||!actor||!['confirmed_sent','skip'].includes(input?.action)||typeof input.note!=='string'||input.note.trim().length<20||input.note.length>2000)throw new Error('Karar ve en az 20 karakterlik kontrol açıklaması gerekli.'); if(input.action==='confirmed_sent'&&(input.confirmed!==true||typeof input.providerId!=='string'||!input.providerId.trim()||input.providerId.length>200))throw new Error('Sağlayıcı gönderim kimliği ve kontrol onayı gerekli.'); const db=open(); try { db.exec('BEGIN IMMEDIATE'); const row=db.prepare("SELECT * FROM notification_outbox WHERE id=? AND state='uncertain'").get(id); if(!row)throw Object.assign(new Error('Kayıt artık inceleme beklemiyor. Listeyi yenileyin.'),{status:409}); const subscription=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(row.subscription_id); if(!subscription)throw new Error('Abonelik artık yok.'); const seen=[...new Set([...parseArrayJson(subscription.sonGorulenler, 'Görülen ihale'),...parseArrayJson(row.tender_ids, 'İhale')])]; const now=new Date().toISOString(),sent=input.action==='confirmed_sent'; db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=CASE WHEN ? THEN ? ELSE sonGonderim END WHERE id=?').run(JSON.stringify(seen),sent?1:0,now,row.subscription_id); db.prepare('INSERT INTO notification_outbox_reviews VALUES (?,?,?,?,?,?)').run(id,actor,input.action,input.note.trim(),sent?input.providerId.trim():null,now); db.prepare('UPDATE notification_outbox SET state=?,provider_id=?,error=NULL WHERE id=?').run(sent?'sent_manual':'skipped',sent?input.providerId.trim():null,id); db.exec('COMMIT');return {id,state:sent?'sent_manual':'skipped'}; }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();} }