Files
ihaleist/server/notifier.mjs
T

172 lines
8.5 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, holdDelivery, available } from './notify-store.mjs';
import { emailConfigured, emailFrom, sendEmail, tenderDigestHtml, escapeHtml } 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');
/** Reject an unreadable feed before baseline or outbox mutations. */
export function notificationSource(payload) {
const invalid = () => { throw new Error('İhale bildirim kaynağı geçersiz; abonelik geçmişi değiştirilmedi.'); };
if (!payload || typeof payload !== 'object' || payload.success === false || !Array.isArray(payload.tenders)) invalid();
const ids = new Set();
for (const tender of payload.tenders) {
if (!tender || typeof tender !== 'object' || Array.isArray(tender)
|| typeof tender.id !== 'string' || !tender.id.trim() || ids.has(tender.id)
|| typeof tender.baslik !== 'string' || !tender.baslik.trim()) invalid();
ids.add(tender.id);
if (tender.items != null && (!Array.isArray(tender.items) || tender.items.some(item =>
!item || typeof item !== 'object' || Array.isArray(item) || (item.ad != null && typeof item.ad !== 'string')))) invalid();
}
return payload.tenders;
}
/** 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;
}
// A retry must preserve its original provider request; stale content needs review,
// not a rewritten payload under the old idempotency key.
export function deliverySourceProblem(delivery,current,clock) {
const pinned = delivery.payload.digest;
if (!Array.isArray(pinned) || pinned.length !== delivery.tenderIds.length)
return 'Gönderim kaynağı doğrulanamadı; otomatik tekrar durduruldu. Yönetici incelemesi gerekli.';
const byId = new Map(current.map(t=>[t.id,t]));
for (let i=0;i<pinned.length;i++) {
const old = pinned[i], fresh = old && byId.get(old.id);
if (!old || old.id !== delivery.tenderIds[i] || !fresh || !bildirilebilir(fresh,clock)
|| !bildirilebilir(old,clock)
|| deadlineInfo(old,clock).deadline?.getTime() !== deadlineInfo(fresh,clock).deadline?.getTime())
return 'İhale kaynağı, son teklif zamanı veya başvuru durumu değişti/doğrulanamıyor. Eski e-posta yeniden gönderilmedi; sağlayıcı kaydı ve güncel ilan için yönetici incelemesi gerekli.';
}
return null;
}
/**
* 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,hold=holdDelivery,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 source = notificationSource(veri);
const tumu = source.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: ${escapeHtml(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;}
const sourceProblem=deliverySourceProblem(delivery,source,now());
if(sourceProblem){hold(delivery,sourceProblem);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;}
}