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()); }, }; }