Files
ihaleist/server/brief-outbox.mjs
T

119 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync, chmodSync } from 'node:fs';
import path from 'node:path';
import { randomUUID, createHash } from 'node:crypto';
const hash = value => createHash('sha256').update(value).digest('hex');
export function briefWeekKey(now = new Date()) {
const date = new Date(now.getTime() + 3 * 3600000);
date.setUTCDate(date.getUTCDate() - (date.getUTCDay()+6)%7);
return date.toISOString().slice(0,10);
}
/** A claimed message is never blindly replayed after an interrupted provider call. */
export function createBriefOutbox(directory) {
function transaction(work) {
mkdirSync(directory,{recursive:true,mode:0o700});
const file=path.join(directory,'brief.sqlite');
const db = new DatabaseSync(file);
try {
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_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');
return result;
} catch(error) { try { db.exec('ROLLBACK'); } catch {} throw error; }
finally { db.close(); }
}
return {
prepare(week,email,messages) {
const id=hash(JSON.stringify([week,email])),manifest=JSON.stringify(messages);
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);
return {id,changed};
}
db.prepare('INSERT INTO brief_batches VALUES (?,?,?,?)').run(id,week,email,manifest);
const insert=db.prepare("INSERT INTO brief_messages(batch,position,state) VALUES (?,?,'pending')");
messages.forEach((_,i)=>insert.run(id,i));
return {id,changed:false};
});
},
claim(batch,position,clock=Date.now()) {
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'||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'};
db.prepare("UPDATE brief_messages SET state='uncertain',error=? WHERE batch=? AND position=?").run('Kesintiye uğrayan gönderim; sağlayıcı kaydı incelenmeli.',batch,position);
return {state:'uncertain'};
}
const lease=randomUUID();
db.prepare("UPDATE brief_messages SET state='sending',lease=?,lease_until=? WHERE batch=? AND position=?").run(lease,clock+120000,batch,position);
return {state:'sending',batch,position,lease};
});
},
accept(claim,receipt) {
if(typeof receipt!=='string'||!receipt.trim())throw new Error('Gönderim kabul kaydı gerekli.');
return transaction(db=>{
const result=db.prepare("UPDATE brief_messages SET state='accepted',receipt=?,error=NULL WHERE batch=? AND position=? AND lease=? AND state='sending'")
.run(receipt,claim.batch,claim.position,claim.lease);
if(result.changes!==1)throw new Error('Özet gönderim kaydı değişti.');
});
},
hold(claim,error) {
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());
},
};
}