Persist notification email batches with leased idempotent delivery
This commit is contained in:
+20
-13
@@ -9,8 +9,8 @@ import {deadlineInfo} from '../src/services/tenderCalendar.js';
|
||||
* NOTIFY_MAX=25 → e-postada listelenecek en fazla ihale
|
||||
*/
|
||||
|
||||
import { list, markSent, markBaseline, available } from './notify-store.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
|
||||
import { list, markBaseline, reserveDelivery, acceptDelivery, failDelivery, available } from './notify-store.mjs';
|
||||
import { emailConfigured, emailFrom, sendEmail, tenderDigestHtml } from './email.mjs';
|
||||
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
|
||||
import { getData } from './user-store.mjs';
|
||||
|
||||
@@ -52,7 +52,7 @@ export function bildirilebilir(t,now=new Date()) {
|
||||
*/
|
||||
let processing=false;
|
||||
export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, dependencies={}) {
|
||||
const {readSubscriptions=list,saveSent=markSent,saveBaseline=markBaseline,configured=emailConfigured,
|
||||
const {readSubscriptions=list,saveBaseline=markBaseline,reserve=reserveDelivery,accepted=acceptDelivery,failed=failDelivery,configured=emailConfigured,
|
||||
deliverEmail=sendEmail,telegramReady=telegramConfigured,deliverTelegram=sendTelegram,now=()=>new Date()}=dependencies;
|
||||
if(processing)return {atlandi:'önceki tur sürüyor'};
|
||||
processing=true;
|
||||
@@ -80,12 +80,12 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
|
||||
|
||||
// İ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.initialized && !zorla) {
|
||||
if (!a.initialized && !a.deliveryPending && !zorla) {
|
||||
saveBaseline(a.id, eslesen.map((t) => t.id));
|
||||
continue;
|
||||
}
|
||||
const gonderilecek = (zorla && !yeniler.length ? eslesen : yeniler).slice(0,maxAdet);
|
||||
if (!gonderilecek.length) continue;
|
||||
if (!gonderilecek.length && !a.deliveryPending) continue;
|
||||
|
||||
const kriterMetni = [
|
||||
a.sehir && `İl: ${a.sehir}`,
|
||||
@@ -102,22 +102,29 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}, depe
|
||||
Bu bildirimi uygulamadaki Mobil PWA → E-posta Bildirimi bölümünden kapatabilirsiniz.
|
||||
</p>`;
|
||||
|
||||
await deliverEmail({
|
||||
to: a.email,
|
||||
const delivery=reserve(a.id,{
|
||||
from:emailFrom(),to: a.email,
|
||||
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
|
||||
html,
|
||||
});
|
||||
html,digest:gonderilecek,criteria:kriterMetni,
|
||||
},gonderilecek.map(t=>t.id),{force:zorla});
|
||||
if(!delivery)continue;
|
||||
if(delivery.blocked){hata++;continue;}
|
||||
try {
|
||||
const result=await deliverEmail({...delivery.payload,idempotencyKey:'notify/'+delivery.id});
|
||||
accepted(delivery,result?.id);
|
||||
}catch(error){failed(delivery,error);throw error;}
|
||||
// Telegram bağlıysa aynı özet oraya da (kısa biçim)
|
||||
try {
|
||||
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 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` : ''}`);
|
||||
const deliveredTenders=delivery.payload.digest || [];
|
||||
const deliveredCriteria=delivery.payload.criteria || '';
|
||||
const satirlar = deliveredTenders.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`);
|
||||
await deliverTelegram(tg.chatId, `<b>İhaleist — ${deliveredTenders.length} yeni ihale</b> (${tgKacir(deliveredCriteria)})\n\n${satirlar.join('\n')}${deliveredTenders.length > 10 ? `\n… +${deliveredTenders.length - 10} ihale` : ''}`);
|
||||
}
|
||||
} catch (e) { console.warn(`[notifier] telegram ${a.email}: ${e.message}`); }
|
||||
saveSent(a.id, gonderilecek.map((t) => t.id));
|
||||
gonderilen++;
|
||||
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
|
||||
console.log(`[notifier] ${a.email} → ${delivery.tenderIds.length} ihale gönderildi (${kriterMetni})`);
|
||||
} catch (e) {
|
||||
hata++;
|
||||
console.warn(`[notifier] abonelik ${a.id} gönderilemedi: ${e.message}`);
|
||||
|
||||
+56
-3
@@ -1,4 +1,4 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createHash,randomUUID} from 'node:crypto';
|
||||
/**
|
||||
* Otomatik e-posta bildirimi abonelikleri (SQLite).
|
||||
* Kullanıcı kendi filtre kriterlerini kaydeder; zamanlayıcı yeni eşleşen
|
||||
@@ -39,6 +39,8 @@ CREATE TABLE IF NOT EXISTS abonelikler (
|
||||
UNIQUE(email, criteria_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email);
|
||||
CREATE TABLE IF NOT EXISTS notification_outbox(id TEXT PRIMARY KEY,subscription_id INTEGER NOT NULL,payload TEXT NOT NULL,tender_ids TEXT NOT NULL,state TEXT NOT NULL,first_attempt INTEGER,lease TEXT,lease_until INTEGER,error TEXT,provider_id TEXT);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS outbox_active ON notification_outbox(subscription_id) WHERE state IN ('pending','sending','uncertain');
|
||||
`;
|
||||
|
||||
export function available() {
|
||||
@@ -148,6 +150,8 @@ export function list(email) {
|
||||
aktif: Boolean(r.aktif),
|
||||
sonGonderim: r.sonGonderim,
|
||||
initialized: r.sonGorulenler !== null,
|
||||
deliveryPending:!!db.prepare("SELECT id FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(r.id),
|
||||
deliveryStatus:db.prepare("SELECT state,error FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(r.id) || null,
|
||||
sonGorulenler: (() => { try { return JSON.parse(r.sonGorulenler || '[]'); } catch { return []; } })(),
|
||||
}));
|
||||
} finally {
|
||||
@@ -159,8 +163,12 @@ export function remove(id, email) {
|
||||
const db = open({ create: false });
|
||||
if (!db) return false;
|
||||
try {
|
||||
const r = db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id, email);
|
||||
return r.changes > 0;
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const r=db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id,email);
|
||||
if(r.changes)db.prepare("UPDATE notification_outbox SET state='cancelled' WHERE subscription_id=? AND state!='sent'").run(id);
|
||||
db.exec('COMMIT');return r.changes>0;
|
||||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -182,3 +190,48 @@ function recordSeen(id,ihaleIdleri,sent) {
|
||||
}
|
||||
export const markSent=(id,ids)=>recordSeen(id,ids,true);
|
||||
export const markBaseline=(id,ids)=>recordSeen(id,ids,false);
|
||||
|
||||
export function reserveDelivery(subscriptionId,payload,tenderIds,{clock=Date.now(),force=false}={}) {
|
||||
const db=open();
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const subscription=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=? AND aktif=1').get(subscriptionId);
|
||||
if(!subscription){db.exec('COMMIT');return null;}
|
||||
let row=db.prepare("SELECT * FROM notification_outbox WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").get(subscriptionId);
|
||||
if(!row){
|
||||
const seen=new Set(JSON.parse(subscription.sonGorulenler || '[]'));
|
||||
if(!payload||!tenderIds.length||(!force&&tenderIds.some(id=>seen.has(id)))){db.exec('COMMIT');return null;}
|
||||
const id=randomUUID();
|
||||
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)){
|
||||
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};
|
||||
}
|
||||
if(row.state==='sending'&&row.lease_until>clock){db.exec('COMMIT');return null;}
|
||||
const lease=randomUUID();
|
||||
db.prepare("UPDATE notification_outbox SET state='sending',first_attempt=COALESCE(first_attempt,?),lease=?,lease_until=? WHERE id=?").run(clock,lease,clock+120000,row.id);
|
||||
db.exec('COMMIT');return {id:row.id,lease,payload:JSON.parse(row.payload),tenderIds:JSON.parse(row.tender_ids)};
|
||||
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
|
||||
}
|
||||
export function acceptDelivery(delivery,providerId) {
|
||||
if(typeof providerId!=='string'||!providerId)throw new Error('Sağlayıcı gönderim kimliği gerekli.');
|
||||
const db=open();
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const row=db.prepare("SELECT * FROM notification_outbox WHERE id=? AND lease=? AND state='sending'").get(delivery.id,delivery.lease);
|
||||
if(!row)throw new Error('Gönderim kaydı artık etkin değil.');
|
||||
const subscription=db.prepare('SELECT sonGorulenler FROM abonelikler WHERE id=?').get(row.subscription_id);
|
||||
if(!subscription)throw new Error('Abonelik artık yok.');
|
||||
const seen=[...new Set([...JSON.parse(subscription.sonGorulenler||'[]'),...JSON.parse(row.tender_ids)])];
|
||||
db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=? WHERE id=?').run(JSON.stringify(seen),new Date().toISOString(),row.subscription_id);
|
||||
db.prepare("UPDATE notification_outbox SET state='sent',provider_id=?,error=NULL WHERE id=?").run(providerId,row.id);
|
||||
db.exec('COMMIT');
|
||||
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
|
||||
}
|
||||
export function failDelivery(delivery,error) {
|
||||
const db=open();
|
||||
try{return db.prepare("UPDATE notification_outbox SET state='pending',error=? WHERE id=? AND lease=? AND state='sending'").run(String(error?.message || error),delivery.id,delivery.lease).changes>0;}
|
||||
finally{db.close();}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user