diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 4825ce0..845edb7 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -814,3 +814,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Resend belgelerine göre idempotency anahtarları 24 saat tutulur: https://resend.com/docs/dashboard/emails/idempotency-keys . Bu nedenle kalıcı kuyruğun süresi dolan belirsiz işi sessizce yeniden göndermemesi gerekir. - 183 otomatik test geçti. Gövdenin 25 kaydı kapsaması, HTML kaçırma, gönderici/anahtar aktarımı ve eksik gönderim kimliği hatası kontrollü testlerle doğrulandı. Gerçek e-posta gönderilmedi. - Anahtar desteği henüz notifier'ın kalıcı gönderim kaydına bağlanmadı; kalıcı outbox işi açık. Bu değişiklik tekrar gönderimin bütünüyle önlendiği anlamına gelmez. + +### Kalıcı abonelik e-postası kuyruğu — 6 Eylül 2026 +- notification_outbox notify.sqlite'a eklendi. İçerik, gönderen, alıcı, ihale grubu ve UUID kalıcıdır; retry güncel şablonu/ilanı kullanarak metni değiştirmez. Resend Idempotency-Key aynı UUID'den üretilir. +- Abonelik başına tek aktif grup unique index ve transaction ile korunur. 120sn işçi sahipliğiyle ikinci işçi aynı grubu üstlenemez; geç eski işçinin başarı/hata kaydı reddedilir. Sağlayıcı kabulü ve görülen ihale listesi aynı transaction'da tamamlanır. Eski abonelik listesiyle çalışan işçi daha önce işaretlenmiş grubu yeniden oluşturamaz. +- İlk çağrıdan 23 saat geçmiş belirsiz grup otomatik yeniden gönderilmez; abonelik listesinde inceleme gereksinimi gösterilir. Abonelik silinince bekleyen grup iptal edilir; hâlihazırda sağlayıcıya ulaşmış isteği geri çekme garantisi yoktur. +- 186 test ve üretim derlemesi geçti. Gerçek depo/notifier + kontrollü sağlayıcıyla hata→kaynak değişikliği→aynı gövde/anahtar retry→kabul test edildi. Kaynak sabitleme, işçi yarışması, eski işçi reddi, atomik görülme, süre aşımı ve abonelik silme test edildi. +- Gerçek Resend teslimi, gerçek SIGKILL/outbox kabulü ve belirsiz gönderimin yönetici çözüm ekranı henüz yok. Telegram ayrı kalıcı kuyrukta değil; e-posta kabulünden sonra süreç kesilirse Telegram bildirimi eksik kalabilir. Sağlayıcı hesap değişimi/geri yükleme işlemleri için runbook kısıtları eklendi. Planın tümü açık. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index 7170ca6..48f76d0 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -65,3 +65,5 @@ Bildirim ve takip taraması deposu artık `IHALEIST_USER_DATA_DIR/takip.sqlite` E-posta aboneliği deposu `notify.sqlite` da `IHALEIST_USER_DATA_DIR` ayarını izler; değişken yoksa eski `server/data` yolu kullanılır. Özel dizinli eski kurulumlarda yükseltmeden önce sabit yoldaki notify.sqlite tutarlı biçimde yedeklenip yeni hedefe taşınmalıdır; mevcut hedef üzerine yazılmaz. Yerel değişiklik üretim deposunu taşımadı. Bu dosya yalnız kullanıcı/belge yedeğinin kapsamına girmez, tam volume envanterinde ayrıca bulunmalıdır. notify.sqlite abonelik kimliği geçişi: `criteria_key` sütunu eklendi; konu ve kurum kümeleri dahil bütün filtreler aboneliğin kimliğine katılır. Eski tablo işlem içinde yeniden kurulur; id, oluşturulma, aktiflik, son gönderim, görülen ihaleler ve AUTOINCREMENT üst sınırı korunur. Eski eşdeğer yinelenen kayıtlar silinmez; ayrı legacy kimliğiyle korunur. Bozuk eski konu/kurum JSON'u geçişi başarısız yapar ve transaction geri alınır. Yükseltmeden önce notify.sqlite yedeği alınmalı; eski uygulama koduna dönülecekse eski unique kuralını kullanan INSERT nedeniyle yalnız kodu geri almak yeterli değildir: eşleşen eski veritabanı yedeğiyle birlikte geri dönüş gerekir. Yeni sürümde oluşan kayıtların geri dönüşte nasıl korunacağı ayrıca değerlendirilmelidir. + +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; belirsiz kaydın elle çözüm ekranı henüz yoktur. 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. diff --git a/server/notifier.mjs b/server/notifier.mjs index fa082c2..8455ded 100644 --- a/server/notifier.mjs +++ b/server/notifier.mjs @@ -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.
`; - 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, `İhaleist — ${gonderilecek.length} yeni ihale (${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, `İhaleist — ${deliveredTenders.length} yeni ihale (${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}`); diff --git a/server/notify-store.mjs b/server/notify-store.mjs index f582bb6..31e7fab 100644 --- a/server/notify-store.mjs +++ b/server/notify-store.mjs @@ -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();} +} diff --git a/src/components/tools/NotifySubscriptions.jsx b/src/components/tools/NotifySubscriptions.jsx index 126227e..224d545 100644 --- a/src/components/tools/NotifySubscriptions.jsx +++ b/src/components/tools/NotifySubscriptions.jsx @@ -123,6 +123,7 @@ const SubscriptionForm = () => {{ozet(a)}
{a.sonGonderim ? `son gönderim: ${new Date(a.sonGonderim).toLocaleString('tr-TR')}` : 'ilk tur bekleniyor'} + {a.deliveryStatus && {a.deliveryStatus.state==='uncertain'?'Gönderim sonucu belirsiz. Uygulama yöneticisinin sağlayıcı kaydını kontrol etmesi gerekiyor.':'E-posta gönderimi bekliyor.'}}