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
+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=>{