From 7a01e4bed7c9d225839d039175655a886d522b05 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 14:37:32 +0300 Subject: [PATCH] feat: review uncertain weekly brief deliveries with audit history --- docs/quality/uygulama-durumu.md | 8 +++++ docs/quality/yedek-geri-yukleme.md | 2 ++ server/brief-delivery.mjs | 3 +- server/brief-outbox.mjs | 42 ++++++++++++++++++++++++-- server/brief.mjs | 3 +- server/index.js | 13 +++++++- src/components/tools/DeliveryQueue.jsx | 21 +++++++------ src/components/tools/PwaSettings.jsx | 1 + tests/brief-outbox.test.mjs | 37 +++++++++++++++++++++++ tests/browser-qa.mjs | 4 +++ tests/http-state.test.mjs | 22 ++++++++++++++ 11 files changed, 141 insertions(+), 15 deletions(-) diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 5017d35..30ea200 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1078,3 +1078,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Zamanlayıcı gönderimden önce yeni hafta-tamamlandı dosyası yazmıyor. Kaynak yükleme hatası gönderim kaydı oluşturmuyor; sonraki Pazartesi saatlik kontrolde tekrar denenebilir. Eski brief-son.json geçiş haftasında çift gönderimi önlemek için hâlâ okunuyor; eski kayıt gerçek teslimatı kanıtlamadığından geçiş haftası kontrolü gerekiyor. - Doğrulama: 256 test geçti; build başarılı. Yeni testler kalıcı kabul sonrası tekrar, aynı anda iki gönderici, kullanıcı/hafta ayrımı, belirsiz hata, değişen içerik, kaynak hatası sonrası devam, gerçek alt süreci SIGKILL ile durdurma ve tam volume yedek/geri yüklemeden sonra tekrar engelini kapsıyor. Gerçek mesaj gönderilmedi. - Açık kalanlar: belirsiz kayıtları sağlayıcı kanıtı ve denetim iziyle karara bağlayan yönetici akışı, Telegram sağlayıcı mesaj kimliği, gerçek sağlayıcı kabulü, Pazartesi tamamen kapalı kalmış sunucunun telafisi ve geçiş haftası operasyonu. Bu aşama bütün haftalık bildirim kabul maddelerini kapatmaz; plan aktif. + +### 2026-09-06 — Haftalık özet yönetici incelemesi + +- Mobil PWA içinde yöneticiye özel, erişilebilir ad taşıyan haftalık özet inceleme bölümü eklendi; abonelik e-postası incelemesi ayrı kalıyor. Bekleyen mesajlar ve kalıcı kararlar listeleniyor. Haftalık listede sessiz 50 kayıt kesintisi yok; çok büyük yönetici kuyruğunda sayfalama/performans hâlâ değerlendirilmeli. +- API oturumsuz erişime 401, normal kullanıcıya 403, desteklenmeyen metoda 405 veriyor. Gönderildi kararı sağlayıcı kimliği, kontrol onayı ve 20–2000 karakterlik açıklama gerektiriyor. Yeniden göndermeden kapatma teslimat iddiası üretmiyor. Sadece belirsiz kayıt karara bağlanabilir; ikinci karar 409. Karar geçmişi mesaj güncellemesiyle aynı transaction içinde saklanıyor. +- Karara bağlanan mesajlar tekrar sahiplenilmiyor. Haftanın tüm mesajları kapalıysa değişen içerik planı da yeni mesaj başlatmıyor. Süresi dolmuş yarım gönderim yönetici listesinde belirsiz görünüyor; etkin işçi kaydı kapatılamıyor. +- Doğrulama: 260 test ve son UI değişikliği sonrası build başarılı. HTTP üzerinden yetki, zorunlu kanıt, karar kalıcılığı ve çakışma; depoda mesaj ayrımı, etkin sahiplik ve skip davranışı test edildi. İzole tarayıcıda altı kontrol geçti: kanıtla kapatma kalıcılığı, sağlayıcı kimliği, göndermeden kapatma, karar sahibi, ayrı abonelik kaydının korunması ve normal kullanıcıda gizlilik. Kanıt `rapor/qa-weekly-brief-review.json`. Test sekmesi ve bu çalışmaya ait geçici sunucu kapatıldı. +- Gerçek mesaj, canlı dağıtım veya gerçek sağlayıcı doğrulaması yapılmadı. Planın diğer kabul maddeleri, Telegram sağlayıcı mesaj kimliği ve haftalık telafi politikası açık. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index b766aa2..f8b1c61 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -89,3 +89,5 @@ Haftalık özet `brief` anahtarı user-store tarafından artık kabul edilir. `/ Haftalık özet teslimat deposu `IHALEIST_USER_DATA_DIR/brief.sqlite` içindedir (ayar yoksa `server/data`). Alıcı, haftalık mesaj planı, mesaj içeriği, parça konumu, işçi sahipliği ve kabul/belirsizlik kayıtlarını içerir; özel veridir. Tam volume yedeği bu SQLite dosyasını kapsar, yalnız kullanıcı/belge yedeği kapsamaz. Dosya 0600, yeni dizin 0700 oluşturulur. Kod geri dönüşü bu kayıtları yok sayıp aynı haftanın özetini tekrar gönderebilir; eski kodun otomatik gönderimini açmadan geçiş haftası sağlayıcı kayıtları değerlendirilmelidir. Eski `server/data/brief-son.json` silinmez: eski sürümün aynı hafta için işaretlediği otomatik gönderim geçişte tekrar yapılmaz. Eski sürüm bu işareti göndermeden önce yazdığı için bu kayıt teslimatı kanıtlamaz. Geçiş haftası manuel kontrol gerektirir. Yeni zamanlayıcı bu dosyaya tamamlandı kaydı yazmaz; kaynak yüklenemezse Pazartesi sonraki saatlik kontrolde tekrar yükleyebilir. Sağlayıcı çağrısına girilmiş fakat kabulü doğrulanamamış mesajlar otomatik yeniden gönderilmez. 120 saniyelik sahipliği sona eren yarım kayıt da belirsiz olur. `brief-outbox.mjs` içindeki salt-okuma `inspect()` sonuçları kayıt incelemesine yardım eder; yönetici karar ekranı/karar geçmişi henüz tamamlanmadı. Kayıtları silmek veya doğrudan pending yapmak yeniden mesaj gönderme riski taşır. + +Haftalık belirsiz kayıtların karar akışı artık Yönetim → Mobil PWA → **Haftalık özet gönderim incelemesi** bölümündedir. `/api/admin/brief/outbox` yalnız yöneticiye açıktır. Gönderildi kararı sağlayıcı kimliği, açık kontrol onayı ve 20–2000 karakterlik açıklama ister; doğrulanamayan mesaj yeni gönderim yapmadan kapatılabilir. `brief_reviews` tablosu karar sahibini, türünü, açıklamasını ve tarihini aynı transaction içinde saklar. İkinci karar 409 ile reddedilir. Süresi dolmuş gönderim sahipliği liste yenilendiğinde belirsiz olarak görünür; etkin gönderim için karar verilemez. Tam volume yedeği karar tablosunu da kapsar. Bu bölüm önceki nottaki eksik yönetici karar ekranını tamamlar; sağlayıcı teslimat kanıtını otomatik olarak doğrulamaz. diff --git a/server/brief-delivery.mjs b/server/brief-delivery.mjs index b647d4a..5302f6c 100644 --- a/server/brief-delivery.mjs +++ b/server/brief-delivery.mjs @@ -31,6 +31,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi }); if (!channels.length) { r.kanalsiz++; continue; } const batch = outbox?.prepare(briefWeekKey(clock()),user.email,channels.map(channel=>channel.payload)); + if (batch?.closed) { r.atlanan+=batch.count; continue; } if (batch?.changed) { r.bekleyen++; r.hata++; continue; } const failedChannels = new Set(); for (const [position,channel] of channels.entries()) { @@ -38,7 +39,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi // An earlier channel can await the network while the user disables briefs. if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; } const claim = outbox?.claim(batch.id,position,clock().getTime()); - if (claim?.state === 'accepted') { r.atlanan++; continue; } + if (claim?.state === 'accepted' || claim?.state === 'skipped') { r.atlanan++; continue; } if (claim && claim.state !== 'sending') { r.bekleyen++; failedChannels.add(channel.name); if (claim.state === 'uncertain') failed=true; diff --git a/server/brief-outbox.mjs b/server/brief-outbox.mjs index 5170a1c..2743681 100644 --- a/server/brief-outbox.mjs +++ b/server/brief-outbox.mjs @@ -20,7 +20,8 @@ export function createBriefOutbox(directory) { chmodSync(file,0o600); db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS brief_batches(id TEXT PRIMARY KEY,week TEXT NOT NULL,email TEXT NOT NULL,manifest TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS brief_messages(batch TEXT NOT NULL,position INTEGER NOT NULL,state TEXT NOT NULL,lease TEXT,lease_until INTEGER,receipt TEXT,error TEXT,PRIMARY KEY(batch,position));`); + CREATE TABLE IF NOT EXISTS brief_messages(batch TEXT NOT NULL,position INTEGER NOT NULL,state TEXT NOT NULL,lease TEXT,lease_until INTEGER,receipt TEXT,error TEXT,PRIMARY KEY(batch,position)); + CREATE TABLE IF NOT EXISTS brief_reviews(batch TEXT NOT NULL,position INTEGER NOT NULL,actor TEXT NOT NULL,action TEXT NOT NULL,note TEXT NOT NULL,reviewed_at TEXT NOT NULL,PRIMARY KEY(batch,position));`); db.exec('BEGIN IMMEDIATE'); const result=work(db); db.exec('COMMIT'); @@ -34,6 +35,8 @@ export function createBriefOutbox(directory) { return transaction(db=>{ const row=db.prepare('SELECT manifest FROM brief_batches WHERE id=?').get(id); if(row) { + const remaining=db.prepare("SELECT COUNT(*) AS count FROM brief_messages WHERE batch=? AND state NOT IN ('accepted','skipped')").get(id).count; + if(!remaining)return {id,closed:true,count:db.prepare('SELECT COUNT(*) AS count FROM brief_messages WHERE batch=?').get(id).count}; const changed=row.manifest!==manifest; if(changed) db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND state='pending'") .run('Haftalık özet içeriği veya alıcı kanalı değişti; yeniden gönderim öncesi inceleme gerekli.',id); @@ -49,7 +52,7 @@ export function createBriefOutbox(directory) { return transaction(db=>{ const row=db.prepare('SELECT * FROM brief_messages WHERE batch=? AND position=?').get(batch,position); if(!row)throw new Error('Özet teslimat kaydı bulunamadı.'); - if(row.state==='accepted')return {state:'accepted'}; + if(row.state==='accepted'||row.state==='skipped')return {state:row.state}; if(row.state==='uncertain')return {state:'uncertain'}; if(row.state==='sending') { if(row.lease_until>clock)return {state:'busy'}; @@ -73,6 +76,41 @@ export function createBriefOutbox(directory) { return transaction(db=>db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=? AND lease=? AND state='sending'") .run(String(error?.message || error),claim.batch,claim.position,claim.lease).changes===1); }, + queue(clock=Date.now()) { + return transaction(db=>{ + db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE state='sending' AND lease_until<=?") + .run('Kesintiye uğrayan gönderim; sağlayıcı kaydı incelenmeli.',clock); + return db.prepare(`SELECT b.week,b.email,b.manifest,m.*,r.actor,r.action,r.note,r.reviewed_at + FROM brief_messages m JOIN brief_batches b ON b.id=m.batch + LEFT JOIN brief_reviews r ON r.batch=m.batch AND r.position=m.position + WHERE m.state!='accepted' OR r.action IS NOT NULL + ORDER BY b.week DESC,b.email,m.position`).all().map(row=>{ + const message=JSON.parse(row.manifest)[row.position]; + return {id:`${row.batch}:${row.position}`,to:message.to || row.email, + subject:`Haftalık özet · ${row.week} · ${message.channel === 'telegram' ? 'Telegram' : 'E-posta'} · Mesaj ${row.position+1}`, + state:row.action==='confirmed_sent'?'sent_manual':row.state,error:row.error,providerId:row.receipt, + review:row.action?{actor:row.actor,action:row.action,note:row.note,at:row.reviewed_at}:null}; + }); + }); + }, + resolve(id,actor,input) { + const match=typeof id==='string' && /^([a-f0-9]{64}):(\d+)$/.exec(id); + if(!match || !Number.isSafeInteger(Number(match[2])) || typeof actor!=='string' || !actor.trim() || + !['confirmed_sent','skip'].includes(input?.action) || typeof input.note!=='string' || input.note.trim().length<20 || input.note.length>2000) + throw new Error('Karar ve en az 20 karakterlik kontrol açıklaması gerekli.'); + const sent=input.action==='confirmed_sent'; + if(sent && (input.confirmed!==true || typeof input.providerId!=='string' || !input.providerId.trim() || input.providerId.length>200)) + throw new Error('Sağlayıcı gönderim kimliği ve kontrol onayı gerekli.'); + const batch=match[1],position=Number(match[2]); + return transaction(db=>{ + const row=db.prepare("SELECT state FROM brief_messages WHERE batch=? AND position=?").get(batch,position); + if(row?.state!=='uncertain')throw Object.assign(new Error('Kayıt artık inceleme beklemiyor. Listeyi yenileyin.'),{status:409}); + db.prepare('INSERT INTO brief_reviews VALUES (?,?,?,?,?,?)').run(batch,position,actor,input.action,input.note.trim(),new Date().toISOString()); + db.prepare('UPDATE brief_messages SET state=?,receipt=?,error=NULL WHERE batch=? AND position=?') + .run(sent?'accepted':'skipped',sent?input.providerId.trim():null,batch,position); + return {id,state:sent?'sent_manual':'skipped'}; + }); + }, inspect() { return transaction(db=>db.prepare('SELECT b.week,b.email,m.* FROM brief_messages m JOIN brief_batches b ON b.id=m.batch ORDER BY b.week DESC,b.email,m.position').all()); }, diff --git a/server/brief.mjs b/server/brief.mjs index 3e689cf..56dcff2 100644 --- a/server/brief.mjs +++ b/server/brief.mjs @@ -57,10 +57,11 @@ export function briefHtml(b) {

Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.

