Hold stale tender notification retries for review

This commit is contained in:
2026-09-06 13:19:55 +03:00
parent 02625906f2
commit 1fbf77c0ac
6 changed files with 73 additions and 4 deletions
+7
View File
@@ -860,3 +860,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- E-posta kriter açıklaması ortak escapeHtml ile kaçırılır; kullanıcı kriterindeki HTML bağlantısı/etiketi e-posta gövdesine işaretleme olarak giremez. Konu satırı düz metnini korur.
- 195 otomatik test geçti. Yalnız sunucu ve test kodu değiştiğinden frontend derlemesi tekrarlanmadı. Testler dış e-posta göndermez.
- Bu kontrol beslemenin yapısını doğrular; şekli geçerli fakat eksik/eski kaynakların tamlık ve güncelliğini kanıtlamaz. Bekleyen gönderimin ihalesi sonradan iptal olduğunda yeniden değerlendirme, gerçek sağlayıcı teslimi ve diğer plan kabulleri açık.
### Bekleyen e-postada ihale güncellik kontrolü — 6 Eylül 2026
- Kuyruktan alınan sabit e-posta, sağlayıcı çağrısı öncesinde o turun doğrulanmış kaynak listesiyle karşılaştırılır. İlan kayıp, iptal/sonuçlanmış, süresi geçmiş, zamanı belirsiz veya son teklif zamanı değişmişse eski içerik yeniden gönderilmez. Sabit digest eksik/kimlikleri uyumsuzsa da durur.
- holdDelivery mevcut lease sahibinin sending kaydını uncertain inceleme durumuna alır; payload/UUID/görülmüş kayıtlar değişmez. Eski worker hold/başarı/hata ile kaydı değiştiremez. Sonraki reserve turu inceleme gerekçesini artık genel bir hatayla ezmez.
- İnceleme mevcut yönetici outbox akışını kullanır: sağlayıcı kaydıyla gönderildiğini doğrulama veya yeniden göndermeden kapatma. Tarihi değişen grubun içeriği aynı idempotency key altında yeniden yazılmaz.
- 197 otomatik test geçti. Eksik ilan, iptal, süre aşımı, eksik saat, ileri alınmış tarih; provider/ack çağrısının yapılmaması; hold lease sınırı; sonraki turda gerekçenin korunması doğrulandı. Önceki gerçek süreç kesintisi ve aynı anahtarla tekrar testleri de geçti. Dış sağlayıcıya gönderim yapılmadı.
- Kontrol o turda alınan kaynak görünümüne dayanır; canlı kaynağın sonradan değişmesini veya geçerli şekle sahip eski beslemeyi tespit garantisi vermez. Başlık değişikliği tek başına tekrar anahtarını bozmaz. UI değişmediğinden frontend derlemesi tekrarlanmadı.
+3
View File
@@ -69,3 +69,6 @@ notify.sqlite abonelik kimliği geçişi: `criteria_key` sütunu eklendi; konu v
Kalıcı e-posta kuyruğu aynı notify.sqlite içinde `notification_outbox` tablosundadır. Tam içerik/gönderen/alıcı ve ihale listesi ile sağlayıcı istek kimliği burada korunur; yedeğe dahil edilmelidir. Bir bekleyen grup abonelik başına tek aktif kayıtla tutulur. 120 saniyelik işçi sahipliği, 30 saniyelik sağlayıcı isteğinden uzundur. İlk denemeden 23 saat sonra belirsiz kayıt otomatik tekrar gönderilmez; Resend'in 24 saatlik tekrar önleme süresine güvenli pay bırakılır. Yönetici sağlayıcı kaydını doğrulamalıdır; Mobil PWA sayfasındaki yöneticiye özel E-posta gönderim incelemesi bölümünden kayıt karara bağlanır. Sağlayıcı kabulü doğrulandıysa gönderim kimliği/onay/açıklama ile kapatılır; aksi halde yeniden göndermeden kapatma seçilebilir. Her iki işlem de yeni e-posta göndermez ve açıklamalı karar geçmişini korur. Bekleyen iş varken sağlayıcı hesabı/anahtarı değiştirilmemelidir: tekrar önleme anahtarı farklı sağlayıcı hesapları arasında garanti vermez. Eski uygulamaya dönmek kuyruğu yok sayabileceğinden kod ve DB geri dönüşü birlikte değerlendirilmelidir.
`node --test tests/outbox-crash.test.mjs`, sağlayıcı kabulünden sonra commit öncesi/sonrası gerçek SIGKILL kurtarmasını izole volume'da sınar. Ayrı süreçte aynı payload/istek kimliği ve SQLite bütünlüğü doğrulanır. Sağlayıcı yerel bir simülatördür; test gerçek Resend hesabının idempotency davranışını veya üretim platformundaki disk kurtarmasını kanıtlamaz.
### Güncellik nedeniyle tutulan e-postalar
Kuyruktaki ilanın artık kaynakta bulunmaması, teklif zamanının değişmesi, iptal/sonuç veya süre aşımı halinde otomatik tekrar durur ve yönetici inceleme listesinde gerekçe görünür. Liste sınırlaması veya geçici kaynak eksikliği de bu durumu oluşturabilir. Önce sağlayıcı kaydını ve resmi ilanı karşılaştırın; mevcut karar akışından gönderildiğini kanıtlayarak veya yeniden göndermeden kapatarak çözün. Payload ya da idempotency anahtarını elle değiştirmeyin. Yeniden göndermeden kapatmak bu gruptaki ihale kimliklerini görülmüş sayar; değişen ilan için otomatik yeni e-posta taahhüt edilmez.
+23 -3
View File
@@ -9,7 +9,7 @@ import {deadlineInfo} from '../src/services/tenderCalendar.js';
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale
*/
import { list, markBaseline, reserveDelivery, acceptDelivery, failDelivery, available } from './notify-store.mjs';
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';
@@ -62,13 +62,30 @@ export function bildirilebilir(t,now=new Date()) {
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,configured=emailConfigured,
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;
@@ -81,7 +98,8 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
if (!abonelikler.length) return { abonelik: 0 };
const veri = await tenderGetir();
const tumu = notificationSource(veri).filter(t=>bildirilebilir(t,now()));
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;
@@ -125,6 +143,8 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
},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);
+10 -1
View File
@@ -206,7 +206,8 @@ export function reserveDelivery(subscriptionId,payload,tenderIds,{clock=Date.now
db.prepare("INSERT INTO notification_outbox(id,subscription_id,payload,tender_ids,state) VALUES (?,?,?,?,'pending')").run(id,subscriptionId,JSON.stringify(payload),JSON.stringify(tenderIds));
row=db.prepare('SELECT * FROM notification_outbox WHERE id=?').get(id);
}
if(row.state==='uncertain'||(row.first_attempt!==null&&clock-row.first_attempt>=23*3600000)){
if(row.state==='uncertain'){db.exec('COMMIT');return {blocked:true};}
if(row.first_attempt!==null&&clock-row.first_attempt>=23*3600000){
db.prepare("UPDATE notification_outbox SET state='uncertain',error=? WHERE id=?").run('Gönderim sonucu belirsiz; sağlayıcı kaydı kontrol edilmeden tekrar gönderilmez.',row.id);
db.exec('COMMIT');return {blocked:true};
}
@@ -237,6 +238,14 @@ export function failDelivery(delivery,error) {
finally{db.close();}
}
/** Stop replay without altering the pinned request or acknowledging delivery. */
export function holdDelivery(delivery,reason) {
const db=open();
try{return db.prepare("UPDATE notification_outbox SET state='uncertain',error=? WHERE id=? AND lease=? AND state='sending'")
.run(String(reason),delivery.id,delivery.lease).changes>0;}
finally{db.close();}
}
export function deliveryQueue() {
const db=open();
try{return db.prepare("SELECT o.*,r.actor,r.action,r.note,r.reviewed_at FROM notification_outbox o LEFT JOIN notification_outbox_reviews r ON r.id=o.id WHERE o.state IN ('pending','sending','uncertain','sent_manual','skipped') ORDER BY o.first_attempt DESC LIMIT 50").all().map(row=>{
+16
View File
@@ -55,3 +55,19 @@ test('subscription criteria remain plain text in outgoing digest HTML',async()=>
assert.ok(!message.html.includes(criteria));
assert.ok(message.subject.includes(criteria),'the non-HTML subject retains the literal criterion');
});
test('pending digest is held before provider replay when its deadline or eligibility changed',async()=>{
const pinned={...future};
for(const current of [[],[{...future,durum:'İptal'}],[{...future,tarih:'01.09.2026'}],
[{...future,saat:''}],[{...future,tarih:'11.09.2026'}]]) {
let held=0;
const delivery={id:'pending',lease:'test',tenderIds:[future.id],payload:{digest:[pinned],html:'original'}};
const deps={now,configured:()=>true,telegramReady:()=>false,
readSubscriptions:()=>[{id:1,email:'qa',initialized:true,deliveryPending:true}],reserve:()=>delivery,
hold:(job,reason)=>{assert.equal(job,delivery);assert.match(reason,/incelemesi/);held++;},
accepted:()=>assert.fail('must not mark as delivered'),deliverEmail:()=>assert.fail('must not replay stale email')};
const result=await bildirimleriIsle(async()=>({tenders:current}),{},deps);
assert.equal(held,1);assert.equal(result.hata,1);assert.equal(result.gonderilen,0);
assert.equal(delivery.payload.html,'original');
}
});
+14
View File
@@ -85,3 +85,17 @@ for(const action of ['confirmed_sent','skip'])test(`manual outbox ${action} is a
assert.equal(subscriptions.reserveDelivery(sub.id,{to:email,html:'A'},['review-tender']),null);
assert.equal(subscriptions.remove(sub.id,email),true);assert.equal(subscriptions.deliveryQueue().find(row=>row.id===delivery.id).review.action,action);
});
test('source review hold preserves payload and seen state, fences stale workers and retains reason',()=>{
const sub=subscriptions.subscribe('source-review',{konular:['Laboratuvar']})[0];
subscriptions.markBaseline(sub.id,[]);
const first=subscriptions.reserveDelivery(sub.id,{html:'immutable'},['pending-item'],{clock:1000});
const reclaimed=subscriptions.reserveDelivery(sub.id,{html:'changed'},['new-item'],{clock:122000});
assert.equal(subscriptions.holdDelivery(first,'stale worker'),false);
assert.equal(subscriptions.holdDelivery(reclaimed,'Kaynak tarihi değişti; inceleme gerekli.'),true);
assert.equal(subscriptions.failDelivery(reclaimed,'late failure'),false);
assert.throws(()=>subscriptions.acceptDelivery(reclaimed,'late-provider'),/etkin değil/);
assert.deepEqual(subscriptions.reserveDelivery(sub.id,{html:'new'},['new-item'],{clock:123000}),{blocked:true});
assert.deepEqual(subscriptions.list('source-review')[0].sonGorulenler,[]);
assert.equal(subscriptions.deliveryQueue().find(row=>row.id===first.id).error,'Kaynak tarihi değişti; inceleme gerekli.');
});