feat: otomatik e-posta bildirimi

Kullanıcı kriter kaydediyor; sunucu periyodik olarak eşleşen YENİ ihaleleri
bulup özet e-posta gönderiyor.

- server/notify-store.mjs: abonelik deposu (SQLite, kalıcı volume'de)
  kriterler: il · sektör · alım türü · anahtar kelime · favori kurumlar
- server/notifier.mjs: eşleştirme + gönderim motoru
  · aynı ihale iki kez bildirilmez (sonGorulenler takibi)
  · ilk abonelikte geçmiş bildirilmez, yalnızca temel alınır (spam önleme)
  · sonuçlanmış/arşiv kayıtları bildirime girmez
- /api/notify/subscriptions (GET/POST/DELETE) + /api/notify/run (admin, anlık test)
- scheduler: NOTIFY_INTERVAL_SAAT (varsayılan 6) ile periyodik tur
- NotifySubscriptions paneli: kriter ekle/sil, 'ekrandaki filtreleri kullan'
- tendersPayload ortak yardımcısına çıkarıldı (API ve bildirim aynı veriyi kullanır)
This commit is contained in:
2026-08-29 03:19:47 +03:00
parent 667b335ea7
commit f86339417a
6 changed files with 525 additions and 33 deletions
+67 -32
View File
@@ -25,6 +25,8 @@ import { readHistory, historyStats } from './history-store.mjs';
import { startScheduler, schedulerStatus } from './scheduler.mjs';
import { searchKik, kararMetni } from './kik.mjs';
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -395,6 +397,33 @@ function readBody(req, limit = 100 * 1024) {
});
}
// /api/tenders gövdesini üretir; bildirim işi de aynı veriyi kullanır.
async function tendersPayload(force = false) {
const c = await getTenders(force);
let dt = { tenders: [], totalCount: 0, available: false };
try { dt = readDtTenders(DT_LIMIT); }
catch (e) { console.warn('[ihaleist-api] DT okuma hatası:', e.message); }
let hist = { tenders: [], totalCount: 0, available: false };
try { hist = readHistory(HISTORY_LIMIT); }
catch (e) { console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message); }
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
return {
success: true,
source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
ilanCount: c.totalCount,
todayCount: c.todayCount,
dtCount: dt.totalCount,
historyCount: hist.totalCount,
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
};
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
try {
@@ -561,6 +590,40 @@ const server = http.createServer(async (req, res) => {
}
}
// --- Otomatik bildirim abonelikleri ---
if (url.pathname === '/api/notify/subscriptions') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
if (!notifyAvailable()) return json(res, 503, { success: false, error: 'Bildirim deposu kullanılamıyor' });
if (req.method === 'POST') {
try {
const body = await readBody(req);
const abone = subscribe(user.email, body.kriter || {}, user.name);
return json(res, 200, { success: true, abonelikler: abone });
} catch (e) {
return json(res, 400, { success: false, error: e.message });
}
}
if (req.method === 'DELETE') {
const id = Number(url.searchParams.get('id'));
const ok = removeSub(id, user.email);
return json(res, ok ? 200 : 404, { success: ok, abonelikler: listSubs(user.email) });
}
return json(res, 200, { success: true, abonelikler: listSubs(user.email) });
}
// Aboneliklerin hemen işlenmesi (admin/test)
if (url.pathname === '/api/notify/run' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici yetkisi gerekli' });
try {
const sonuc = await bildirimleriIsle(() => tendersPayload());
return json(res, 200, { success: true, ...sonuc });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
}
}
// --- E-posta bildirimi (Resend) ---
if (url.pathname === '/api/notify/email/config') {
return json(res, 200, { configured: emailConfigured() });
@@ -585,37 +648,8 @@ const server = http.createServer(async (req, res) => {
}
if (url.pathname === '/api/tenders') {
const force = url.searchParams.get('refresh') === '1';
const c = await getTenders(force);
// Doğrudan temin (EKAP worker) verisini varsa birleştir
let dt = { tenders: [], totalCount: 0, available: false };
try {
dt = readDtTenders(DT_LIMIT);
} catch (e) {
console.warn('[ihaleist-api] DT okuma hatası:', e.message);
}
// Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı)
let hist = { tenders: [], totalCount: 0, available: false };
try {
hist = readHistory(HISTORY_LIMIT);
} catch (e) {
console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message);
}
const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları'];
if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker');
if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)');
return json(res, 200, {
success: true,
source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
// Havuz = canlı ilan + doğrudan temin + sonuç arşivi (yüklü sayısıyla tutarlı olsun)
totalCount: c.totalCount + dt.totalCount + hist.totalCount,
ilanCount: c.totalCount,
todayCount: c.todayCount,
dtCount: dt.totalCount,
historyCount: hist.totalCount,
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders],
});
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
return json(res, 200, out);
}
if (url.pathname === '/api/dt') {
@@ -666,5 +700,6 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, () => {
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
startScheduler();
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
startScheduler({ tenderGetir: () => tendersPayload() });
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur
* ve özet e-posta gönderir.
*
* Env:
* NOTIFY_ENABLED=0 → kapat
* NOTIFY_INTERVAL_SAAT=6 → kontrol sıklığı
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale
*/
import { list, markSent, available } from './notify-store.mjs';
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
/** Bir ihale, aboneliğin kriterlerine uyuyor mu? */
export function eslesiyorMu(t, a) {
if (a.sehir && norm(t.sehir) !== norm(a.sehir)) return false;
if (a.sektor && norm(t.sektor) !== norm(a.sektor)) return false;
if (a.alimTuru && !norm(t.alimTuru).includes(norm(a.alimTuru))) return false;
if (a.anahtar) {
const q = norm(a.anahtar);
const alan = norm(`${t.baslik || ''} ${t.kurum || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
if (!alan.includes(q)) return false;
}
if (a.kurumlar?.length) {
const kurum = norm(t.kurum);
if (!a.kurumlar.some((k) => norm(k) === kurum)) return false;
}
return true;
}
/** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */
function bildirilebilir(t) {
if (t.durum === 'Sonuçlandı') return false;
if (String(t.id || '').startsWith('hist-')) return false;
return true;
}
/**
* Tüm aktif abonelikleri işler.
* @param {Function} tenderGetir () => Promise<{tenders: []}>
*/
export async function bildirimleriIsle(tenderGetir) {
if (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' };
if (!available()) return { atlandi: 'depo yok' };
if (!emailConfigured()) return { atlandi: 'e-posta yapılandırılmamış' };
const abonelikler = list();
if (!abonelikler.length) return { abonelik: 0 };
const veri = await tenderGetir();
const tumu = (veri.tenders || []).filter(bildirilebilir);
const maxAdet = Number(process.env.NOTIFY_MAX || 25);
let gonderilen = 0;
let hata = 0;
for (const a of abonelikler) {
try {
const gorulen = new Set(a.sonGorulenler || []);
const eslesen = tumu.filter((t) => eslesiyorMu(t, a));
const yeniler = eslesen.filter((t) => !gorulen.has(t.id));
// İlk kurulumda geçmişi spam'lemeyelim: sadece işaretle, gönderme.
if (!a.sonGonderim) {
markSent(a.id, eslesen.map((t) => t.id));
continue;
}
if (!yeniler.length) continue;
const kriterMetni = [
a.sehir && `İl: ${a.sehir}`,
a.sektor && `Sektör: ${a.sektor}`,
a.alimTuru && `Tür: ${a.alimTuru}`,
a.anahtar && `Anahtar: ${a.anahtar}`,
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: yeniler.slice(0, maxAdet) })
+ `<p style="font-family:Arial,sans-serif;font-size:11px;color:#8b93a4;max-width:640px;margin:12px auto 0">
Kriteriniz: ${kriterMetni} — toplam ${yeniler.length} yeni ihale.
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
</p>`;
await sendEmail({
to: a.email,
subject: `İhaleist — ${yeniler.length} yeni ihale (${kriterMetni})`,
html,
});
markSent(a.id, eslesen.map((t) => t.id));
gonderilen++;
console.log(`[notifier] ${a.email} → ${yeniler.length} yeni ihale gönderildi (${kriterMetni})`);
} catch (e) {
hata++;
console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`);
}
}
return { abonelik: abonelikler.length, gonderilen, hata };
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 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 = 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
aktif INTEGER NOT NULL DEFAULT 1,
sonGonderim TEXT,
sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri
olusturma TEXT,
UNIQUE(email, sehir, sektor, alim_turu, anahtar)
);
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 });
}
const db = new DatabaseSync(DB_PATH);
db.exec(SCHEMA);
return db;
}
const bosaNull = (v) => (v == null || v === '' ? '' : String(v).trim());
export function subscribe(email, kriter = {}, ad = null) {
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, aktif, olusturma)
VALUES (?,?,?,?,?,?,?,1,?)
ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET
aktif=1, kurumlar=excluded.kurumlar, ad=excluded.ad
`).run(
email,
ad,
bosaNull(kriter.sehir),
bosaNull(kriter.sektor),
bosaNull(kriter.alimTuru),
bosaNull(kriter.anahtar),
JSON.stringify(kriter.kurumlar || []),
new Date().toISOString()
);
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 []; } })(),
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();
}
}
+29 -1
View File
@@ -14,6 +14,7 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { historyStats } from './history-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -137,7 +138,24 @@ export function schedulerStatus() {
};
}
export function startScheduler() {
// --- Otomatik e-posta bildirimi ---
let bildirimCalisiyor = false;
async function bildirimTuru(tenderGetir) {
if (bildirimCalisiyor) return;
bildirimCalisiyor = true;
try {
const s = await bildirimleriIsle(tenderGetir);
if (s.atlandi) console.log(`[scheduler] bildirim atlandı: ${s.atlandi}`);
else console.log(`[scheduler] bildirim turu: ${s.abonelik} abonelik, ${s.gonderilen || 0} e-posta, ${s.hata || 0} hata`);
} catch (e) {
console.warn('[scheduler] bildirim hatası:', e.message);
} finally {
bildirimCalisiyor = false;
}
}
export function startScheduler({ tenderGetir } = {}) {
if (process.env.SCAN_ENABLED === '0') {
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
return;
@@ -158,4 +176,14 @@ export function startScheduler() {
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
}
// Otomatik e-posta bildirimi
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
} else {
const nSaat = Number(process.env.NOTIFY_INTERVAL_SAAT || 6);
console.log(`[scheduler] otomatik e-posta bildirimi her ${nSaat} saatte bir kontrol edilecek`);
setTimeout(() => bildirimTuru(tenderGetir), 6 * 60 * 1000).unref?.();
setInterval(() => bildirimTuru(tenderGetir), Math.max(1, nSaat) * 3600 * 1000).unref?.();
}
}