Persist observed tender source revisions and expose paginated history
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();}
|
||||
}
|
||||
Reference in New Issue
Block a user