From 4c4ba0c4c82f657d27fae624bad2bedebf807a7d Mon Sep 17 00:00:00 2001
From: ht
Date: Sun, 6 Sep 2026 13:00:12 +0300
Subject: [PATCH] Keep unsent notification batches pending and retain seen
history
---
docs/quality/uygulama-durumu.md | 8 +++++++
server/notifier.mjs | 40 +++++++++++++++++++------------
server/notify-store.mjs | 23 +++++++++++-------
tests/notifier-delivery.test.mjs | 26 ++++++++++++++++++++
tests/subscription-store.test.mjs | 8 +++++++
5 files changed, 82 insertions(+), 23 deletions(-)
create mode 100644 tests/notifier-delivery.test.mjs
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'));
+});