Give topic and institution subscription sets independent identities
This commit is contained in:
@@ -787,3 +787,9 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
||||
- Sunucu filtre tipleri/listeleri ve DELETE kimliği doğrulanıyor; desteklenmeyen metot 405. notify.sqlite yapılandırılmış kullanıcı volume'una bağlı ve 0600 izinli; eski özel dizinli kurulum için taşıma gereksinimi runbook'ta.
|
||||
- 174 test ve üretim derlemesi geçti. Yeni depo testi geçersiz kriterin kayıt oluşturmamasını, hesap izolasyonunu ve sahip silmesini doğruluyor. Bu yeni formun tarayıcı hata/kayıt/sil kabulü ve gerçek e-posta teslimi henüz yapılmadı.
|
||||
- Mevcut abonelik kimliği (aynı şehir/sektör/alım türü/anahtar) yeniden kayıtta konu listesini güncelliyor; farklı konu kümelerini bağımsız abonelikler olarak tutma davranışı ayrıca ele alınmalı. D09 tamamlanmış sayılmaz.
|
||||
|
||||
### Abonelik konu/kurum kimliği — 6 Eylül 2026
|
||||
- Aynı şehir/sektör/alım türü/anahtarda farklı konu listesi önceki aboneliği değiştiriyordu. Yeni criteria_key konu ve kurum kümelerini de kapsar; farklı kümeler bağımsız saklanır. Aynı seçimin sıra/büyük-küçük harf/kenar boşluğu farkı yeni abonelik oluşturmaz, gönderim geçmişi korunur.
|
||||
- Legacy tablo tek transaction ile taşınır. Kimlikler, gönderim/görülen ihale alanları, aktiflik ve AUTOINCREMENT üst sınırı korunur. Normalleştirmeyle eşdeğer olan eski kayıtlar kayıpsız bırakılır; bozuk eski JSON'da geçiş rollback olur.
|
||||
- 177 otomatik test geçti: farklı kümeler, eşdeğer kayıt tekrarları, gönderim geçmişi, legacy tekrarlar/id sırası, index yeniden kurulması ve hatada rollback/integrity doğrulandı. Sunucu kodu değişti; arayüz değişmediği için önceki derleme tekrarlanmadı.
|
||||
- Eski koda geri dönüşün veritabanı yedeğiyle eşleştirilmesi runbook'a eklendi. Üretim migration uygulanmadı. Yeni abonelik formunun tarayıcı kayıt/sil/hata kabulü açık.
|
||||
|
||||
@@ -63,3 +63,5 @@ Kalıcı genel analiz ve teklif işleri için süreç kesintisi davranışı:
|
||||
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.
|
||||
|
||||
E-posta aboneliği deposu `notify.sqlite` da `IHALEIST_USER_DATA_DIR` ayarını izler; değişken yoksa eski `server/data` yolu kullanılır. Özel dizinli eski kurulumlarda yükseltmeden önce sabit yoldaki notify.sqlite tutarlı biçimde yedeklenip yeni hedefe taşınmalıdır; mevcut hedef üzerine yazılmaz. Yerel değişiklik üretim deposunu taşımadı. Bu dosya yalnız kullanıcı/belge yedeğinin kapsamına girmez, tam volume envanterinde ayrıca bulunmalıdır.
|
||||
|
||||
notify.sqlite abonelik kimliği geçişi: `criteria_key` sütunu eklendi; konu ve kurum kümeleri dahil bütün filtreler aboneliğin kimliğine katılır. Eski tablo işlem içinde yeniden kurulur; id, oluşturulma, aktiflik, son gönderim, görülen ihaleler ve AUTOINCREMENT üst sınırı korunur. Eski eşdeğer yinelenen kayıtlar silinmez; ayrı legacy kimliğiyle korunur. Bozuk eski konu/kurum JSON'u geçişi başarısız yapar ve transaction geri alınır. Yükseltmeden önce notify.sqlite yedeği alınmalı; eski uygulama koduna dönülecekse eski unique kuralını kullanan INSERT nedeniyle yalnız kodu geri almak yeterli değildir: eşleşen eski veritabanı yedeğiyle birlikte geri dönüş gerekir. Yeni sürümde oluşan kayıtların geri dönüşte nasıl korunacağı ayrıca değerlendirilmelidir.
|
||||
|
||||
+48
-11
@@ -1,3 +1,4 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
/**
|
||||
* Otomatik e-posta bildirimi abonelikleri (SQLite).
|
||||
* Kullanıcı kendi filtre kriterlerini kaydeder; zamanlayıcı yeni eşleşen
|
||||
@@ -34,7 +35,8 @@ CREATE TABLE IF NOT EXISTS abonelikler (
|
||||
sonGonderim TEXT,
|
||||
sonGorulenler TEXT, -- JSON: son gönderimde bildirilen ihale id'leri
|
||||
olusturma TEXT,
|
||||
UNIQUE(email, sehir, sektor, alim_turu, anahtar)
|
||||
criteria_key TEXT NOT NULL,
|
||||
UNIQUE(email, criteria_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email);
|
||||
`;
|
||||
@@ -50,14 +52,48 @@ function open({ create = true } = {}) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true, mode:0o700 });
|
||||
}
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
try {
|
||||
fs.chmodSync(DB_PATH,0o600);
|
||||
db.exec('PRAGMA busy_timeout=5000');
|
||||
db.exec(SCHEMA);
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const k = db.prepare('PRAGMA table_info(abonelikler)').all().map((c) => c.name);
|
||||
if (!k.includes('konular')) db.exec('ALTER TABLE abonelikler ADD COLUMN konular TEXT');
|
||||
} catch (e) { console.warn('[notify-store] şema geçişi:', e.message); }
|
||||
db.exec(SCHEMA);
|
||||
const columns=db.prepare('PRAGMA table_info(abonelikler)').all().map(column=>column.name);
|
||||
if(!columns.includes('konular'))db.exec('ALTER TABLE abonelikler ADD COLUMN konular TEXT');
|
||||
if(!columns.includes('criteria_key')) {
|
||||
const rows=db.prepare('SELECT * FROM abonelikler ORDER BY id').all();
|
||||
const sequence=db.prepare("SELECT seq FROM sqlite_sequence WHERE name='abonelikler'").get()?.seq || 0;
|
||||
db.exec('ALTER TABLE abonelikler RENAME TO abonelikler_legacy');
|
||||
db.exec(SCHEMA);
|
||||
const names=['id','email','ad','sehir','sektor','alim_turu','anahtar','kurumlar','konular','aktif','sonGonderim','sonGorulenler','olusturma','criteria_key'];
|
||||
const insert=db.prepare(`INSERT INTO abonelikler (${names.join(',')}) VALUES (${names.map(()=>'?').join(',')})`);
|
||||
const used=new Set();
|
||||
for(const row of rows) {
|
||||
const key=subscriptionKey({sehir:row.sehir,sektor:row.sektor,alimTuru:row.alim_turu,anahtar:row.anahtar,
|
||||
konular:JSON.parse(row.konular || '[]'),kurumlar:JSON.parse(row.kurumlar || '[]')});
|
||||
const identity=JSON.stringify([row.email,key]);
|
||||
// Preserve legacy duplicates and their delivery history; do not discard records.
|
||||
row.criteria_key=used.has(identity)?key+':legacy:'+row.id:key;used.add(identity);
|
||||
insert.run(...names.map(name=>row[name] ?? null));
|
||||
}
|
||||
db.exec('DROP TABLE abonelikler_legacy');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_abone_email ON abonelikler(email)');
|
||||
const current=db.prepare("SELECT seq FROM sqlite_sequence WHERE name='abonelikler'").get();
|
||||
if(current)db.prepare("UPDATE sqlite_sequence SET seq=? WHERE name='abonelikler'").run(Math.max(sequence,current.seq));
|
||||
else db.prepare('INSERT INTO sqlite_sequence(name,seq) VALUES (?,?)').run('abonelikler',sequence);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||||
return db;
|
||||
}catch(error){db.close();throw error;}
|
||||
}
|
||||
|
||||
export function subscriptionKey(criteria) {
|
||||
const normal=value=>String(value || '').trim().toLocaleLowerCase('tr-TR');
|
||||
const set=values=>{if(!Array.isArray(values))throw new Error('Abonelik konu/kurum listesi geçersiz.');return [...new Set(values.map(normal))].sort();};
|
||||
return createHash('sha256').update(JSON.stringify([
|
||||
normal(criteria.sehir),normal(criteria.sektor),normal(criteria.alimTuru),normal(criteria.anahtar),set(criteria.konular || []),set(criteria.kurumlar || [])
|
||||
])).digest('hex');
|
||||
}
|
||||
|
||||
const bosaNull = (v) => (v == null || v === '' ? '' : String(v).trim());
|
||||
@@ -70,9 +106,9 @@ export function subscribe(email, kriter = {}, ad = null) {
|
||||
if (!db) throw new Error('Bildirim deposu kullanılamıyor');
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, konular, aktif, olusturma)
|
||||
VALUES (?,?,?,?,?,?,?,?,1,?)
|
||||
ON CONFLICT(email, sehir, sektor, alim_turu, anahtar) DO UPDATE SET
|
||||
INSERT INTO abonelikler (email, ad, sehir, sektor, alim_turu, anahtar, kurumlar, konular, aktif, olusturma, criteria_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,1,?,?)
|
||||
ON CONFLICT(email, criteria_key) DO UPDATE SET
|
||||
aktif=1, kurumlar=excluded.kurumlar, konular=excluded.konular, ad=excluded.ad
|
||||
`).run(
|
||||
email,
|
||||
@@ -81,9 +117,10 @@ export function subscribe(email, kriter = {}, ad = null) {
|
||||
bosaNull(kriter.sektor),
|
||||
bosaNull(kriter.alimTuru),
|
||||
bosaNull(kriter.anahtar),
|
||||
JSON.stringify(kriter.kurumlar || []),
|
||||
JSON.stringify(kriter.konular || []),
|
||||
new Date().toISOString()
|
||||
JSON.stringify((kriter.kurumlar || []).map(value=>value.trim())),
|
||||
JSON.stringify((kriter.konular || []).map(value=>value.trim())),
|
||||
new Date().toISOString(),
|
||||
subscriptionKey(kriter)
|
||||
);
|
||||
return list(email);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
const moduleUrl=new URL('../server/notify-store.mjs',import.meta.url).href;
|
||||
const schema=`CREATE TABLE abonelikler(id INTEGER PRIMARY KEY AUTOINCREMENT,email TEXT NOT NULL,ad TEXT,sehir TEXT,sektor TEXT,alim_turu TEXT,anahtar TEXT,kurumlar TEXT,konular TEXT,aktif INTEGER NOT NULL DEFAULT 1,sonGonderim TEXT,sonGorulenler TEXT,olusturma TEXT,UNIQUE(email,sehir,sektor,alim_turu,anahtar));CREATE INDEX idx_abone_email ON abonelikler(email);`;
|
||||
for(const malformed of [false,true])test(`subscription migration ${malformed?'rolls back invalid legacy data':'preserves ids delivery history duplicates and sequence'}`,()=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'ihaleist-subscription-migrate-')),file=join(dir,'notify.sqlite');
|
||||
try {
|
||||
let db=new DatabaseSync(file);db.exec(schema);
|
||||
const insert=db.prepare('INSERT INTO abonelikler(id,email,ad,sehir,sektor,alim_turu,anahtar,kurumlar,konular,sonGonderim,sonGorulenler,olusturma) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)');
|
||||
insert.run(7,'owner','Name','Ankara','','','','[]',malformed?'invalid':'["Laboratuvar"]','2026-09-01','["seen-1"]','2026-08-01');
|
||||
insert.run(8,'owner','Second','ANKARA','','','','[]','["laboratuvar"]','2026-09-02','["seen-2"]','2026-08-02');
|
||||
db.exec("UPDATE sqlite_sequence SET seq=99 WHERE name='abonelikler'");db.close();
|
||||
const output=JSON.parse(execFileSync(process.execPath,['--input-type=module','-e',`
|
||||
const store=await import(${JSON.stringify(moduleUrl)});
|
||||
try {const old=store.list('owner');const rows=store.subscribe('owner',{sehir:'Ankara',konular:['Cihaz']});console.log(JSON.stringify({old,rows}));}
|
||||
catch(error){console.log(JSON.stringify({error:error.message}));}
|
||||
`],{env:{...process.env,IHALEIST_USER_DATA_DIR:dir},encoding:'utf8',timeout:10000,stdio:['ignore','pipe','pipe']}));
|
||||
db=new DatabaseSync(file);
|
||||
try {
|
||||
assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check,'ok');
|
||||
const columns=db.prepare('PRAGMA table_info(abonelikler)').all().map(row=>row.name);
|
||||
if(malformed){assert.ok(output.error);assert.equal(columns.includes('criteria_key'),false);assert.equal(db.prepare('SELECT count(*) n FROM abonelikler').get().n,2);}
|
||||
else {
|
||||
assert.equal(output.error,undefined);assert.equal(output.old.length,2);
|
||||
assert.deepEqual(output.old.find(row=>row.id===7).sonGorulenler,['seen-1']);assert.equal(output.old.find(row=>row.id===8).sonGonderim,'2026-09-02');
|
||||
assert.equal(output.rows.length,3);assert.equal(output.rows[0].id,100);
|
||||
assert.equal(db.prepare("SELECT count(*) n FROM sqlite_master WHERE name='idx_abone_email' AND tbl_name='abonelikler'").get().n,1);
|
||||
}
|
||||
}finally {db.close();}
|
||||
}finally {rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -16,3 +16,13 @@ test('subscription writes validate criteria and deletion is account scoped in th
|
||||
assert.equal(statSync(join(dir,'notify.sqlite')).mode&0o777,0o600);
|
||||
assert.equal(subscriptions.remove(rows[0].id,'owner'),true);assert.deepEqual(subscriptions.list('owner'),[]);
|
||||
});
|
||||
|
||||
test('distinct topic and institution sets coexist while equivalent subscriptions retain delivery history',()=>{
|
||||
const one=subscriptions.subscribe('sets',{konular:['Laboratuvar','TEST'],kurumlar:['Hastane A']})[0];
|
||||
subscriptions.markSent(one.id,['tender-1']);
|
||||
subscriptions.subscribe('sets',{konular:['test',' laboratuvar '],kurumlar:['hastane a','Hastane A']});
|
||||
let rows=subscriptions.list('sets');assert.equal(rows.length,1);assert.equal(rows[0].id,one.id);assert.deepEqual(rows[0].sonGorulenler,['tender-1']);
|
||||
subscriptions.subscribe('sets',{konular:['Cihaz'],kurumlar:['Hastane A']});
|
||||
subscriptions.subscribe('sets',{konular:['Laboratuvar','TEST'],kurumlar:['Hastane B']});
|
||||
rows=subscriptions.list('sets');assert.equal(rows.length,3);assert.equal(rows.find(row=>row.id===one.id).sonGorulenler[0],'tender-1');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user