Correct calendar deadline export and isolate notification storage

This commit is contained in:
2026-09-06 12:50:06 +03:00
parent f2b33749a7
commit c7bb67b101
7 changed files with 54 additions and 6 deletions
+7
View File
@@ -760,3 +760,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Lease dolmadan çağrı yapılmaması, lease dolunca interrupted durumu, açık retry olmadan çağrı yapılmaması, retry sonrasında aynı kaynak/sonuç ve başka hesaba erişim engeli doğrulandı. Uzak kaynak B çözücüsü hiç çağrılmadı; sabit kaynak A kullanıldı. Her iki SQLite integrity_check sonucu ok. - Lease dolmadan çağrı yapılmaması, lease dolunca interrupted durumu, açık retry olmadan çağrı yapılmaması, retry sonrasında aynı kaynak/sonuç ve başka hesaba erişim engeli doğrulandı. Uzak kaynak B çözücüsü hiç çağrılmadı; sabit kaynak A kullanıldı. Her iki SQLite integrity_check sonucu ok.
- 169 otomatik test geçti. Üretim kodu değişmediği için önceki başarılı derleme tekrar edilmedi. - 169 otomatik test geçti. Üretim kodu değişmediği için önceki başarılı derleme tekrar edilmedi.
- Test lease değerlendirme saatini ilerletir; gerçek 60 saniyelik bekleme, gerçek sağlayıcı isteği, elektrik kesintisi veya platform deploy/volume kurtarma kabulü değildir. Tam D11 yayın/geri dönüş provası açık. - Test lease değerlendirme saatini ilerletir; gerçek 60 saniyelik bekleme, gerçek sağlayıcı isteği, elektrik kesintisi veya platform deploy/volume kurtarma kabulü değildir. Tam D11 yayın/geri dönüş provası açık.
### Takvim dosyası ve bildirim deposu — 6 Eylül 2026
- ICS çıktısındaki DTSTART ile aynı ana yazılan DTEND kaldırıldı. Son teklif anı ek süre uydurmadan, TRANSP:TRANSPARENT olarak dışa aktarılır. RFC 5545 §3.6.1 ve §3.8.2.2: https://www.rfc-editor.org/rfc/rfc5545.html
- İhale tarihi değiştiğinde UID sabit kalır; dosya ve arayüz bunun otomatik takvim aboneliği olmadığını açıklar. Düğme “Takvim Dosyasını İndir (.ics)” olarak adlandırıldı. Tek başına UID, Google/Outlook dosya içe aktarımında mevcut kaydın güncelleneceğini garanti etmez.
- Tek başına CR karakteri de ICS metninde kaçırılıyor; ek VEVENT enjeksiyonu testi eklendi.
- Bildirim deposunun sabit server/data yolu, IHALEIST_USER_DATA_DIR ayarına bağlandı; varsayılan yol korunur. Yeni dosya izinleri 0600. İki ayrı süreçte kalıcılık ve hesap bazlı okundu işaretleme test edildi. Bu değişiklik test deposunun üretim bildirimleriyle karışmasını ve özel volume yedeğinin bildirimleri atlamasını önler.
- 171 test ve üretim derlemesi geçti. Özel veri dizini kullanan eski kurulumlar için takip.sqlite taşıma gereksinimi runbook'a eklendi. Üretim taşıması yapılmadı; gerçek Google/Outlook içe aktarımı ve zeyilname bildiriminin dış kanallara teslimi henüz kabul edilmedi.
+2
View File
@@ -59,3 +59,5 @@ Kalıcı genel analiz ve teklif işleri için süreç kesintisi davranışı:
- İnsan incelemeli genel analizde dosya/inceleme sürümü değişmişse eski işi yeniden başlatmak yerine güncel incelemeyle yeni iş gerekir. - İnsan incelemeli genel analizde dosya/inceleme sürümü değişmişse eski işi yeniden başlatmak yerine güncel incelemeyle yeni iş gerekir.
- Kaynağın sabitlenmesinden önce kesilen işin, geçmişte hangi uzak metni edinmiş olabileceği kanıtlanamaz. Eski sürümde tamamlanan kayıtların kaynakları geriye dönük üretilemez. - Kaynağın sabitlenmesinden önce kesilen işin, geçmişte hangi uzak metni edinmiş olabileceği kanıtlanamaz. Eski sürümde tamamlanan kayıtların kaynakları geriye dönük üretilemez.
- `node --test tests/ai-job-crash.test.mjs`: iki gerçek alt süreç SIGKILL testi; yeni süreçte kaynak/sonuç ve SQLite bütünlüğü kontrol edilir. Lease süresi testte saat parametresiyle ilerletilir. Gerçek AI sağlayıcısı ve üretim platformunun volume yeniden bağlama davranışı bu testin kapsamı dışındadır. - `node --test tests/ai-job-crash.test.mjs`: iki gerçek alt süreç SIGKILL testi; yeni süreçte kaynak/sonuç ve SQLite bütünlüğü kontrol edilir. Lease süresi testte saat parametresiyle ilerletilir. Gerçek AI sağlayıcısı ve üretim platformunun volume yeniden bağlama davranışı bu testin kapsamı dışındadır.
Bildirim ve takip taraması deposu artık `IHALEIST_USER_DATA_DIR/takip.sqlite` içindedir; değişken yoksa mevcut `server/data/takip.sqlite` yolu korunur. Özel kullanıcı veri diziniyle çalışan eski kurulum yükseltilirken eski sabit yoldaki takip.sqlite ayrıca envantere alınmalıdır. Tüm yazıcılar durdurularak SQLite tutarlı yedeği doğrulanmalı ve hedefte mevcut bir takip.sqlite üzerine yazmadan yeni dizine aktarılmalıdır. Bu yerel değişiklik üretim verisini taşımadı. Tam volume yedeği bu dosyayı kapsar; yalnız `backup-user-data.mjs` kullanmak bildirimleri yedeklemez.
+3 -2
View File
@@ -25,7 +25,7 @@ let DatabaseSync = null;
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* devre dışı */ } try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* devre dışı */ }
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.join(__dirname, 'data', 'takip.sqlite'); const DB_PATH = path.join(process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname,'data'), 'takip.sqlite');
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS takip_anlik ( CREATE TABLE IF NOT EXISTS takip_anlik (
ikn TEXT PRIMARY KEY, ilan_sayisi INTEGER, son_ilan_tarihi TEXT, ihale_tarih TEXT, ikn TEXT PRIMARY KEY, ilan_sayisi INTEGER, son_ilan_tarihi TEXT, ihale_tarih TEXT,
@@ -42,8 +42,9 @@ let db = null;
function open() { function open() {
if (db) return db; if (db) return db;
if (!DatabaseSync) return null; if (!DatabaseSync) return null;
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); fs.mkdirSync(path.dirname(DB_PATH), { recursive: true, mode:0o700 });
db = new DatabaseSync(DB_PATH); db = new DatabaseSync(DB_PATH);
fs.chmodSync(DB_PATH,0o600);
db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA journal_mode = WAL;');
db.exec(SCHEMA); db.exec(SCHEMA);
return db; return db;
+2 -1
View File
@@ -422,8 +422,9 @@ export const TenderDetailModal = () => {
onClick={handleExportICS} onClick={handleExportICS}
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-bold py-2.5 px-3 rounded-lg text-xs flex items-center justify-center gap-2 border border-slate-300" className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-bold py-2.5 px-3 rounded-lg text-xs flex items-center justify-center gap-2 border border-slate-300"
> >
<Calendar className="w-4 h-4 text-blue-600" /> Google / Outlook Takvime Ekle <Calendar className="w-4 h-4 text-blue-600" /> Takvim Dosyasını İndir (.ics)
</button> </button>
<p className="text-xs text-slate-600 mt-2">Bu dosya otomatik güncellenmez. Tarih veya saat değişirse Google/Outlook takviminizdeki eski kaydı güncelleyin; yeni dosyayı içe aktarmak ikinci bir kayıt oluşturabilir.</p>
</div> </div>
</div> </div>
+3 -3
View File
@@ -15,7 +15,7 @@ export function tenderDeadline(t) {
throw new Error('Geçersiz ihale tarihi veya saati.'); throw new Error('Geçersiz ihale tarihi veya saati.');
return new Date(Date.UTC(Number(year),Number(month)-1,Number(day),hour-3,minute)); return new Date(Date.UTC(Number(year),Number(month)-1,Number(day),hour-3,minute));
} }
const escapeICS = v => String(v || '').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,'); const escapeICS = v => String(v || '').replace(/\\/g,'\\\\').replace(/\r\n|\r|\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,');
const stamp = date => date.toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z'); const stamp = date => date.toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z');
function fold(line) { function fold(line) {
const result = []; let current = ''; const result = []; let current = '';
@@ -31,9 +31,9 @@ export function createTenderICS(t, now = new Date()) {
if (!uid) throw new Error('İhale kimliği gerekli.'); if (!uid) throw new Error('İhale kimliği gerekli.');
return ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Ihaleist//Tender Calendar//TR','CALSCALE:GREGORIAN', return ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Ihaleist//Tender Calendar//TR','CALSCALE:GREGORIAN',
'BEGIN:VEVENT',`UID:${uid}@ihaleist.com`,`DTSTAMP:${stamp(now)}`, 'BEGIN:VEVENT',`UID:${uid}@ihaleist.com`,`DTSTAMP:${stamp(now)}`,
`DTSTART:${stamp(start)}`,`DTEND:${stamp(start)}`, `DTSTART:${stamp(start)}`,'TRANSP:TRANSPARENT',
`SUMMARY:${escapeICS('İhale son teklif: '+t.baslik)}`, `SUMMARY:${escapeICS('İhale son teklif: '+t.baslik)}`,
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`, `DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum+'\nBu tek seferlik takvim kaydı otomatik güncellenmez. İhale tarihini resmi kaynaktan doğrulayın; değişiklikte takviminizdeki eski kaydı güncelleyin.')}`,
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n'); 'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
} }
+13
View File
@@ -47,3 +47,16 @@ test('month view includes late-month tenders and leap days', () => {
assert.equal(calendarMonthDays('2027-02').length,28); assert.equal(calendarMonthDays('2027-02').length,28);
assert.deepEqual(calendarMonthDays('2026-13'),[]); assert.deepEqual(calendarMonthDays('2026-13'),[]);
}); });
test('deadline ICS is a transparent instant, preserves UID across date changes and states update limits',()=>{
const first=createTenderICS(t,new Date('2026-09-01T00:00:00Z'));
const changed=createTenderICS({...t,tarih:'12.09.2026',saat:'14:30'},new Date('2026-09-02T00:00:00Z'));
assert.equal(first.match(/^UID:.+$/m)[0],changed.match(/^UID:.+$/m)[0]);
assert.match(changed,/DTSTART:20260912T113000Z/);
assert.doesNotMatch(changed,/DTEND:|DURATION:/);
assert.match(changed,/TRANSP:TRANSPARENT/);
assert.match(changed.replace(/\r\n /g,''),/otomatik güncellenmez/);
const hostile=createTenderICS({...t,baslik:'A\rEND:VEVENT\rBEGIN:VEVENT\nB'});
assert.equal(hostile.split('\r\n').filter(line=>line==='BEGIN:VEVENT').length,1);
assert.equal(hostile.split('\r\n').filter(line=>line==='END:VEVENT').length,1);
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync} from 'node:child_process';
import {mkdtempSync,rmSync,existsSync,statSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
const url=new URL('../server/zeyilname.mjs',import.meta.url).href;
test('notifications live in configured user volume, persist, and scope read acknowledgements to owner',()=>{
const dir=mkdtempSync(join(tmpdir(),'ihaleist-notifications-'));
const run=code=>JSON.parse(execFileSync(process.execPath,['--input-type=module','-e',`const notifications=await import(${JSON.stringify(url)});${code}`],{
env:{...process.env,IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared')},encoding:'utf8',timeout:10000,stdio:['ignore','pipe','pipe']
}));
try {
const initial=run(`
const id=notifications.bildirimYaz({email:'owner',tur:'zeyilname',ikn:'2026/1',baslik:'Tarih değişti',metin:'10 Eylül → 12 Eylül'});
const foreign=notifications.okunduIsaretle('other',[id]);
console.log(JSON.stringify({id,foreign,owner:notifications.bildirimleriOku('owner'),other:notifications.bildirimleriOku('other')}));
`);
assert.equal(initial.foreign,0);assert.deepEqual(initial.other,[]);assert.equal(initial.owner[0].okundu,0);
assert.ok(existsSync(join(dir,'takip.sqlite')));assert.equal(statSync(join(dir,'takip.sqlite')).mode&0o777,0o600);
const next=run(`const changed=notifications.okunduIsaretle('owner',[${JSON.stringify(initial.id)}]);console.log(JSON.stringify({changed,rows:notifications.bildirimleriOku('owner')}));`);
assert.equal(next.changed,1);assert.equal(next.rows[0].okundu,1);assert.equal(next.rows[0].metin,'10 Eylül → 12 Eylül');
}finally {rmSync(dir,{recursive:true,force:true});}
});