Files
ihaleist/server/notifier.mjs
T

136 lines
6.4 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 {deadlineInfo} from '../src/services/tenderCalendar.js';
/**
* 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, markBaseline, reserveDelivery, acceptDelivery, failDelivery, available } from './notify-store.mjs';
import { emailConfigured, emailFrom, sendEmail, tenderDigestHtml } from './email.mjs';
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
import { getData } from './user-store.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;
}
// Konu başlıkları: herhangi biri ihale metninde geçiyorsa eşleşir
if (a.konular?.length) {
const alan = norm(`${t.baslik || ''} ${t.sektor || ''} ${t.brans || ''} ${(t.items || []).map((i) => i.ad).join(' ')}`);
if (!a.konular.some((k) => alan.includes(norm(k)))) 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. */
export function bildirilebilir(t,now=new Date()) {
if (['SONUÇLANDI','İPTAL','İPTAL EDİLDİ','İPTAL EDİLMİŞ'].includes(norm(t.durum))) return false;
if(deadlineInfo(t,now).expired!==false)return false;
if (String(t.id || '').startsWith('hist-')) return false;
return true;
}
/**
* Tüm aktif abonelikleri işler.
* @param {Function} tenderGetir () => Promise<{tenders: []}>
*/
let processing=false;
export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, dependencies={}) {
const {readSubscriptions=list,saveBaseline=markBaseline,reserve=reserveDelivery,accepted=acceptDelivery,failed=failDelivery,configured=emailConfigured,
deliverEmail=sendEmail,telegramReady=telegramConfigured,deliverTelegram=sendTelegram,now=()=>new Date()}=dependencies;
if(processing)return {atlandi:'önceki tur sürüyor'};
processing=true;
try {
if (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' };
if (!available()) return { atlandi: 'depo yok' };
if (!configured()) return { atlandi: 'e-posta yapılandırılmamış' };
const abonelikler = readSubscriptions();
if (!abonelikler.length) return { abonelik: 0 };
const veri = await tenderGetir();
const tumu = (veri.tenders || []).filter(t=>bildirilebilir(t,now()));
const requested=Number(process.env.NOTIFY_MAX || 25);
const maxAdet=Number.isInteger(requested)&&requested>0?Math.min(requested,200):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.
// zorla=true ise (yönetici "şimdi gönder" dediğinde) bu adım atlanır.
if (!a.initialized && !a.deliveryPending && !zorla) {
saveBaseline(a.id, eslesen.map((t) => t.id));
continue;
}
const gonderilecek = (zorla && !yeniler.length ? eslesen : yeniler).slice(0,maxAdet);
if (!gonderilecek.length && !a.deliveryPending) 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.konular?.length && `Konular: ${a.konular.slice(0, 4).join(', ')}${a.konular.length > 4 ? '…' : ''}`,
a.kurumlar?.length && `${a.kurumlar.length} favori kurum`,
].filter(Boolean).join(' · ') || 'Tüm ihaleler';
const html = tenderDigestHtml({ name: a.ad || a.email, tenders: gonderilecek.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 ${gonderilecek.length} ihale.
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
</p>`;
const delivery=reserve(a.id,{
from:emailFrom(),to: a.email,
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
html,digest:gonderilecek,criteria:kriterMetni,
},gonderilecek.map(t=>t.id),{force:zorla});
if(!delivery)continue;
if(delivery.blocked){hata++;continue;}
try {
const result=await deliverEmail({...delivery.payload,idempotencyKey:'notify/'+delivery.id});
accepted(delivery,result?.id);
}catch(error){failed(delivery,error);throw error;}
// Telegram bağlıysa aynı özet oraya da (kısa biçim)
try {
const tg = telegramReady() ? getData(a.email, 'telegram') : null;
if (tg?.chatId) {
const deliveredTenders=delivery.payload.digest || [];
const deliveredCriteria=delivery.payload.criteria || '';
const satirlar = deliveredTenders.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`);
await deliverTelegram(tg.chatId, `<b>İhaleist — ${deliveredTenders.length} yeni ihale</b> (${tgKacir(deliveredCriteria)})\n\n${satirlar.join('\n')}${deliveredTenders.length > 10 ? `\n… +${deliveredTenders.length - 10} ihale` : ''}`);
}
} catch (e) { console.warn(`[notifier] telegram ${a.email}: ${e.message}`); }
gonderilen++;
console.log(`[notifier] ${a.email} → ${delivery.tenderIds.length} ihale gönderildi (${kriterMetni})`);
} catch (e) {
hata++;
console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`);
}
}
return { abonelik: abonelikler.length, gonderilen, hata };
}finally {processing=false;}
}