Keep unsent notification batches pending and retain seen history
This commit is contained in:
@@ -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.
|
||||
|
||||
+25
-15
@@ -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.
|
||||
</p>`;
|
||||
|
||||
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, `<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}`); }
|
||||
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;}
|
||||
}
|
||||
|
||||
+14
-7
@@ -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) {
|
||||
function recordSeen(id,ihaleIdleri,sent) {
|
||||
const db=open({create:false});
|
||||
if (!db) return;
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user