172 lines
8.5 KiB
JavaScript
172 lines
8.5 KiB
JavaScript
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;}
|
||
}
|