119 lines
7.3 KiB
JavaScript
119 lines
7.3 KiB
JavaScript
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());
|
||
},
|
||
};
|
||
}
|