Files
ihaleist/server/notify-store.mjs
T

178 lines
7.2 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} 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,
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();
}
}
export function markSent(id, ihaleIdleri) {
const db = open({ create: false });
if (!db) return;
try {
db.prepare('UPDATE abonelikler SET sonGonderim=?, sonGorulenler=? WHERE id=?')
.run(new Date().toISOString(), JSON.stringify(ihaleIdleri.slice(0, 400)), id);
} finally {
db.close();
}
}