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
+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 { briefOlustur, briefMetni, haftalikBriefGonder } from './brief.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 { 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 ---
if (url.pathname === '/api/notify/subscriptions') {
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 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');
`;
@@ -166,7 +167,7 @@ export function remove(id, email) {
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);
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;
}catch(error){db.exec('ROLLBACK');throw error;}
} 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;}
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();}
}