/** * 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; } // 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. */ 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, { zorla = false } = {}) { 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. // zorla=true ise (yönetici "şimdi gönder" dediğinde) bu adım atlanır. if (!a.sonGonderim && !zorla) { markSent(a.id, eslesen.map((t) => t.id)); continue; } const gonderilecek = zorla && !yeniler.length ? eslesen.slice(0, maxAdet) : yeniler; if (!gonderilecek.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.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) }) + `

Kriteriniz: ${kriterMetni} — toplam ${gonderilecek.length} ihale. Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.

`; await sendEmail({ to: a.email, subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`, html, }); markSent(a.id, eslesen.map((t) => t.id)); gonderilen++; console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`); } catch (e) { hata++; console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`); } } return { abonelik: abonelikler.length, gonderilen, hata }; }