Add audited administrator resolution of uncertain email deliveries

This commit is contained in:
2026-09-06 13:06:17 +03:00
parent d956de3014
commit 22e886936e
8 changed files with 118 additions and 3 deletions
+6
View File
@@ -821,3 +821,9 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- İ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. - İ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. - 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. - 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.
### Belirsiz e-posta gönderimi yönetici incelemesi — 6 Eylül 2026
- Mobil PWA içinde yalnız yöneticiye açık bekleyen gönderim/inceleme listesi eklendi. /api/admin/notify/outbox yetkilendirmesi sunucuda 401/403 ile uygulanır; UI gizleme tek kontrol değildir.
- Belirsiz iş confirmed_sent (sağlayıcı id, açık kontrol onayı, 20+ karakter açıklama) veya skip (yeniden göndermeden kapatma) ile çözülebilir. Yeni e-posta gönderilmez. İki karar da grup kimliklerini görülmüş sayarak aynı grubun yeniden oluşmasını önler; yalnız confirmed_sent son gönderim tarihi oluşturur.
- notification_outbox_reviews aktör, karar, açıklama ve zamanı tutar. Karar ve abonelik görülme güncellemesi atomiktir; yalnız uncertain durumunda tek karar kabul edilir. Abonelik sonradan silinse de karar geçmişi korunur.
- 189 test ve derleme geçti. HTTP yönetici yetkisi, geçersiz/eskimiş karar, sağlayıcı kanıtı gereksinimi, tekrar karar reddi, replay engeli ve unsubscribe sonrası inceleme geçmişi test edildi. Yeni yönetici ekranının tarayıcı kabulü henüz yapılmadı; sağlayıcı kaydının gerçek doğrulaması insan incelemesidir.
+1 -1
View File
@@ -66,4 +66,4 @@ E-posta aboneliği deposu `notify.sqlite` da `IHALEIST_USER_DATA_DIR` ayarını
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. 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. 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; Mobil PWA sayfasındaki yöneticiye özel E-posta gönderim incelemesi bölümünden kayıt karara bağlanır. Sağlayıcı kabulü doğrulandıysa gönderim kimliği/onay/açıklama ile kapatılır; aksi halde yeniden göndermeden kapatma seçilebilir. Her iki işlem de yeni e-posta göndermez ve açıklamalı karar geçmişini korur. 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.
+12 -1
View File
@@ -58,7 +58,7 @@ import { documentUrl, ilanMetni, documentText, browserAvailable } from './docume
import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs'; import { ihaleRss, sonucRss, mcpIstek, yaklasanIhaleler } from './feeds-mcp.mjs';
import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs'; import { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.mjs';
import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni } from './ai-teklif.mjs'; import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni } from './ai-teklif.mjs';
import { subscribe, list as listSubs, remove as removeSub, available as notifyAvailable } from './notify-store.mjs'; import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolveDelivery, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs'; import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs'; import { scanSite } from './site-scan.mjs';
@@ -1399,6 +1399,17 @@ const server = http.createServer(async (req, res) => {
} }
} }
if(url.pathname==='/api/admin/notify/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:deliveryQueue()});
if(req.method==='POST'){const body=await readBody(req);return json(res,200,{success:true,delivery:resolveDelivery(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});}
}
// --- Otomatik bildirim abonelikleri --- // --- Otomatik bildirim abonelikleri ---
if (url.pathname === '/api/notify/subscriptions') { if (url.pathname === '/api/notify/subscriptions') {
const user = userFromAuthHeader(req.headers['authorization']); const user = userFromAuthHeader(req.headers['authorization']);
+29 -1
View File
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS abonelikler (
); );
CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email); 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 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 TABLE IF NOT EXISTS notification_outbox_reviews(id TEXT PRIMARY KEY,actor TEXT NOT NULL,action TEXT NOT NULL,note TEXT NOT NULL,provider_id TEXT,reviewed_at TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS outbox_active ON notification_outbox(subscription_id) WHERE state IN ('pending','sending','uncertain'); CREATE UNIQUE INDEX IF NOT EXISTS outbox_active ON notification_outbox(subscription_id) WHERE state IN ('pending','sending','uncertain');
`; `;
@@ -166,7 +167,7 @@ export function remove(id, email) {
db.exec('BEGIN IMMEDIATE'); db.exec('BEGIN IMMEDIATE');
try { try {
const r=db.prepare('DELETE FROM abonelikler WHERE id=? AND email=?').run(id,email); 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); if(r.changes)db.prepare("UPDATE notification_outbox SET state='cancelled' WHERE subscription_id=? AND state IN ('pending','sending','uncertain')").run(id);
db.exec('COMMIT');return r.changes>0; db.exec('COMMIT');return r.changes>0;
}catch(error){db.exec('ROLLBACK');throw error;} }catch(error){db.exec('ROLLBACK');throw error;}
} finally { } finally {
@@ -235,3 +236,30 @@ export function failDelivery(delivery,error) {
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;} 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();} finally{db.close();}
} }
export function deliveryQueue() {
const db=open();
try{return db.prepare("SELECT o.*,r.actor,r.action,r.note,r.reviewed_at FROM notification_outbox o LEFT JOIN notification_outbox_reviews r ON r.id=o.id WHERE o.state IN ('pending','sending','uncertain','sent_manual','skipped') ORDER BY o.first_attempt DESC LIMIT 50").all().map(row=>{
const payload=JSON.parse(row.payload);
return {id:row.id,subscriptionId:row.subscription_id,to:payload.to,subject:payload.subject,state:row.state,error:row.error,
firstAttempt:row.first_attempt,providerId:row.provider_id,review:row.action?{actor:row.actor,action:row.action,note:row.note,at:row.reviewed_at}:null};
});}finally{db.close();}
}
export function resolveDelivery(id,actor,input) {
if(typeof id!=='string'||!id||!actor||!['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.');
if(input.action==='confirmed_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 db=open();
try {
db.exec('BEGIN IMMEDIATE');
const row=db.prepare("SELECT * FROM notification_outbox WHERE id=? AND state='uncertain'").get(id);
if(!row)throw Object.assign(new Error('Kayıt artık inceleme beklemiyor. Listeyi yenileyin.'),{status:409});
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)])];
const now=new Date().toISOString(),sent=input.action==='confirmed_sent';
db.prepare('UPDATE abonelikler SET sonGorulenler=?,sonGonderim=CASE WHEN ? THEN ? ELSE sonGonderim END WHERE id=?').run(JSON.stringify(seen),sent?1:0,now,row.subscription_id);
db.prepare('INSERT INTO notification_outbox_reviews VALUES (?,?,?,?,?,?)').run(id,actor,input.action,input.note.trim(),sent?input.providerId.trim():null,now);
db.prepare('UPDATE notification_outbox SET state=?,provider_id=?,error=NULL WHERE id=?').run(sent?'sent_manual':'skipped',sent?input.providerId.trim():null,id);
db.exec('COMMIT');return {id,state:sent?'sent_manual':'skipped'};
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
}
+41
View File
@@ -0,0 +1,41 @@
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(){
const {isAdmin,authUser}=useApp();
return isAdmin?<Queue key={`${authUser.email}:${getToken()}`}/>:null;
}
function Queue(){
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 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>
<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();})}/>)}
</section>;
}
function Review({row,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">
<p className="font-bold">{row.subject}</p><p>{String(row.to)} · {labels[row.state]}</p><p className="break-all">Kayıt: {row.id}</p>
{row.error&&<p>{row.error}</p>}
{row.review&&<p>{row.review.actor} · {new Date(row.review.at).toLocaleString('tr-TR')} · {row.review.note}</p>}
{row.state==='uncertain'&&<fieldset disabled={busy} className="space-y-2">
<label className="block">Kontrol açıklaması<textarea className="block border w-full p-2" value={note} onChange={e=>setNote(e.target.value)} minLength={20}/></label>
<label className="block">Sağlayıcı gönderim kimliği<input className="block border w-full p-2" value={providerId} onChange={e=>setProviderId(e.target.value)}/></label>
<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>
</fieldset>}
</div>;
}
+2
View File
@@ -1,3 +1,4 @@
import {DeliveryQueue} from './DeliveryQueue';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react'; import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
@@ -302,6 +303,7 @@ export const PwaSettings = () => {
{/* Otomatik bildirim abonelikleri */} {/* Otomatik bildirim abonelikleri */}
<NotifySubscriptions /> <NotifySubscriptions />
<DeliveryQueue />
</div> </div>
); );
+11
View File
@@ -188,3 +188,14 @@ test('durable AI job endpoints enforce account isolation without starting provid
assert.equal((await request('/api/me/ai-jobs?id=missing','DELETE',null,other)).status,404); assert.equal((await request('/api/me/ai-jobs?id=missing','DELETE',null,other)).status,404);
assert.equal((await request('/api/me/ai-jobs','PUT',{})).status,405); assert.equal((await request('/api/me/ai-jobs','PUT',{})).status,405);
}); });
test('outbox review endpoint requires administrator and rejects unsupported or invalid changes',async()=>{
assert.equal((await request('/api/admin/notify/outbox','GET',null,null)).status,401);
assert.equal((await request('/api/admin/notify/outbox')).status,403);
assert.equal((await request('/api/admin/notify/outbox','POST',{id:'x',action:'skip',note:'Bu kullanıcı yönetici değildir.'})).status,403);
const admin=signToken({email:'outbox-admin@example.test',name:'Admin',isAdmin:true});
assert.equal((await request('/api/admin/notify/outbox','GET',null,admin)).status,200);
assert.equal((await request('/api/admin/notify/outbox','DELETE',null,admin)).status,405);
assert.equal((await request('/api/admin/notify/outbox','POST',{id:'x',action:'skip',note:'short'},admin)).status,400);
assert.equal((await request('/api/admin/notify/outbox','POST',{id:'missing',action:'skip',note:'Sağlayıcı kaydı kontrol edildi ve kapatılıyor.'},admin)).status,409);
});
+16
View File
@@ -69,3 +69,19 @@ test('notifier resumes exact saved email after provider failure even when curren
assert.equal(calls.length,2);assert.deepEqual(calls[0],calls[1]);assert.ok(calls[0].idempotencyKey.startsWith('notify/')); assert.equal(calls.length,2);assert.deepEqual(calls[0],calls[1]);assert.ok(calls[0].idempotencyKey.startsWith('notify/'));
assert.deepEqual(subscriptions.list('integration')[0].sonGorulenler,['integrated']); assert.deepEqual(subscriptions.list('integration')[0].sonGorulenler,['integrated']);
}); });
for(const action of ['confirmed_sent','skip'])test(`manual outbox ${action} is audited, closes only uncertain work and prevents replay`,()=>{
const email='review-'+action;
const sub=subscriptions.subscribe(email,{konular:['Lab']})[0];subscriptions.markBaseline(sub.id,[]);
const delivery=subscriptions.reserveDelivery(sub.id,{to:email,subject:'Review',html:'A'},['review-tender'],{clock:1});
assert.throws(()=>subscriptions.resolveDelivery(delivery.id,'admin',{action,note:'Kontrol edilen sağlayıcı kaydı açıklaması.',providerId:'provider-proof',confirmed:true}),/inceleme beklemiyor/);
subscriptions.reserveDelivery(sub.id,null,[],{clock:23*3600000+1});
assert.throws(()=>subscriptions.resolveDelivery(delivery.id,'admin',{action,note:'short'}),/20 karakter/);
if(action==='confirmed_sent')assert.throws(()=>subscriptions.resolveDelivery(delivery.id,'admin',{action,note:'Kontrol edilen sağlayıcı kaydı açıklaması.'}),/kontrol onayı/);
subscriptions.resolveDelivery(delivery.id,'admin',{action,note:'Kontrol edilen sağlayıcı kaydı açıklaması.',providerId:'provider-proof',confirmed:true});
const row=subscriptions.list(email)[0];assert.equal(row.deliveryPending,false);assert.deepEqual(row.sonGorulenler,['review-tender']);assert.equal(!!row.sonGonderim,action==='confirmed_sent');
const review=subscriptions.deliveryQueue().find(row=>row.id===delivery.id);assert.equal(review.review.actor,'admin');assert.equal(review.review.action,action);assert.equal(review.state,action==='skip'?'skipped':'sent_manual');
assert.throws(()=>subscriptions.resolveDelivery(delivery.id,'admin',{action,note:'İkinci karar yazılmamalıdır açıklaması.',providerId:'provider-proof',confirmed:true}),/inceleme beklemiyor/);
assert.equal(subscriptions.reserveDelivery(sub.id,{to:email,html:'A'},['review-tender']),null);
assert.equal(subscriptions.remove(sub.id,email),true);assert.equal(subscriptions.deliveryQueue().find(row=>row.id===delivery.id).review.action,action);
});