185 lines
7.7 KiB
JavaScript
185 lines
7.7 KiB
JavaScript
import {createHash} 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');
|
||
|
||
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);
|
||
`;
|
||
|
||
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,
|
||
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 {
|
||
const r = db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id, email);
|
||
return r.changes > 0;
|
||
} 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([...JSON.parse(row.sonGorulenler || '[]'),...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);
|