284 lines
15 KiB
JavaScript
284 lines
15 KiB
JavaScript
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();}
|
||
}
|