fix: respect brief opt-out and report channel delivery failures
This commit is contained in:
@@ -1054,3 +1054,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- Yeni takip anlık görüntüleri kazanan firma alanını da saklıyor; havuzdan çıkan bir sonuç kaydının yalnız bu bilgi kaybolduğu için açık görünmesi önleniyor. Eski görüntülerde hiç saklanmamış kazanan bilgisi bu değişiklikle geriye dönük üretilemiyor; güncel kaynak doğrulaması ihtiyacı sürüyor.
|
- Yeni takip anlık görüntüleri kazanan firma alanını da saklıyor; havuzdan çıkan bir sonuç kaydının yalnız bu bilgi kaybolduğu için açık görünmesi önleniyor. Eski görüntülerde hiç saklanmamış kazanan bilgisi bu değişiklikle geriye dönük üretilemiyor; güncel kaynak doğrulaması ihtiyacı sürüyor.
|
||||||
- Regresyon testi güncel kaynakta sonradan iptal, havuz dışındaki üç kapalı durum ve kazanan kaydı, açık saat-belirsiz kayıt, metin/HTML özet ve tüm durumlar aramasını kapsıyor.
|
- Regresyon testi güncel kaynakta sonradan iptal, havuz dışındaki üç kapalı durum ve kazanan kaydı, açık saat-belirsiz kayıt, metin/HTML özet ve tüm durumlar aramasını kapsıyor.
|
||||||
- Doğrulama: `npm test` 237/237; `npm run build` başarılı. Mevcut büyük paket uyarısı sürüyor. Canlı gönderim ve dağıtım yapılmadı. Haftalık zamanlayıcının gönderimden önce tamamlandı kaydı yazması ve yeniden deneme/dayanıklılık akışı hâlâ incelenecek; genel plan tamamlanmadı.
|
- Doğrulama: `npm test` 237/237; `npm run build` başarılı. Mevcut büyük paket uyarısı sürüyor. Canlı gönderim ve dağıtım yapılmadı. Haftalık zamanlayıcının gönderimden önce tamamlandı kaydı yazması ve yeniden deneme/dayanıklılık akışı hâlâ incelenecek; genel plan tamamlanmadı.
|
||||||
|
|
||||||
|
### 2026-09-06 — Haftalık özet tercihi ve gönderim sonucu
|
||||||
|
|
||||||
|
- Yönetici tarafından başlatılan gönderim artık `zorla=1` ile kullanıcının kapalı tercihini aşamıyor. Tercih her kanal çağrısından hemen önce yeniden okunuyor; Telegram beklerken kapatan kullanıcıya sonraki e-posta gönderilmiyor. Zaten sağlayıcıya verilmiş çağrı geri alınmış sayılmıyor.
|
||||||
|
- Gönderim işlevi sağlayıcı kabulünü doğruluyor (Telegram `true`, e-posta dolu gönderim kimliği). Ret/istisna/eksik kabul kaydı hata sayılıyor; bir kanalın başarısı diğerinin hatasını gizlemiyor ve sonraki kullanıcıların işlenmesini durdurmuyor.
|
||||||
|
- Yönetici yanıtında `kanallar.telegram/email` için `denenen/kabul/hata`, kapalı tercih sayısı `kapali`, yapılandırılmış kanalı olmayanların sayısı `kanalsiz` var. `gonderilen` en az bir kanalda kabul edilen kullanıcı sayısı, `hata` en az bir hata yaşayan kullanıcı sayısıdır; kısmi başarıda ikisi de artabilir. `success` hata varsa false, `partial` en az bir kullanıcıya kabul varken hata varsa true. Sağlayıcı kabulü alıcının okuduğunu kanıtlamaz.
|
||||||
|
- Altı yeni test: zorla opt-out, kısmi kanal hatası, eksik/false kabul, gönderim sırasında tercih değişikliği, boş/kanalsız özet, kullanıcı/ilan kaynağı hatası. 243 test geçti; gerçek e-posta/Telegram gönderilmedi.
|
||||||
|
- Henüz kapanmayanlar: kalıcı kanal bazlı tekrar engeli, zamanlayıcının gönderimden önce hafta kaydı yazması, Telegram uzun içerik kesilmesi. Bu değişiklik otomatik yeniden deneme eklemiyor; kabul edilmiş iletilerin tekrarına yol açacak toplu tekrar çözüm olarak kullanılmamalı.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Delivery accounting shared by scheduled and administrator-triggered briefs. */
|
||||||
|
export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) {
|
||||||
|
return async function sendBrief(tenderGetir) {
|
||||||
|
const tenders = await tenderGetir();
|
||||||
|
const r = { kullanici:0, gonderilen:0, bos:0, hata:0, kapali:0, kanalsiz:0,
|
||||||
|
kanallar:{ telegram:{denenen:0,kabul:0,hata:0}, email:{denenen:0,kabul:0,hata:0} } };
|
||||||
|
for (const user of store.listUsers()) {
|
||||||
|
r.kullanici++;
|
||||||
|
let accepted = false, failed = false;
|
||||||
|
try {
|
||||||
|
// Manual runs must also honor the recipient's opt-out.
|
||||||
|
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; continue; }
|
||||||
|
const brief = buildBrief(user.email, tenders);
|
||||||
|
if (brief.bos) { r.bos++; continue; }
|
||||||
|
const channels = [];
|
||||||
|
const telegram = store.getData(user.email, 'telegram');
|
||||||
|
if (telegramConfigured() && telegram?.chatId) channels.push({
|
||||||
|
name:'telegram', send:() => sendTelegram(telegram.chatId, text(brief).slice(0,3900)),
|
||||||
|
valid:receipt => receipt === true,
|
||||||
|
});
|
||||||
|
if (emailConfigured()) channels.push({
|
||||||
|
name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }),
|
||||||
|
valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()),
|
||||||
|
});
|
||||||
|
if (!channels.length) r.kanalsiz++;
|
||||||
|
for (const channel of channels) {
|
||||||
|
// An earlier channel can await the network while the user disables briefs.
|
||||||
|
if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; }
|
||||||
|
const counts = r.kanallar[channel.name];
|
||||||
|
counts.denenen++;
|
||||||
|
try {
|
||||||
|
const receipt = await channel.send();
|
||||||
|
if (!channel.valid(receipt)) throw new Error('Sağlayıcı gönderimi doğrulamadı.');
|
||||||
|
counts.kabul++;
|
||||||
|
accepted = true;
|
||||||
|
} catch (error) {
|
||||||
|
counts.hata++;
|
||||||
|
failed = true;
|
||||||
|
warn('[brief]', user.email, channel.name, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
warn('[brief]', user.email, error.message);
|
||||||
|
}
|
||||||
|
if (accepted) r.gonderilen++;
|
||||||
|
if (failed) r.hata++;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-22
@@ -3,6 +3,7 @@
|
|||||||
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
|
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
|
||||||
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { createBriefSender } from './brief-delivery.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as store from './user-store.mjs';
|
import * as store from './user-store.mjs';
|
||||||
@@ -69,28 +70,10 @@ export function briefHtml(b) {
|
|||||||
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
|
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) {
|
export const haftalikBriefGonder = createBriefSender({
|
||||||
const tenders = await tenderGetir();
|
store, buildBrief:briefOlustur, text:briefMetni, html:briefHtml,
|
||||||
const r = { kullanici: 0, gonderilen: 0, bos: 0, hata: 0 };
|
telegramConfigured, sendTelegram, emailConfigured, sendEmail,
|
||||||
for (const u of store.listUsers()) {
|
});
|
||||||
r.kullanici++;
|
|
||||||
try {
|
|
||||||
const tercih = store.getData(u.email, 'brief');
|
|
||||||
if (tercih && tercih.kapali && !zorla) continue;
|
|
||||||
const b = briefOlustur(u.email, tenders);
|
|
||||||
if (b.bos) { r.bos++; continue; }
|
|
||||||
const tg = store.getData(u.email, 'telegram');
|
|
||||||
let gitti = false;
|
|
||||||
if (telegramConfigured() && tg?.chatId) gitti = await sendTelegram(tg.chatId, briefMetni(b).slice(0, 3900)).then(() => true).catch(() => false);
|
|
||||||
if (emailConfigured()) gitti = (await sendEmail({ to: u.email, subject: `İhaleist — Haftalık Brief (${b.hafta})`, html: briefHtml(b), text: briefMetni(b) }).then(() => true).catch(() => false)) || gitti;
|
|
||||||
if (gitti) r.gonderilen++;
|
|
||||||
} catch (e) {
|
|
||||||
r.hata++;
|
|
||||||
console.warn('[brief]', u.email, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function briefScheduleKey(now = new Date()) {
|
export function briefScheduleKey(now = new Date()) {
|
||||||
const tr = new Date(now.getTime() + 3 * 3600000);
|
const tr = new Date(now.getTime() + 3 * 3600000);
|
||||||
|
|||||||
+2
-2
@@ -1537,8 +1537,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
|
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
|
||||||
const user = userFromAuthHeader(req.headers['authorization']);
|
const user = userFromAuthHeader(req.headers['authorization']);
|
||||||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
|
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
|
||||||
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders, { zorla: url.searchParams.get('zorla') === '1' });
|
const r = await haftalikBriefGonder(async () => (await tendersPayload()).tenders);
|
||||||
return json(res, 200, { success: true, ...r });
|
return json(res, 200, { success: r.hata === 0, partial: r.hata > 0 && r.gonderilen > 0, ...r });
|
||||||
}
|
}
|
||||||
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && req.method === 'POST') {
|
if ((url.pathname === '/api/ai/belge-sor' || url.pathname === '/api/ai/teklif-denetim' || url.pathname === '/api/ai/teklif-taslak') && req.method === 'POST') {
|
||||||
const user = userFromAuthHeader(req.headers['authorization']);
|
const user = userFromAuthHeader(req.headers['authorization']);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createBriefSender } from '../server/brief-delivery.mjs';
|
||||||
|
|
||||||
|
function fixture(overrides={}) {
|
||||||
|
const calls=[];
|
||||||
|
const prefs=new Map();
|
||||||
|
const deps={
|
||||||
|
store:{ listUsers:()=>[{email:'one@example.test'}], getData:(email,key)=>key==='brief'?prefs.get(email):{chatId:'test-chat'} },
|
||||||
|
buildBrief:()=>({bos:false,hafta:'Test'}),text:()=> 'Test metni',html:()=> '<p>Test metni</p>',
|
||||||
|
telegramConfigured:()=>true,emailConfigured:()=>true,
|
||||||
|
sendTelegram:async()=>{calls.push('telegram');return true;},
|
||||||
|
sendEmail:async()=>{calls.push('email');return {id:'test-receipt'};},
|
||||||
|
warn:()=>{}, ...overrides,
|
||||||
|
};
|
||||||
|
return {calls,prefs,send:createBriefSender(deps)};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manual force cannot bypass weekly brief opt-out', async()=>{
|
||||||
|
const f=fixture({buildBrief:()=>{throw new Error('Must not build disabled recipient brief');}});
|
||||||
|
f.prefs.set('one@example.test',{kapali:true});
|
||||||
|
const result=await f.send(async()=>[],{zorla:true});
|
||||||
|
assert.equal(result.kapali,1);
|
||||||
|
assert.equal(result.gonderilen,0);
|
||||||
|
assert.equal(result.hata,0);
|
||||||
|
assert.deepEqual(f.calls,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected channel is counted even when the other channel accepts', async()=>{
|
||||||
|
const f=fixture({sendTelegram:async()=>{throw new Error('Provider unavailable');}});
|
||||||
|
const result=await f.send(async()=>[]);
|
||||||
|
assert.equal(result.gonderilen,1);
|
||||||
|
assert.equal(result.hata,1);
|
||||||
|
assert.deepEqual(result.kanallar,{telegram:{denenen:1,kabul:0,hata:1},email:{denenen:1,kabul:1,hata:0}});
|
||||||
|
assert.deepEqual(f.calls,['email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('false and missing receipts do not become successful sends',async()=>{
|
||||||
|
const f=fixture({sendTelegram:async()=>false,sendEmail:async()=>({})});
|
||||||
|
const result=await f.send(async()=>[]);
|
||||||
|
assert.equal(result.gonderilen,0);
|
||||||
|
assert.equal(result.hata,1);
|
||||||
|
assert.equal(result.kanallar.telegram.hata,1);
|
||||||
|
assert.equal(result.kanallar.email.hata,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opt-out during Telegram delivery prevents the following email',async()=>{
|
||||||
|
const f=fixture({sendTelegram:async()=>{f.prefs.set('one@example.test',{kapali:true});return true;}});
|
||||||
|
const result=await f.send(async()=>[]);
|
||||||
|
assert.equal(result.gonderilen,1);
|
||||||
|
assert.equal(result.kapali,1);
|
||||||
|
assert.equal(result.kanallar.email.denenen,0);
|
||||||
|
assert.deepEqual(f.calls,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty brief and unconfigured delivery are distinguished without sending',async()=>{
|
||||||
|
const empty=fixture({buildBrief:()=>({bos:true})});
|
||||||
|
assert.equal((await empty.send(async()=>[])).bos,1);
|
||||||
|
assert.deepEqual(empty.calls,[]);
|
||||||
|
const unconfigured=fixture({telegramConfigured:()=>false,emailConfigured:()=>false});
|
||||||
|
const result=await unconfigured.send(async()=>[]);
|
||||||
|
assert.equal(result.kanalsiz,1);
|
||||||
|
assert.equal(result.gonderilen,0);
|
||||||
|
assert.equal(result.hata,0);
|
||||||
|
assert.deepEqual(unconfigured.calls,[]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one recipient failure does not skip subsequent recipients; feed failure is propagated',async()=>{
|
||||||
|
const f=fixture({store:{listUsers:()=>[{email:'bad@example.test'},{email:'good@example.test'}],getData:()=>null},
|
||||||
|
buildBrief:email=>{if(email==='bad@example.test')throw new Error('Invalid data');return {bos:false,hafta:'Test'};}});
|
||||||
|
const result=await f.send(async()=>[]);
|
||||||
|
assert.equal(result.kullanici,2);
|
||||||
|
assert.equal(result.hata,1);
|
||||||
|
assert.equal(result.gonderilen,1);
|
||||||
|
assert.deepEqual(f.calls,['email']);
|
||||||
|
await assert.rejects(f.send(async()=>{throw new Error('Feed unavailable');}),/Feed unavailable/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user