Files
ihaleist/server/notify-store.mjs
T

295 lines
16 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.
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.`);
}
}
function parseObjectJson(raw, label) {
try {
const value = JSON.parse(raw || '{}');
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('nesne 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: parseArrayJson(r.kurumlar, 'Kurum filtresi'),
konular: parseArrayJson(r.konular, 'Konu filtresi'),
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);
const reservedPayload=parseObjectJson(row.payload, 'Bildirim');
const reservedTenderIds=parseArrayJson(row.tender_ids, 'İhale');
db.exec('COMMIT');return {id:row.id,lease,payload:reservedPayload,tenderIds:reservedTenderIds};
}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=parseObjectJson(row.payload, 'Bildirim');
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();}
}