Persist observed tender source revisions and expose paginated history

This commit is contained in:
2026-09-06 10:49:30 +03:00
parent a0f4824c7e
commit 68fe84e9c7
9 changed files with 166 additions and 0 deletions
+18
View File
@@ -1,3 +1,4 @@
import { observeTenders, tenderHistory } from './tender-history.mjs';
import { requestLifetime } from './request-lifetime.mjs';
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
@@ -582,6 +583,11 @@ async function getDetail(ilanId) {
const raw = await ilanDetail(ilanId);
const text = htmlToText(raw.content || '');
const detail = { ...announcementFields(text), adNo: raw.adNo || null };
const sourceTender=cache.tenders.find(t=>t.id===`ilan-${ilanId}`);
if(sourceTender) {
try { observeTenders([{...sourceTender,...detail,id:`ilan-${ilanId}:detail`}]); }
catch { detail.sourceWarning='İlan alındı ancak kaynak geçmişi kaydedilemedi.'; }
}
cache.detail.set(ilanId, detail);
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
return detail;
@@ -661,6 +667,8 @@ async function tendersPayload(force = false) {
}
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
let historyWarning=null;
try { observeTenders(combined); } catch { historyWarning='Kaynak geçmişi kaydedilemedi; değişiklik arşivi eksik olabilir.'; };
return {
success: true,
source: sources.join(' + '),
@@ -669,6 +677,7 @@ async function tendersPayload(force = false) {
ilanCount: c.totalCount,
todayCount: bugunEklenenSayisi(combined),
loadedUniqueCount: combined.length,
historyWarning,
dtCount: dt.totalCount,
historyCount: hist.totalCount,
tenders: combined,
@@ -1476,6 +1485,15 @@ const server = http.createServer(async (req, res) => {
} finally { lifetime.dispose(); }
}
if (url.pathname === '/api/tenders/source-history') {
if (!userFromAuthHeader(req.headers.authorization)) return json(res,401,{success:false,error:'Oturum gerekli.'});
if (req.method !== 'GET') return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
const before=url.searchParams.get('before');
if(before && (!/^\d+$/.test(before) || !Number.isSafeInteger(Number(before)))) return json(res,400,{success:false,error:'Geçersiz sayfa.'});
try { return json(res,200,{success:true,...tenderHistory({ikn:url.searchParams.get('ikn'),kurum:url.searchParams.get('kurum')},{before:before?Number(before):null})}); }
catch { return json(res,503,{success:false,error:'Kaynak geçmişi alınamadı. Yeniden deneyin.'}); }
}
if (url.pathname === '/api/tenders') {
const out = await tendersPayload(url.searchParams.get('refresh') === '1');
return json(res, 200, out);
+56
View File
@@ -0,0 +1,56 @@
import {DatabaseSync} from 'node:sqlite';
import {mkdirSync} from 'node:fs';
import {dirname,join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {createHash} from 'node:crypto';
const root=process.env.IHALEIST_SHARED_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data');
const fields=['baslik','kurum','tarih','saat','durum','alimTuru','aciklama','documentVersion'];
function key(t) {
const ikn=String(t.ikn || '').trim();
if(!ikn)return null;
return /^\d{4}\/\d+$/.test(ikn) ? ikn : `DT:${ikn}:${String(t.kurum || '').trim()}`;
}
function open() {
mkdirSync(root,{recursive:true});
const db=new DatabaseSync(join(root,'tender-history.sqlite'));
try { db.exec(`PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,tender_key TEXT NOT NULL,source_id TEXT NOT NULL,
observed_at TEXT NOT NULL,content_hash TEXT NOT NULL,snapshot TEXT NOT NULL,changes TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS observation_tender ON observations(tender_key,id);
CREATE INDEX IF NOT EXISTS observation_source ON observations(tender_key,source_id,id);`);
return db;
} catch(error) {db.close();throw error;}
}
export function observeTenders(tenders) {
const db=open();
try {
db.exec('BEGIN IMMEDIATE');
const last=db.prepare('SELECT content_hash,snapshot FROM observations WHERE tender_key=? AND source_id=? ORDER BY id DESC LIMIT 1');
const insert=db.prepare('INSERT INTO observations(tender_key,source_id,observed_at,content_hash,snapshot,changes) VALUES (?,?,?,?,?,?)');
for(const t of tenders) {
const k=key(t);if(!k)continue;
const source=String(t.id || t.source || 'unknown');
const prev=last.get(k,source);
// A list response missing detail text must not erase an earlier observed value.
const snapshot={...(prev ? JSON.parse(prev.snapshot) : {})};
for(const field of fields)if(t[field]!=null && t[field]!=='')snapshot[field]=String(t[field]);
const canonical=JSON.stringify(Object.fromEntries(fields.filter(f=>snapshot[f]!=null).map(f=>[f,snapshot[f]])));
const hash=createHash('sha256').update(canonical).digest('hex');
if(prev?.content_hash===hash)continue;
const before=prev ? JSON.parse(prev.snapshot) : {};
const changes=prev ? fields.filter(f=>before[f]!==snapshot[f]).map(field=>({field,before:before[field]??null,after:snapshot[field]??null})) : [];
insert.run(k,source,new Date().toISOString(),hash,canonical,JSON.stringify(changes));
}
db.exec('COMMIT');
} catch(error) {db.exec('ROLLBACK');throw error;} finally {db.close();}
}
export function tenderHistory(tender,{before=null,limit=20}={}) {
const k=key(tender);if(!k)throw new Error('İhale kimliği gerekli.');
const db=open();
try {
const rows=db.prepare('SELECT id,source_id,observed_at,changes FROM observations WHERE tender_key=? AND id<? ORDER BY id DESC LIMIT ?')
.all(k,before || Number.MAX_SAFE_INTEGER,limit+1);
const more=rows.length>limit, page=rows.slice(0,limit);
return {records:page.map(r=>({id:r.id,sourceId:r.source_id,observedAt:r.observed_at,changes:JSON.parse(r.changes)})),nextCursor:more?page.at(-1).id:null};
} finally {db.close();}
}