diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index f9e866c..577ba52 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -799,3 +799,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - `qa-subscription-lifecycle.json`: 6 kontrol doğru. Hata yanıtları yalnız QA middleware'de üretilir; gerçek kayıt/sil/liste endpointleri kullanıldı. E-posta teslimi yapılmadı. - Ekrandaki koşulsuz gönderim ve yinelenmeme vaadi değiştirildi: e-posta hizmetinin etkin olması gerektiği ve abonelik kaydının teslim kanıtı olmadığı açıklandı. Derleme geçti; salt UI metni/test altyapısı değiştiği için önceki 177 test yeniden çalıştırılmadı. - Dış e-posta/Telegram teslimi, provider hata sonrası yeniden deneme ve planın diğer kabul maddeleri açık. + +### E-posta bildirim sınırı ve görülme geçmişi — 6 Eylül 2026 +- Bulgu: e-posta ilk NOTIFY_MAX (varsayılan 25) ihaleyi içerirken bütün eşleşmeler gönderilmiş sayılıyordu; 26. ve sonraki kayıtlar kayboluyordu. Artık yalnız e-postaya alınan grup gönderilmiş işaretlenir; kalanlar sonraki turda alınır. +- Görülme geçmişinin 400 kayıtla kesilmesi kaldırıldı. Başarılı grup mevcut geçmişle transaction içinde birleştirilir; eski ilanlar sırf kesildiği için yeniden yeni sayılmaz. +- İlk tarama markBaseline ile son gönderim zamanını değiştirmeden başlangıç listesini kaydeder. initialized alanı mevcut sonGorulenler kaydından türetilir, yeni şema geçişi yoktur. +- Süresi geçmiş, iptal/sonuç/arşiv ve tarih-saati doğrulanamayan ilanlar yeni ihale e-postasına alınmaz. Aynı süreçte üst üste başlayan turlar için çalışma kilidi eklendi. +- 181 otomatik test geçti. Kontrollü sağlayıcı hatası→hiçbir işaretleme, 28 kayıt→25+3 dağılımı, başlangıçta gönderim olmaması, 450+ kayıt geçmişi ve eşzamanlı tur koruması test edildi. Gerçek e-posta gönderilmedi. +- Sağlayıcının e-postayı kabul edip yerel kayıt öncesi süreç kapanması halinde tekrar gönderim ihtimali sürer; çok süreçli gönderim kilidi ve Telegram için ayrı teslim kuyruğu henüz yok. Bu çalışma dış teslim/eksiksiz outbox kabulü değildir. diff --git a/server/notifier.mjs b/server/notifier.mjs index f2a6d02..fa082c2 100644 --- a/server/notifier.mjs +++ b/server/notifier.mjs @@ -1,3 +1,4 @@ +import {deadlineInfo} from '../src/services/tenderCalendar.js'; /** * Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur * ve özet e-posta gönderir. @@ -8,7 +9,7 @@ * NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale */ -import { list, markSent, available } from './notify-store.mjs'; +import { list, markSent, markBaseline, available } from './notify-store.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs'; import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs'; import { getData } from './user-store.mjs'; @@ -38,8 +39,9 @@ export function eslesiyorMu(t, a) { } /** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */ -function bildirilebilir(t) { - if (t.durum === 'Sonuçlandı') return false; +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; } @@ -48,17 +50,24 @@ function bildirilebilir(t) { * Tüm aktif abonelikleri işler. * @param {Function} tenderGetir () => Promise<{tenders: []}> */ -export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { +let processing=false; +export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, dependencies={}) { + const {readSubscriptions=list,saveSent=markSent,saveBaseline=markBaseline,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 (!emailConfigured()) return { atlandi: 'e-posta yapılandırılmamış' }; + if (!configured()) return { atlandi: 'e-posta yapılandırılmamış' }; - const abonelikler = list(); + const abonelikler = readSubscriptions(); if (!abonelikler.length) return { abonelik: 0 }; const veri = await tenderGetir(); - const tumu = (veri.tenders || []).filter(bildirilebilir); - const maxAdet = Number(process.env.NOTIFY_MAX || 25); + 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; @@ -71,11 +80,11 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { // İ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)); + if (!a.initialized && !zorla) { + saveBaseline(a.id, eslesen.map((t) => t.id)); continue; } - const gonderilecek = zorla && !yeniler.length ? eslesen.slice(0, maxAdet) : yeniler; + const gonderilecek = (zorla && !yeniler.length ? eslesen : yeniler).slice(0,maxAdet); if (!gonderilecek.length) continue; const kriterMetni = [ @@ -93,20 +102,20 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
`; - await sendEmail({ + await deliverEmail({ to: a.email, subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`, html, }); // Telegram bağlıysa aynı özet oraya da (kısa biçim) try { - const tg = telegramConfigured() ? getData(a.email, 'telegram') : null; + const tg = telegramReady() ? getData(a.email, 'telegram') : null; if (tg?.chatId) { const satirlar = gonderilecek.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`); - await sendTelegram(tg.chatId, `İhaleist — ${gonderilecek.length} yeni ihale (${tgKacir(kriterMetni)})\n\n${satirlar.join('\n')}${gonderilecek.length > 10 ? `\n… +${gonderilecek.length - 10} ihale` : ''}`); + await deliverTelegram(tg.chatId, `İhaleist — ${gonderilecek.length} yeni ihale (${tgKacir(kriterMetni)})\n\n${satirlar.join('\n')}${gonderilecek.length > 10 ? `\n… +${gonderilecek.length - 10} ihale` : ''}`); } } catch (e) { console.warn(`[notifier] telegram ${a.email}: ${e.message}`); } - markSent(a.id, eslesen.map((t) => t.id)); + saveSent(a.id, gonderilecek.map((t) => t.id)); gonderilen++; console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`); } catch (e) { @@ -115,4 +124,5 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { } } return { abonelik: abonelikler.length, gonderilen, hata }; + }finally {processing=false;} } diff --git a/server/notify-store.mjs b/server/notify-store.mjs index 2a6032a..f582bb6 100644 --- a/server/notify-store.mjs +++ b/server/notify-store.mjs @@ -147,6 +147,7 @@ export function list(email) { konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(), aktif: Boolean(r.aktif), sonGonderim: r.sonGonderim, + initialized: r.sonGorulenler !== null, sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(), })); } finally { @@ -165,13 +166,19 @@ export function remove(id, email) { } } -export function markSent(id, ihaleIdleri) { - const db = open({ create: false }); - if (!db) return; +function recordSeen(id,ihaleIdleri,sent) { + const db=open({create:false}); + if(!db)throw new Error('Bildirim deposu kullanılamıyor'); try { - db.prepare('UPDATE abonelikler SET sonGonderim=?, sonGorulenler=? WHERE id=?') - .run(new Date().toISOString(), JSON.stringify(ihaleIdleri.slice(0, 400)), id); - } finally { - db.close(); - } + db.exec('BEGIN IMMEDIATE'); + const row=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(id); + if(!row)throw new Error('Abonelik artık yok.'); + const seen=[...new Set([...JSON.parse(row.sonGorulenler || '[]'),...ihaleIdleri])]; + db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=CASE WHEN ? THEN ? ELSE sonGonderim END WHERE id=?') + .run(JSON.stringify(seen),sent?1:0,new Date().toISOString(),id); + db.exec('COMMIT'); + }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;} + finally{db.close();} } +export const markSent=(id,ids)=>recordSeen(id,ids,true); +export const markBaseline=(id,ids)=>recordSeen(id,ids,false); diff --git a/tests/notifier-delivery.test.mjs b/tests/notifier-delivery.test.mjs new file mode 100644 index 0000000..ecce65b --- /dev/null +++ b/tests/notifier-delivery.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {bildirimleriIsle,bildirilebilir} from '../server/notifier.mjs'; +const future={tarih:'10.09.2026',saat:'10:00',durum:'Açık',baslik:'Laboratuvar'}; +const now=()=>new Date('2026-09-06T10:00:00Z'); +test('notification eligibility excludes expired cancelled and unknown deadlines',()=>{ + assert.equal(bildirilebilir(future,now()),true); + for(const patch of [{durum:'İptal'},{durum:'Sonuçlandı'},{id:'hist-1'},{tarih:'01.09.2026'},{saat:''},{tarih:''}])assert.equal(bildirilebilir({...future,...patch},now()),false); +}); +test('delivery failure retains pending items and batches acknowledge only included tenders',async()=>{ + const rows=Array.from({length:28},(_,i)=>({...future,id:'t-'+i,ikn:'2026/'+i})); + const subscription={id:1,email:'qa@example.test',initialized:true,sonGorulenler:[]}; + const sent=[];let fail=true; + const dependencies={now,configured:()=>true,telegramReady:()=>false,readSubscriptions:()=>[subscription],saveSent:(id,ids)=>{sent.push(ids);subscription.sonGorulenler.push(...ids);},deliverEmail:async()=>{if(fail)throw new Error('provider unavailable');}}; + assert.equal((await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies)).hata,1);assert.equal(sent.length,0); + fail=false;assert.equal((await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies)).gonderilen,1);assert.equal(sent[0].length,25); + await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies);assert.equal(sent[1].length,3); + await bildirimleriIsle(async()=>({tenders:rows}),{},dependencies);assert.equal(sent.length,2); +}); +test('baseline is distinct from delivery and overlapping turns do not send twice',async()=>{ + let release;const pending=new Promise(resolve=>release=resolve);let baseline=0; + const dependencies={now,configured:()=>true,telegramReady:()=>false,readSubscriptions:()=>[{id:1,initialized:false}],saveBaseline:()=>baseline++,deliverEmail:()=>assert.fail('baseline must not send')}; + const first=bildirimleriIsle(async()=>{await pending;return {tenders:[future]};},{},dependencies); + assert.equal((await bildirimleriIsle(()=>assert.fail(),{},dependencies)).atlandi,'önceki tur sürüyor'); + release();await first;assert.equal(baseline,1); +}); diff --git a/tests/subscription-store.test.mjs b/tests/subscription-store.test.mjs index 9ef4c43..46de8a6 100644 --- a/tests/subscription-store.test.mjs +++ b/tests/subscription-store.test.mjs @@ -26,3 +26,11 @@ test('distinct topic and institution sets coexist while equivalent subscriptions subscriptions.subscribe('sets',{konular:['Laboratuvar','TEST'],kurumlar:['Hastane B']}); rows=subscriptions.list('sets');assert.equal(rows.length,3);assert.equal(rows.find(row=>row.id===one.id).sonGorulenler[0],'tender-1'); }); + +test('baseline does not claim delivery and seen history is not truncated at 400',()=>{ + const row=subscriptions.subscribe('history',{konular:['Laboratuvar']})[0]; + subscriptions.markBaseline(row.id,Array.from({length:450},(_,i)=>'old-'+i)); + let saved=subscriptions.list('history')[0];assert.equal(saved.initialized,true);assert.equal(saved.sonGonderim,null);assert.equal(saved.sonGorulenler.length,450); + subscriptions.markSent(row.id,['new-1','old-1']);saved=subscriptions.list('history')[0]; + assert.equal(saved.sonGorulenler.length,451);assert.ok(saved.sonGonderim);assert.ok(saved.sonGorulenler.includes('old-0')); +});