feat: review uncertain weekly brief deliveries with audit history

This commit is contained in:
2026-09-06 14:37:32 +03:00
parent 99a7f2f571
commit 7a01e4bed7
11 changed files with 141 additions and 15 deletions
+8
View File
@@ -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.
+2
View File
@@ -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.
+2 -1
View File
@@ -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;
+40 -2
View File
@@ -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());
},
+2 -1
View File
@@ -57,10 +57,11 @@ 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>`;
}
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()) {
+12 -1
View File
@@ -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' });
+11 -10
View File
@@ -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?<Queue key={`${authUser.email}:${getToken()}`}/>:null;
return isAdmin?<Queue weekly={weekly} key={`${authUser.email}:${getToken()}:${weekly}`}/>: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 <section className="bg-white border rounded-xl p-5 space-y-3">
<h3 className="font-bold">E-posta gönderim incelemesi · Yönetici</h3>
<p className="text-sm">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.</p>
return <section aria-label={weekly?'Haftalık özet gönderim incelemesi':'E-posta gönderim incelemesi'} className="bg-white border rounded-xl p-5 space-y-3">
<h3 className="font-bold">{weekly?'Haftalık özet gönderim incelemesi':'E-posta gönderim incelemesi'} · Yönetici</h3>
<p className="text-sm">{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.</p>
<button disabled={busy} onClick={()=>act(load)} className="border rounded px-3 py-2">Gönderim listesini yenile</button>
{busy&&<p role="status">Gönderim kayıtları işleniyor…</p>}
{error&&<p role="alert" className="text-red-700">{error}</p>}
{rows?.length===0&&<p>İncelenecek veya bekleyen gönderim yok.</p>}
{rows?.map(row=><Review key={row.id} row={row} busy={busy} resolve={input=>act(async()=>{await accountRequest('/api/admin/notify/outbox',{method:'POST',body:JSON.stringify({id:row.id,...input})});await load();})}/>)}
{rows?.map(row=><Review key={row.id} row={row} weekly={weekly} busy={busy} resolve={input=>act(async()=>{await accountRequest(endpoint,{method:'POST',body:JSON.stringify({id:row.id,...input})});await load();})}/>)}
</section>;
}
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 <div className="border rounded p-3 space-y-2 text-sm">
@@ -36,7 +37,7 @@ function Review({row,busy,resolve}){
<label className="block"><input type="checkbox" checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/> Sağlayıcı kaydında bu gönderimin kabul edildiğini kontrol ettim.</label>
<button disabled={note.trim().length<20||!providerId.trim()||!confirmed} onClick={()=>resolve({action:'confirmed_sent',note,providerId,confirmed})} className="border rounded p-2 mr-2">Gönderildi olarak kapat</button>
<button disabled={note.trim().length<20} onClick={()=>resolve({action:'skip',note})} className="border rounded p-2">Yeniden göndermeden kapat</button>
<p>İkinci seçenek bu grubu tekrar göndermez; teslim edildiği iddia edilmez. Sonraki yeni ihaleler için abonelik devam eder.</p>
<p>{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.'}</p>
</fieldset>}
</div>;
}
+1
View File
@@ -301,6 +301,7 @@ const PwaPanel = () => {
{/* Otomatik bildirim abonelikleri */}
<NotifySubscriptions />
<DeliveryQueue />
<DeliveryQueue weekly />
</div>
);
+37
View File
@@ -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');
});
+4
View File
@@ -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');
+22
View File
@@ -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');
});