Keep unsent notification batches pending and retain seen history

This commit is contained in:
2026-09-06 13:00:12 +03:00
parent d2d66c8e9d
commit 4c4ba0c4c8
5 changed files with 82 additions and 23 deletions
+8
View File
@@ -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ı. - `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ı. - 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. - 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.
+25 -15
View File
@@ -1,3 +1,4 @@
import {deadlineInfo} from '../src/services/tenderCalendar.js';
/** /**
* Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur * Otomatik ihale bildirimi — abonelik kriterlerine uyan YENİ ihaleleri bulur
* ve özet e-posta gönderir. * ve özet e-posta gönderir.
@@ -8,7 +9,7 @@
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale * 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 { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs'; import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
import { getData } from './user-store.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. */ /** Sonuçlanmış/arşiv kayıtları bildirime girmez — yalnızca teklif verilebilir ilanlar. */
function bildirilebilir(t) { export function bildirilebilir(t,now=new Date()) {
if (t.durum === 'Sonuçlandı') return false; 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; if (String(t.id || '').startsWith('hist-')) return false;
return true; return true;
} }
@@ -48,17 +50,24 @@ function bildirilebilir(t) {
* Tüm aktif abonelikleri işler. * Tüm aktif abonelikleri işler.
* @param {Function} tenderGetir () => Promise<{tenders: []}> * @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 (process.env.NOTIFY_ENABLED === '0') return { atlandi: 'kapalı' };
if (!available()) return { atlandi: 'depo yok' }; 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 }; if (!abonelikler.length) return { abonelik: 0 };
const veri = await tenderGetir(); const veri = await tenderGetir();
const tumu = (veri.tenders || []).filter(bildirilebilir); const tumu = (veri.tenders || []).filter(t=>bildirilebilir(t,now()));
const maxAdet = Number(process.env.NOTIFY_MAX || 25); const requested=Number(process.env.NOTIFY_MAX || 25);
const maxAdet=Number.isInteger(requested)&&requested>0?Math.min(requested,200):25;
let gonderilen = 0; let gonderilen = 0;
let hata = 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. // İ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. // zorla=true ise (yönetici "şimdi gönder" dediğinde) bu adım atlanır.
if (!a.sonGonderim && !zorla) { if (!a.initialized && !zorla) {
markSent(a.id, eslesen.map((t) => t.id)); saveBaseline(a.id, eslesen.map((t) => t.id));
continue; 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; if (!gonderilecek.length) continue;
const kriterMetni = [ 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. Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
</p>`; </p>`;
await sendEmail({ await deliverEmail({
to: a.email, to: a.email,
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`, subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
html, html,
}); });
// Telegram bağlıysa aynı özet oraya da (kısa biçim) // Telegram bağlıysa aynı özet oraya da (kısa biçim)
try { try {
const tg = telegramConfigured() ? getData(a.email, 'telegram') : null; const tg = telegramReady() ? getData(a.email, 'telegram') : null;
if (tg?.chatId) { if (tg?.chatId) {
const satirlar = gonderilecek.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`); 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, `<b>İhaleist — ${gonderilecek.length} yeni ihale</b> (${tgKacir(kriterMetni)})\n\n${satirlar.join('\n')}${gonderilecek.length > 10 ? `\n… +${gonderilecek.length - 10} ihale` : ''}`); await deliverTelegram(tg.chatId, `<b>İhaleist — ${gonderilecek.length} yeni ihale</b> (${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}`); } } 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++; gonderilen++;
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`); console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
} catch (e) { } catch (e) {
@@ -115,4 +124,5 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) {
} }
} }
return { abonelik: abonelikler.length, gonderilen, hata }; return { abonelik: abonelikler.length, gonderilen, hata };
}finally {processing=false;}
} }
+14 -7
View File
@@ -147,6 +147,7 @@ export function list(email) {
konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(), konular: (() => { try { return JSON.parse(r.konular || '[]'); } catch { return []; } })(),
aktif: Boolean(r.aktif), aktif: Boolean(r.aktif),
sonGonderim: r.sonGonderim, sonGonderim: r.sonGonderim,
initialized: r.sonGorulenler !== null,
sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(), sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(),
})); }));
} finally { } finally {
@@ -165,13 +166,19 @@ export function remove(id, email) {
} }
} }
export function markSent(id, ihaleIdleri) { function recordSeen(id,ihaleIdleri,sent) {
const db=open({create:false}); const db=open({create:false});
if (!db) return; if(!db)throw new Error('Bildirim deposu kullanılamıyor');
try { try {
db.prepare('UPDATE abonelikler SET sonGonderim=?, sonGorulenler=? WHERE id=?') db.exec('BEGIN IMMEDIATE');
.run(new Date().toISOString(), JSON.stringify(ihaleIdleri.slice(0, 400)), id); const row=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(id);
} finally { if(!row)throw new Error('Abonelik artık yok.');
db.close(); 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);
+26
View File
@@ -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);
});
+8
View File
@@ -26,3 +26,11 @@ test('distinct topic and institution sets coexist while equivalent subscriptions
subscriptions.subscribe('sets',{konular:['Laboratuvar','TEST'],kurumlar:['Hastane B']}); 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'); 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'));
});