Require explicit persisted acknowledgement of notifications

This commit is contained in:
2026-09-06 12:52:14 +03:00
parent c7bb67b101
commit 458f638b81
7 changed files with 89 additions and 23 deletions
+7
View File
@@ -767,3 +767,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Tek başına CR karakteri de ICS metninde kaçırılıyor; ek VEVENT enjeksiyonu testi eklendi. - 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. - 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. - 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.
### Bildirim okundu akışı — 6 Eylül 2026
- Bulgu: Header'daki “Tümünü Okundu İşaretle” yalnız span idi. Arka plan sorgusu ilk beş bildirimi geçici listeye ekleyip alınan bütün okunmamış kayıtları kullanıcı görmeden okundu yapıyordu; sonraki oturumda kayıtlara erişilemiyordu.
- Sunucu bildirimleri kararlı kimlikleriyle, okunma durumu korunarak listeleniyor. Listeyi almak okundu bilgisi yazmıyor. Menü açıldığında ve 5 dakikalık aralıkla yenileniyor; hatalar görünür ve tekrar yükleme düğmesi var. Son 50 sunucu kaydı kapsamı korunur.
- Gerçek okundu düğmesi sunucu yanıtından sonra yakalanan bildirim kümesini günceller; hata/kısmi yanıtta yerel durum değişmez. İşlem sırasında gelen yeni kayıtlar yanlışlıkla işaretlenmez. Hesap değişimi ve geç eski okuma yanıtları atılır.
- Sunucu okundu işlemi liste tip/boyut doğrulamasıyla tek transaction'da çalışır; yabancı hesap kayıtlarına erişemez. Desteklenmeyen HTTP metotları 405 döner; depoya erişilememesi boş liste gibi gösterilmez.
- 173 test ve üretim derlemesi başarılı. Yeni testler kimliklerin/okunmamış durumun korunmasını, kayıt hatası/kısmi yanıtı ve işlem kümesini doğrular. Menü hata→tekrar deneme ve sayfa yenileme sonrası okundu davranışının tarayıcı kabulü henüz yapılmadı. Dış push/e-posta teslimi bu testlerin kapsamı dışındadır.
+1
View File
@@ -929,6 +929,7 @@ const server = http.createServer(async (req, res) => {
const body = await readBody(req); const body = await readBody(req);
return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) }); return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) });
} }
if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) }); return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) });
} }
if (url.pathname === '/api/health/takip') { if (url.pathname === '/api/health/takip') {
+7 -3
View File
@@ -80,13 +80,17 @@ export function bildirimYaz({ email, tur, ikn, baslik, metin }) {
return id; return id;
} }
export function bildirimleriOku(email, { limit = 50 } = {}) { export function bildirimleriOku(email, { limit = 50 } = {}) {
const d = open(); if (!d) return []; const d = open(); if (!d) throw new Error('Bildirim deposuna erişilemiyor.');
return d.prepare('SELECT * FROM bildirimler WHERE email=? ORDER BY zaman DESC LIMIT ?').all(email, limit); return d.prepare('SELECT * FROM bildirimler WHERE email=? ORDER BY zaman DESC LIMIT ?').all(email, limit);
} }
export function okunduIsaretle(email, idler) { export function okunduIsaretle(email, idler) {
const d = open(); if (!d || !idler?.length) return 0; if(!Array.isArray(idler)||idler.length>200||idler.some(id=>typeof id!=='string'||!id||id.length>100))throw new Error('Geçersiz bildirim kimlikleri.');
const d = open(); if (!d)throw new Error('Bildirim deposuna erişilemiyor.');
if(!idler.length)return 0;
const st = d.prepare('UPDATE bildirimler SET okundu=1 WHERE email=? AND id=?'); const st = d.prepare('UPDATE bildirimler SET okundu=1 WHERE email=? AND id=?');
let n = 0; for (const id of idler) n += st.run(email, id).changes; return n; d.exec('BEGIN IMMEDIATE');
try {let n=0;for(const id of new Set(idler))n+=st.run(email,id).changes;d.exec('COMMIT');return n;}
catch(error){d.exec('ROLLBACK');throw error;}
} }
async function gonder(email, baslik, metinHtml, metinDuz) { async function gonder(email, baslik, metinHtml, metinDuz) {
+6 -3
View File
@@ -12,6 +12,7 @@ export const Header = () => {
setIsAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
userProfile, userProfile,
notifications, notifications,
refreshNotifications,markNotificationsRead,notificationError,notificationBusy,
setActiveTab, setActiveTab,
clearAllFilters, clearAllFilters,
isRefreshingLive, isRefreshingLive,
@@ -144,7 +145,7 @@ export const Header = () => {
<div className="relative"> <div className="relative">
<button <button
aria-label="Bildirimler" aria-label="Bildirimler"
onClick={() => setShowNotifications(!showNotifications)} onClick={() => {setShowNotifications(!showNotifications);if(!showNotifications)void refreshNotifications();}}
className="relative p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-lg transition-colors" className="relative p-2 text-slate-300 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
> >
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
@@ -157,13 +158,15 @@ export const Header = () => {
<div className="absolute right-0 mt-2 w-80 bg-white text-slate-800 rounded-xl shadow-xl border border-slate-200 py-2 z-50 animate-fade-in"> <div className="absolute right-0 mt-2 w-80 bg-white text-slate-800 rounded-xl shadow-xl border border-slate-200 py-2 z-50 animate-fade-in">
<div className="px-4 py-2 border-b border-slate-100 flex items-center justify-between"> <div className="px-4 py-2 border-b border-slate-100 flex items-center justify-between">
<h4 className="font-bold text-sm">Bildirimler</h4> <h4 className="font-bold text-sm">Bildirimler</h4>
<span className="text-xs text-blue-600 cursor-pointer hover:underline">Tümünü Okundu İşaretle</span> <button disabled={notificationBusy || !notifications.some(n=>!n.read)} onClick={markNotificationsRead} className="text-xs text-blue-600 hover:underline disabled:opacity-50">Tümünü Okundu İşaretle</button>
</div> </div>
{notificationError&&<p role="alert" className="px-3 py-2 text-xs text-red-700">{notificationError} <button className="underline" onClick={refreshNotifications}>Bildirimleri yeniden yükle</button></p>}
{notificationBusy&&<p role="status" className="px-3 text-xs">Okundu bilgisi kaydediliyor…</p>}
<div className="max-h-64 overflow-y-auto divide-y divide-slate-100"> <div className="max-h-64 overflow-y-auto divide-y divide-slate-100">
{notifications.map(n => ( {notifications.map(n => (
<div key={n.id} className="p-3 hover:bg-slate-50 transition-colors cursor-pointer text-xs"> <div key={n.id} className="p-3 hover:bg-slate-50 transition-colors cursor-pointer text-xs">
<div className="font-semibold text-slate-900 flex justify-between"> <div className="font-semibold text-slate-900 flex justify-between">
<span>{n.title}</span> <span>{!n.read&&<span className="text-blue-600 mr-1">Okunmadı ·</span>}{n.title}</span>
<span className="text-slate-400 font-normal text-[10px]">{n.time}</span> <span className="text-slate-400 font-normal text-[10px]">{n.time}</span>
</div> </div>
<p className="text-slate-600 mt-1 leading-relaxed">{n.text}</p> <p className="text-slate-600 mt-1 leading-relaxed">{n.text}</p>
+38 -17
View File
@@ -1,3 +1,4 @@
import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications';
import { searchTender } from '../services/searchTender'; import { searchTender } from '../services/searchTender';
import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch';
import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react';
@@ -159,6 +160,7 @@ export const AppProvider = ({ children }) => {
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => { useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus(''); aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
setVeriSenkron(false); setPlan(null); setSaveError(null); setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
@@ -564,11 +566,17 @@ export const AppProvider = ({ children }) => {
}; };
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [notificationError,setNotificationError]=useState(null);
const [notificationBusy,setNotificationBusy]=useState(false);
const notificationVersion=useRef(0),notificationMutation=useRef(null),seenServerNotifications=useRef(new Set());
// Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı // Gerçek bildirim: listeye ekler; sessiz saat dışında ve push açıksa tarayıcı
// bildirimi (Notification API) da gösterir. // bildirimi (Notification API) da gösterir.
const notify = (title, text) => { const notify = (title, text) => {
setNotifications((prev) => [{ id: 'n-' + Date.now(), title, text, time: 'az önce', read: false }, ...prev].slice(0, 30)); setNotifications((prev) => [{ id: 'n-' + Date.now(), title, text, time: 'az önce', read: false }, ...prev].slice(0, 30));
pushNotification(title,text);
};
const pushNotification=(title,text)=>{
if (isQuietNow() || !reminderSettings.pushEnabled) return; if (isQuietNow() || !reminderSettings.pushEnabled) return;
try { try {
if ('Notification' in window && Notification.permission === 'granted') { if ('Notification' in window && Notification.permission === 'granted') {
@@ -581,7 +589,31 @@ export const AppProvider = ({ children }) => {
if (Notification.permission === 'granted') return 'granted'; if (Notification.permission === 'granted') return 'granted';
try { return await Notification.requestPermission(); } catch { return 'denied'; } try { return await Notification.requestPermission(); } catch { return 'denied'; }
}; };
const markNotificationsRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); const refreshNotifications=async()=>{
const session=getToken(),version=++notificationVersion.current;
setNotificationError(null);
try {
const rows=await bildirimleriGetir();
if(getToken()!==session||version!==notificationVersion.current)return;
const checked=mergeServerNotifications([],rows);
const fresh=checked.filter(row=>!row.read&&!seenServerNotifications.current.has(row.id));
checked.forEach(row=>seenServerNotifications.current.add(row.id));
fresh.slice(0,5).forEach(row=>pushNotification(row.title,row.text));
setNotifications(current=>[...checked,...current.filter(row=>!row.serverId)].slice(0,80));
}catch(error){if(getToken()===session&&version===notificationVersion.current)setNotificationError(error.message);}
};
const markNotificationsRead=async()=>{
if(notificationMutation.current)return;
const operation={session:getToken()};notificationMutation.current=operation;
notificationVersion.current++;setNotificationBusy(true);setNotificationError(null);
try {
const ids=await acknowledgeNotifications(notifications,bildirimOkundu);
if(getToken()!==operation.session||notificationMutation.current!==operation)return;
notificationVersion.current++;
setNotifications(current=>current.map(row=>ids.has(row.id)?{...row,read:true}:row));
}catch(error){if(getToken()===operation.session&&notificationMutation.current===operation)setNotificationError(error.message);}
finally {if(notificationMutation.current===operation){notificationMutation.current=null;setNotificationBusy(false);}}
};
// Persist state to localstorage // Persist state to localstorage
// Havuz on binlerce kayda çıkabiliyor; tamamı localStorage kotasını (≈5 MB) // Havuz on binlerce kayda çıkabiliyor; tamamı localStorage kotasını (≈5 MB)
@@ -645,22 +677,10 @@ export const AppProvider = ({ children }) => {
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster // Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
useEffect(() => { useEffect(() => {
if (!veriSenkron) return; if (!veriSenkron) return;
let iptal = false; void refreshNotifications();
const session = getToken(); const timer=setInterval(refreshNotifications,5*60*1000);
const cek = async () => { return()=>{notificationVersion.current++;clearInterval(timer);};
try { }, [veriSenkron,authUser?.email]);
const liste = await bildirimleriGetir();
const yeni = liste.filter((b) => !b.okundu);
if (iptal || getToken() !== session || !yeni.length) return;
for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin);
await bildirimOkundu(yeni.map((b) => b.id));
} catch { /* sessiz */ }
};
cek();
const z = setInterval(cek, 5 * 60 * 1000);
return () => { iptal = true; clearInterval(z); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [veriSenkron]);
// Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak // Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak
// belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar // belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar
@@ -807,6 +827,7 @@ export const AppProvider = ({ children }) => {
notify, notify,
requestPushPermission, requestPushPermission,
markNotificationsRead, markNotificationsRead,
refreshNotifications,notificationError,notificationBusy,
isQuietNow, isQuietNow,
favoriteKurumlar, favoriteKurumlar,
toggleFavoriteKurum, toggleFavoriteKurum,
+11
View File
@@ -0,0 +1,11 @@
export function mergeServerNotifications(current,rows) {
if(!Array.isArray(rows)||rows.some(row=>typeof row?.id!=='string'||typeof row.baslik!=='string'||typeof row.metin!=='string'))throw new Error('Bildirim listesi geçersiz.');
const remote=rows.map(row=>({id:'server:'+row.id,serverId:row.id,title:row.baslik,text:row.metin,
time:new Date(row.zaman).toLocaleString('tr-TR'),read:!!row.okundu,ikn:row.ikn}));
return [...remote,...current.filter(row=>!row.serverId)].slice(0,80);
}
export async function acknowledgeNotifications(snapshot,request) {
const unread=snapshot.filter(row=>!row.read),ids=unread.filter(row=>row.serverId).map(row=>row.serverId);
if(ids.length){const result=await request(ids);if(result?.success!==true||result.okundu!==ids.length)throw new Error('Bazı bildirimler kaydedilemedi. Listeyi yenileyip tekrar deneyin.');}
return new Set(unread.map(row=>row.id));
}
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mergeServerNotifications,acknowledgeNotifications} from '../src/services/notifications.js';
const rows=[{id:'one',baslik:'Tarih değişti',metin:'Yeni tarih',okundu:0,zaman:'2026-09-06T10:00:00Z'}];
test('refresh keeps stable notification identities and server unread state without acknowledging delivery',()=>{
const once=mergeServerNotifications([{id:'local',title:'Yerel',read:false}],rows);
const twice=mergeServerNotifications(once,rows);
assert.deepEqual(twice,once);assert.equal(twice[0].read,false);assert.equal(twice.length,2);
assert.equal(mergeServerNotifications(twice,[{...rows[0],okundu:1}])[0].read,true);
assert.throws(()=>mergeServerNotifications([],{}),/geçersiz/);
});
test('read acknowledgement requires server success and only covers the captured unread snapshot',async()=>{
const current=mergeServerNotifications([{id:'local',read:false}],rows);
await assert.rejects(acknowledgeNotifications(current,async()=>{throw new Error('depo kapalı');}),/depo kapalı/);
assert.equal(current[0].read,false);
await assert.rejects(acknowledgeNotifications(current,async()=>({success:true,okundu:0})),/kaydedilemedi/);
const acknowledged=await acknowledgeNotifications(current,async ids=>{assert.deepEqual(ids,['one']);return {success:true,okundu:1};});
assert.deepEqual([...acknowledged],['server:one','local']);assert.equal(acknowledged.has('arrived-later'),false);
});