From 458f638b81eba15f682a6d01885d8235393e01ac Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 12:52:14 +0300 Subject: [PATCH] Require explicit persisted acknowledgement of notifications --- docs/quality/uygulama-durumu.md | 7 ++++ server/index.js | 1 + server/zeyilname.mjs | 10 ++++-- src/components/Header.jsx | 9 +++-- src/context/AppContext.jsx | 55 ++++++++++++++++++++--------- src/services/notifications.js | 11 ++++++ tests/notifications-client.test.mjs | 19 ++++++++++ 7 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 src/services/notifications.js create mode 100644 tests/notifications-client.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index d1ab701..2e04196 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. - 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. + +### 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. diff --git a/server/index.js b/server/index.js index 6fd59cd..be7893c 100644 --- a/server/index.js +++ b/server/index.js @@ -929,6 +929,7 @@ const server = http.createServer(async (req, res) => { const body = await readBody(req); 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) }); } if (url.pathname === '/api/health/takip') { diff --git a/server/zeyilname.mjs b/server/zeyilname.mjs index 80f204e..75f83b6 100644 --- a/server/zeyilname.mjs +++ b/server/zeyilname.mjs @@ -80,13 +80,17 @@ export function bildirimYaz({ email, tur, ikn, baslik, metin }) { return id; } 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); } 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=?'); - 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) { diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 46a9df5..913cbd1 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -12,6 +12,7 @@ export const Header = () => { setIsAiSpecAnalyzerOpen, userProfile, notifications, + refreshNotifications,markNotificationsRead,notificationError,notificationBusy, setActiveTab, clearAllFilters, isRefreshingLive, @@ -144,7 +145,7 @@ export const Header = () => {
+ {notificationError&&

{notificationError}

} + {notificationBusy&&

Okundu bilgisi kaydediliyor…

}
{notifications.map(n => (
- {n.title} + {!n.read&&Okunmadı ·}{n.title} {n.time}

{n.text}

diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 28450a2..21d3f3d 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,3 +1,4 @@ +import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications'; import { searchTender } from '../services/searchTender'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; 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) useEffect(() => { + notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false); aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus(''); setVeriSenkron(false); setPlan(null); setSaveError(null); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); @@ -564,11 +566,17 @@ export const AppProvider = ({ children }) => { }; 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ı // bildirimi (Notification API) da gösterir. const notify = (title, text) => { 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; try { if ('Notification' in window && Notification.permission === 'granted') { @@ -581,7 +589,31 @@ export const AppProvider = ({ children }) => { if (Notification.permission === 'granted') return 'granted'; 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&¬ificationMutation.current===operation)setNotificationError(error.message);} + finally {if(notificationMutation.current===operation){notificationMutation.current=null;setNotificationBusy(false);}} + }; // Persist state to localstorage // 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 useEffect(() => { if (!veriSenkron) return; - let iptal = false; - const session = getToken(); - const cek = async () => { - try { - 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]); + void refreshNotifications(); + const timer=setInterval(refreshNotifications,5*60*1000); + return()=>{notificationVersion.current++;clearInterval(timer);}; + }, [veriSenkron,authUser?.email]); // Ş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 @@ -807,6 +827,7 @@ export const AppProvider = ({ children }) => { notify, requestPushPermission, markNotificationsRead, + refreshNotifications,notificationError,notificationBusy, isQuietNow, favoriteKurumlar, toggleFavoriteKurum, diff --git a/src/services/notifications.js b/src/services/notifications.js new file mode 100644 index 0000000..5bc2d05 --- /dev/null +++ b/src/services/notifications.js @@ -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)); +} diff --git a/tests/notifications-client.test.mjs b/tests/notifications-client.test.mjs new file mode 100644 index 0000000..9c2144d --- /dev/null +++ b/tests/notifications-client.test.mjs @@ -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); +});