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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user