`; } +export const briefOutbox = createBriefOutbox(process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname,'data')); export const haftalikBriefGonder = createBriefSender({ store, buildBrief:briefOlustur, text:briefMetni, html:briefHtml, telegramConfigured, sendTelegram, emailConfigured, sendEmail, - outbox:createBriefOutbox(process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname,'data')), + outbox:briefOutbox, }); export function briefScheduleKey(now = new Date()) { diff --git a/server/index.js b/server/index.js index 33f9220..4a71568 100644 --- a/server/index.js +++ b/server/index.js @@ -58,7 +58,7 @@ import { startScheduler, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs'; -import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs'; +import { briefOlustur, briefMetni, haftalikBriefGonder, briefOutbox } from './brief.mjs'; import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni } from './ai-teklif.mjs'; import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolveDelivery, available as notifyAvailable } from './notify-store.mjs'; import { bildirimleriIsle } from './notifier.mjs'; @@ -1534,6 +1534,17 @@ const server = http.createServer(async (req, res) => { }catch(e){return json(res,e.kod==='VERSION_CONFLICT'?409:400,{success:false,error:e.message,kod:e.kod});} } + if(url.pathname==='/api/admin/brief/outbox') { + const user=userFromAuthHeader(req.headers.authorization); + if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'}); + if(!user.isAdmin)return json(res,403,{success:false,error:'Yönetici yetkisi gerekli.'}); + try { + if(req.method==='GET')return json(res,200,{success:true,deliveries:briefOutbox.queue()}); + if(req.method==='POST'){const body=await readBody(req);return json(res,200,{success:true,delivery:briefOutbox.resolve(body.id,user.email,body)});} + return json(res,405,{success:false,error:'Desteklenmeyen metot.'}); + }catch(error){return json(res,error.status||400,{success:false,error:error.message});} + } + if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') { const user = userFromAuthHeader(req.headers['authorization']); if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' }); diff --git a/src/components/tools/DeliveryQueue.jsx b/src/components/tools/DeliveryQueue.jsx index 38fe716..bd9b8cd 100644 --- a/src/components/tools/DeliveryQueue.jsx +++ b/src/components/tools/DeliveryQueue.jsx @@ -2,27 +2,28 @@ import React,{useEffect,useRef,useState} from 'react'; import {useApp} from '../../context/AppContext'; import {getToken} from '../../services/auth'; import {accountRequest,requireArray} from '../../services/accountRequest'; -export function DeliveryQueue(){ +export function DeliveryQueue({weekly=false}){ const {isAdmin,authUser}=useApp(); - return isAdmin?:null; + return isAdmin?:null; } -function Queue(){ +function Queue({weekly}){ + const endpoint=weekly?'/api/admin/brief/outbox':'/api/admin/notify/outbox'; const [rows,setRows]=useState(null),[error,setError]=useState(null),[busy,setBusy]=useState(false); const alive=useRef(true),locked=useRef(false); - const load=async()=>{const data=await accountRequest('/api/admin/notify/outbox');const rows=requireArray(data,'deliveries');if(alive.current)setRows(rows);}; + const load=async()=>{const data=await accountRequest(endpoint);const rows=requireArray(data,'deliveries');if(alive.current)setRows(rows);}; const act=async fn=>{if(locked.current)return;locked.current=true;setBusy(true);setError(null);try{await fn();}catch(e){if(alive.current)setError(e.message);}finally{locked.current=false;if(alive.current)setBusy(false);}}; useEffect(()=>{alive.current=true;void act(load);return()=>{alive.current=false;};},[]); - return
-

E-posta gönderim incelemesi · Yönetici

-

Bekleyen işler ve son inceleme kararları gösterilir (en fazla 50). Belirsiz gönderimi sağlayıcı kaydıyla karşılaştırın. Bu işlemler yeni e-posta göndermez.

+ return
+

{weekly?'Haftalık özet gönderim incelemesi':'E-posta gönderim incelemesi'} · Yönetici

+

{weekly?'Haftalık özetin bekleyen mesajları ve inceleme kararları gösterilir.':'Bekleyen işler ve son inceleme kararları gösterilir (en fazla 50).'} Belirsiz gönderimi sağlayıcı kaydıyla karşılaştırın. Bu işlemler yeni mesaj göndermez.

{busy&&

Gönderim kayıtları işleniyor…

} {error&&

{error}

} {rows?.length===0&&

İncelenecek veya bekleyen gönderim yok.

} - {rows?.map(row=>act(async()=>{await accountRequest('/api/admin/notify/outbox',{method:'POST',body:JSON.stringify({id:row.id,...input})});await load();})}/>)} + {rows?.map(row=>act(async()=>{await accountRequest(endpoint,{method:'POST',body:JSON.stringify({id:row.id,...input})});await load();})}/>)}
; } -function Review({row,busy,resolve}){ +function Review({row,weekly,busy,resolve}){ const [note,setNote]=useState(''),[providerId,setProviderId]=useState(''),[confirmed,setConfirmed]=useState(false); const labels={pending:'Bekliyor',sending:'Gönderiliyor',uncertain:'Sonuç belirsiz',sent_manual:'Gönderildiği yönetici tarafından doğrulandı',skipped:'Yeniden gönderilmeden kapatıldı'}; return
@@ -36,7 +37,7 @@ function Review({row,busy,resolve}){ -

İkinci seçenek bu grubu tekrar göndermez; teslim edildiği iddia edilmez. Sonraki yeni ihaleler için abonelik devam eder.

+

{weekly?'İkinci seçenek bu mesajı yeniden göndermeden kapatır; teslim edildiği iddia edilmez. Diğer bekleyen mesajlar ve sonraki haftalar bu kararla kapatılmaz.':'İkinci seçenek bu grubu tekrar göndermez; teslim edildiği iddia edilmez. Sonraki yeni ihaleler için abonelik devam eder.'}

}
; } diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index d68ea15..5b6b45f 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -301,6 +301,7 @@ const PwaPanel = () => { {/* Otomatik bildirim abonelikleri */} + ); diff --git a/tests/brief-outbox.test.mjs b/tests/brief-outbox.test.mjs index b310afe..5cbc18f 100644 --- a/tests/brief-outbox.test.mjs +++ b/tests/brief-outbox.test.mjs @@ -97,3 +97,40 @@ test('weekly id uses Monday in Istanbul even while UTC is still Sunday',()=>{ assert.equal(briefWeekKey(new Date('2026-09-06T20:59:59Z')),'2026-08-31'); assert.equal(briefWeekKey(new Date('2026-09-06T21:00:00Z')),'2026-09-07'); }); + +test('review needs provider evidence, persists actor and closes only the selected message',t=>{ + const {outbox,directory}=fixture(t); + const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email',to:'review@example.test'},{channel:'telegram',chatId:'test'}]); + const first=outbox.claim(batch.id,0,1000),second=outbox.claim(batch.id,1,1000); + outbox.hold(first,'Unknown response');outbox.hold(second,'Unknown response'); + const id=`${batch.id}:0`,input={action:'confirmed_sent',note:'Sağlayıcı kayıtları incelendi ve eşleşti.',providerId:'provider-test-id',confirmed:true}; + assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,confirmed:false}),/onayı/); + assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,providerId:''}),/kimliği/); + assert.throws(()=>outbox.resolve(id,'admin@example.test',{...input,note:'short'}),/açıklaması/); + assert.equal(outbox.resolve(id,'admin@example.test',input).state,'sent_manual'); + assert.throws(()=>outbox.resolve(id,'other-admin@example.test',input),{status:409}); + const rows=createBriefOutbox(directory).queue(10000),done=rows.find(row=>row.id===id); + assert.equal(done.review.actor,'admin@example.test');assert.equal(done.review.note,input.note); + assert.equal(done.providerId,'provider-test-id');assert.equal(done.state,'sent_manual'); + assert.equal(rows.find(row=>row.id!==id).state,'uncertain'); + assert.equal(outbox.claim(batch.id,0).state,'accepted'); +}); + +test('skip never claims delivery and a fully reviewed changed plan cannot start new messages',t=>{ + const {outbox}=fixture(t); + const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email'}]); + outbox.hold(outbox.claim(batch.id,0),'Uncertain'); + outbox.resolve(`${batch.id}:0`,'admin@example.test',{action:'skip',note:'Sonuç doğrulanamadı, tekrar gönderilmeden kapatıldı.'}); + assert.equal(outbox.claim(batch.id,0).state,'skipped'); + const row=outbox.queue()[0];assert.equal(row.providerId,null);assert.equal(row.state,'skipped'); + assert.equal(outbox.prepare('2026-09-07','review@example.test',[{channel:'email',text:'Changed'},{channel:'telegram'}]).closed,true); +}); + +test('review queue exposes expired interrupted sends but does not allow reviewing a live claim',t=>{ + const {outbox}=fixture(t); + const batch=outbox.prepare('2026-09-07','review@example.test',[{channel:'email'}]); + outbox.claim(batch.id,0,1000); + assert.equal(outbox.queue(2000)[0].state,'sending'); + assert.throws(()=>outbox.resolve(`${batch.id}:0`,'admin@example.test',{action:'skip',note:'Bu gönderim halen etkin durumdadır.'}),{status:409}); + assert.equal(outbox.queue(121001)[0].state,'uncertain'); +}); diff --git a/tests/browser-qa.mjs b/tests/browser-qa.mjs index 6e56bf2..5c30652 100644 --- a/tests/browser-qa.mjs +++ b/tests/browser-qa.mjs @@ -15,6 +15,10 @@ const keyUser={email:'keys-browser-qa@example.test',name:'API QA',isAdmin:true}; const keyToken=signToken(keyUser); let apiKeyListFailure=false; process.env.IHALEIST_USER_DATA_DIR=dir; +const {createBriefOutbox}=await import('../server/brief-outbox.mjs'); +const qaBriefOutbox=createBriefOutbox(dir); +const qaBriefBatch=qaBriefOutbox.prepare('2026-09-07','weekly-browser@example.test',[{channel:'email',to:'weekly-browser@example.test'},{channel:'telegram',chatId:'synthetic-chat'}]); +for(const position of [0,1])qaBriefOutbox.hold(qaBriefOutbox.claim(qaBriefBatch.id,position),'QA: Sağlayıcı sonucu belirsiz.'); const qaJobs=await import('../server/ai-job-store.mjs'); const {qualifyOfferDraft}=await import('../server/offer-draft.mjs'); const {executeAnalysisJob}=await import('../server/analysis-job.mjs'); diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index 8051e24..22314b3 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -265,3 +265,25 @@ test('weekly brief preference loads without feed preview and changes only with a assert.equal((await request('/api/me/brief','GET',null,other)).data.kapali,false); assert.equal((await request('/api/me/brief','POST',{kapali:false,revision:saved.data.revision})).status,200); }); + +test('weekly brief review is admin-only and persists evidence without sending messages',async()=>{ + const route='/api/admin/brief/outbox'; + assert.equal((await request(route,'GET',null,null)).status,401); + assert.equal((await request(route)).status,403); + assert.equal((await request(route,'POST',{id:'x',action:'skip',note:'Yetkisiz işlem kontrolü.'})).status,403); + const admin=signToken({email:'weekly-admin@example.test',name:'Admin',isAdmin:true}); + const {createBriefOutbox}=await import('../server/brief-outbox.mjs'); + const box=createBriefOutbox(dir),batch=box.prepare('2026-09-07','weekly-test@example.test',[{channel:'email',to:'weekly-test@example.test'}]); + box.hold(box.claim(batch.id,0),'Test sonucu belirsiz'); + const list=await request(route,'GET',null,admin); + assert.equal(list.status,200);assert.equal(list.data.deliveries.length,1); + const id=list.data.deliveries[0].id; + assert.equal((await request(route,'DELETE',null,admin)).status,405); + const input={id,action:'confirmed_sent',providerId:'test-provider-receipt',confirmed:true,note:'İzole test sağlayıcı kaydı doğrulandı.'}; + assert.equal((await request(route,'POST',{...input,confirmed:false},admin)).status,400); + assert.equal((await request(route,'POST',input,admin)).status,200); + assert.equal((await request(route,'POST',input,admin)).status,409); + const saved=(await request(route,'GET',null,admin)).data.deliveries[0]; + assert.equal(saved.review.actor,'weekly-admin@example.test');assert.equal(saved.providerId,input.providerId); + assert.equal(saved.state,'sent_manual'); +});