fix: persist notification preferences and clarify delivery controls

This commit is contained in:
2026-09-06 14:14:46 +03:00
parent 83df706631
commit a25dcb7a5d
8 changed files with 85 additions and 35 deletions
+6
View File
@@ -1023,3 +1023,9 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Sunucuda rakip özel bildirim tüketicisi bulunmadığı doğrulandı. Otomatik bildirimler aktif metni, proaktif uyarı vaadi ve navigasyondaki Canlı Uyar rozeti kaldırıldı. Bildirim kontrolleri etkin değil olarak gösterilir; dış mesaj gönderilmez. Yasaklı sorgu bağlantısı da doğrulanmamış pano-kopyalandı iddiası taşımaz.
- 230 otomatik test ve derleme geçti. Rakip API sürüm zorunluluğu, eski sürüm, bozuk/yinelenen liste, kayıt korunması ve kaldırma test edildi. İzole tarayıcıda 7 kontrol geçti: kayıt, üç ölçüde Veri yok, bildirimlerin pasif olması, yenileme kalıcılığı, yanıltıcı rozetin kalkması, yinelenen ekleme engeli, çıkarma. Kanıt qa-radar-persistence.json.
- Gerçek kazanan kaynaklarıyla eşleşmenin ekonomik doğruluğu, rakip özel bildirim özelliğinin uygulanması ve iki sekmeli rakip UI çakışması bu kabulde tamamlanmadı. Bu değişiklik çalışmayan bildirim özelliğini çalışıyor göstermez. QA sekmesi ve yeni süreç kapatıldı; canlıya yayın yapılmadı.
### Tarayıcı bildirimi ve sessiz saat kayıtları — 6 Eylül 2026
- reminder ayarı iyimser otomatik kayıttan sürümlü, sunucu onaylı kayda geçirildi. Beklerken ayar kontrolleri kilitlenir; hata görünür, etkin ayar başarısız yazımla değişmez. Sunucu boolean seçimleri ve HH:mm saatlerini doğrular; eski sekme güncel ayarı ezmez.
- Sessiz saat hesabı ortak fonksiyona taşındı. Gece yarısı, başlangıç dahil/bitiş hariç ve eşit başlangıç-bitiş davranışı doğrulandı. Saatlerin cihazın yerel saatine göre olduğu açıkça yazıldı. Geciken bildirim callback'leri güncel ayar ref'ini okur; eski sessiz saat/pushEnabled closure'ı kullanılmaz.
- Gerçek e-posta gönderiminde kullanılmayan E-posta bildirimleri açık kutusu kaldırıldı; kullanıcı gerçek bildirim aboneliklerine yönlendirilir. Tarayıcı/sessiz saat seçiminin e-posta aboneliğini kapatmadığı belirtilir. Tarayıcı ayarında açık uygulama bildirimi ve kapalı uygulamaya arka plan push teslimi ayrımı yapıldı; bir gösterim denemesi kesin teslim olarak sunulmaz.
- 233 otomatik test ve derleme geçti. Saat sınırları, geçersiz ayarlar, eski sürümle yazım ve mevcut ayarın korunması test edildi. Gerçek tarayıcı izin penceresi, yeni ayarların tarayıcı kalıcılığı ve iki sekmeli UI çatışması açık. E-posta yapılandırma/test gönderimi HTTP hata davranışı ayrıca incelenmeli. Dış e-posta veya bildirim gönderilmedi; canlıya yayın yapılmadı.
+2
View File
@@ -81,3 +81,5 @@ Ekip kaydı (`ekip`) da aynı içerik sürümü sözleşmesine dahil edildi. Yen
Favori kurumlar (`favori_kurumlar`) da sürümlü kullanıcı verisi yazımı ister. Eski istemcinin sürümsüz yazımı 428 döner; frontend/backend birlikte sürümlenmelidir. Bekleyen değişiklikler sunucudan onay gelmeden favori listesinde gösterilmez.
Rakip izleme (`rakip_izleme`) da içerik sürümü zorunlu anahtarlara dahil edildi. Eski istemcilerde sürümsüz yazım 428 alır; güncel frontend ile aynı sürüm backend kullanılmalıdır. Eski rakip kayıtlarındaki ölçüler yeni arayüzde güncel havuz eşleşmesi yerine kullanılmaz; kayıtlı firma adı/sektör korunur.
Bildirim tercihleri (`reminder`) sürümlü yazım sözleşmesine dahil edildi. Saat alanları HH:mm, pushEnabled/quietEnabled alanları boolean olmalıdır. Frontend/backend birlikte güncellenmelidir. Eski e-postaEnabled kaydı e-posta aboneliği iptali anlamına gelmez; gönderimler abonelik yönetiminden denetlenir.
+3 -1
View File
@@ -1,3 +1,4 @@
import {validateReminderPreferences} from '../src/services/reminderPreferences.js';
import {validateTeam} from '../src/services/teamData.js';
import { executeAnalysisJob } from './analysis-job.mjs';
import { resolveAiJobInput } from './ai-job-source.mjs';
@@ -1211,7 +1212,8 @@ const server = http.createServer(async (req, res) => {
return json(res,400,{success:false,error:'Favori kurum listesi geçersiz veya yinelenen kurum içeriyor.'});
if(k==='rakip_izleme' && (!Array.isArray(body.deger) || body.deger.some(item=>!item || typeof item.id!=='string'||!item.id||typeof item.name!=='string'||!item.name.trim()||item.name.length>1000||(item.sector!=null&&typeof item.sector!=='string')) || new Set(body.deger.map(item=>item.id)).size!==body.deger.length || new Set(body.deger.map(item=>item.name.trim().toLocaleLowerCase('tr-TR'))).size!==body.deger.length))
return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'});
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme'].includes(k);
if(k==='reminder')validateReminderPreferences(body.deger);
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder'].includes(k);
if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision)))))
return json(res,428,{success:false,kod:'VERSION_REQUIRED',error:'Güncel kayıt sürümü gerekli. Sayfayı yenileyip tekrar deneyin.'});
const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined);
+16 -18
View File
@@ -2,12 +2,11 @@ import {DeliveryQueue} from './DeliveryQueue';
import React, { useState, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { Smartphone, Wifi, WifiOff, Download, Bell, CheckCircle2, Moon, Mail, Send } from 'lucide-react';
import confetti from 'canvas-confetti';
import { getToken } from '../../services/auth';
import { NotifySubscriptions } from './NotifySubscriptions';
export const PwaSettings = () => {
const { userProfile, authUser, tenders, reminderSettings, setReminderSettings, requestPushPermission, notify, isQuietNow } = useApp();
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
const [onbellekAdi, setOnbellekAdi] = useState('');
@@ -73,12 +72,10 @@ export const PwaSettings = () => {
const p = await requestPushPermission();
setPermission(p);
if (p === 'granted') {
setReminderSettings((s) => ({ ...s, pushEnabled: true }));
notify('Bildirimler açıldı', 'Yeni ihale ve son tarih uyarıları buradan gelecek.');
confetti({ particleCount: 50 });
await saveReminderSettings((s) => ({ ...s, pushEnabled: true }));
}
};
const setQuiet = (patch) => setReminderSettings((s) => ({ ...s, ...patch }));
const setQuiet = (patch) => saveReminderSettings((s) => ({ ...s, ...patch }));
// Gerçek PWA kurulumu: tarayıcı beforeinstallprompt verirse yerel diyalog;
// zaten standalone açıldıysa "yüklü"; iOS Safari'de olay yok → yol tarifi.
@@ -173,36 +170,42 @@ export const PwaSettings = () => {
</div>
<p className="text-xs text-slate-600 leading-relaxed">
Tarayıcı bildirimlerine izin verin; yeni ihale ve son tarih uyarıları gerçek bildirim olarak gelir.
Bu uygulama açıkken alınan uyarıları tarayıcı bildirimi olarak gösterebilirsiniz. Kapalı uygulamaya arka plan push teslimi sağlanmaz.
</p>
{permission !== 'granted' ? (
<button onClick={handleEnablePush} disabled={permission === 'unsupported'}
<button onClick={handleEnablePush} disabled={reminderSaving || permission === 'unsupported'}
className="w-full bg-amber-500 hover:bg-amber-400 disabled:opacity-40 text-white font-bold text-xs py-2.5 rounded-xl">
Bildirimlere İzin Ver
</button>
) : (
<button onClick={() => notify('Test bildirimi', isQuietNow() ? 'Sessiz saatte — sadece listeye eklendi.' : 'Bildirimler çalışıyor ✓')}
<button onClick={() => notify('Test bildirimi', isQuietNow() || !reminderSettings.pushEnabled ? 'Tarayıcı bildirimi kapalı veya sessiz saatte — listeye eklendi.' : 'Tarayıcı bildirimi gösterim denemesi.')}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2.5 rounded-xl">
Test Bildirimi Gönder
</button>
)}
{reminderError&&<p role="alert" className="text-xs text-red-700">{reminderError}</p>}
{reminderSaving&&<p role="status" className="text-xs">Bildirim ayarları kaydediliyor…</p>}
<label className="flex justify-between text-xs font-bold">Tarayıcı bildirimlerini göster
<input type="checkbox" checked={reminderSettings.pushEnabled} disabled={reminderSaving} onChange={e=>setQuiet({pushEnabled:e.target.checked})}/>
</label>
{/* Sessiz saatler — gerçek */}
<div className="pt-2 border-t border-slate-100 space-y-2">
<p className="text-xs text-slate-500">Saatler bu cihazın yerel saatine göredir. Başlangıç ve bitiş aynıysa sessiz süre uygulanmaz.</p>
<label className="flex items-center justify-between text-xs font-bold text-slate-800 cursor-pointer">
<span className="flex items-center gap-1.5"><Moon className="w-3.5 h-3.5 text-indigo-500" /> Sessiz Saatler</span>
<input type="checkbox" checked={reminderSettings.quietEnabled}
<input type="checkbox" disabled={reminderSaving} checked={reminderSettings.quietEnabled}
onChange={(e) => setQuiet({ quietEnabled: e.target.checked })}
className="w-4 h-4 accent-indigo-600 rounded" />
</label>
{reminderSettings.quietEnabled && (
<div className="flex items-center gap-2 text-xs">
<input type="time" value={reminderSettings.quietStart}
<input type="time" aria-label="Sessiz saat başlangıcı" disabled={reminderSaving} value={reminderSettings.quietStart}
onChange={(e) => setQuiet({ quietStart: e.target.value })}
className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" />
<span className="text-slate-400">→</span>
<input type="time" value={reminderSettings.quietEnd}
<input type="time" aria-label="Sessiz saat bitişi" disabled={reminderSaving} value={reminderSettings.quietEnd}
onChange={(e) => setQuiet({ quietEnd: e.target.value })}
className="bg-slate-50 border border-slate-300 rounded-lg px-2 py-1.5 flex-1" />
</div>
@@ -234,12 +237,7 @@ export const PwaSettings = () => {
: 'Takip kriterlerinize uyan ihaleler, hesabınızın e-posta adresine özet olarak gönderilir. Aşağıdan kendinize test gönderebilirsiniz.'}
</p>
<label className="flex items-center justify-between text-xs font-bold text-slate-800 cursor-pointer">
<span>E-posta bildirimleri açık</span>
<input type="checkbox" checked={reminderSettings.emailEnabled}
onChange={(e) => setReminderSettings((s) => ({ ...s, emailEnabled: e.target.checked }))}
className="w-4 h-4 accent-blue-600 rounded" />
</label>
<p className="text-xs text-slate-600">E-posta gönderimlerini aşağıdaki bildirim aboneliklerinden yönetin. Tarayıcı bildirim seçimi ve sessiz saatler e-posta aboneliklerini kapatmaz.</p>
<button
onClick={sendTestEmail}
+19 -16
View File
@@ -1,3 +1,4 @@
import {validateReminderPreferences,quietNow} from '../services/reminderPreferences';
import {validateTeam} from '../services/teamData';
import {savedFilterCriteria} from '../services/savedFilterCriteria';
import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications';
@@ -166,7 +167,7 @@ export const AppProvider = ({ children }) => {
useEffect(() => {
notificationVersion.current++;notificationMutation.current=null;seenServerNotifications.current.clear();setNotificationError(null);setNotificationBusy(false);
aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
dataRevisions.current={};setTeamError(null);setCompetitorError(null);
dataRevisions.current={};setTeamError(null);setCompetitorError(null);setReminderError(null);
setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -409,20 +410,22 @@ export const AppProvider = ({ children }) => {
const [vaultDocuments, setVaultDocuments] = useState([]);
const [reminderSettings, setReminderSettings] = useState(defaultReminder);
useEffect(() => {
if (veriSenkron) ilkYazmaDegil('reminder', reminderSettings);
}, [reminderSettings, veriSenkron]);
// Şu an sessiz saat aralığında mıyız (gece yarısını geçen aralıkları da destekler)
const isQuietNow = () => {
if (!reminderSettings.quietEnabled) return false;
const now = new Date();
const cur = now.getHours() * 60 + now.getMinutes();
const [sh, sm] = reminderSettings.quietStart.split(':').map(Number);
const [eh, em] = reminderSettings.quietEnd.split(':').map(Number);
const start = sh * 60 + sm, end = eh * 60 + em;
return start <= end ? cur >= start && cur < end : cur >= start || cur < end;
const reminderSaveLock=useRef(false),reminderCurrent=useRef(reminderSettings);
reminderCurrent.current=reminderSettings;
const [reminderSaving,setReminderSaving]=useState(false),[reminderError,setReminderError]=useState(null);
const saveReminderSettings=async change=>{
if(!authUser||!veriSenkron){setReminderError('Hesap verileri yüklenmeden bildirim ayarları kaydedilemez.');return false;}
if(reminderSaveLock.current)return false;
const session=getToken();reminderSaveLock.current=true;setReminderSaving(true);setReminderError(null);
try {
const next=validateReminderPreferences(typeof change==='function'?change(reminderCurrent.current):change);
const saved=await setUserData('reminder',next,dataRevisions.current.reminder ?? null);
if(getToken()!==session)return false;
dataRevisions.current.reminder=saved.revision;reminderCurrent.current=next;setReminderSettings(next);return true;
}catch(error){if(getToken()===session)setReminderError(error.message);return false;}
finally{reminderSaveLock.current=false;setReminderSaving(false);}
};
const isQuietNow=()=>quietNow(reminderCurrent.current);
const [webhooksConfig, setWebhooksConfig] = useState({
slackWebhookUrl: '',
@@ -580,7 +583,7 @@ export const AppProvider = ({ children }) => {
pushNotification(title,text);
};
const pushNotification=(title,text)=>{
if (isQuietNow() || !reminderSettings.pushEnabled) return;
if (isQuietNow() || !reminderCurrent.current.pushEnabled) return;
try {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body: text, icon: '/favicon.ico' });
@@ -826,7 +829,7 @@ export const AppProvider = ({ children }) => {
vaultDocuments,
setVaultDocuments,
reminderSettings,
setReminderSettings,
saveReminderSettings,reminderSaving,reminderError,
webhooksConfig,
setWebhooksConfig,
apiKeys,
+13
View File
@@ -0,0 +1,13 @@
export function validateReminderPreferences(value) {
if(!value || typeof value!=='object' || Array.isArray(value))throw new Error('Bildirim ayarları geçersiz.');
for(const key of ['pushEnabled','quietEnabled'])if(typeof value[key]!=='boolean')throw new Error('Bildirim seçimini kontrol edin.');
for(const key of ['quietStart','quietEnd'])if(typeof value[key]!=='string'||!/^([01]\d|2[0-3]):[0-5]\d$/.test(value[key]))throw new Error('Sessiz saatleri saat:dakika biçiminde girin.');
return value;
}
export function quietNow(preferences,now=new Date()) {
if(!preferences?.quietEnabled)return false;
try {validateReminderPreferences(preferences);}catch{return true;}
const minute=text=>Number(text.slice(0,2))*60+Number(text.slice(3));
const start=minute(preferences.quietStart),end=minute(preferences.quietEnd),current=now.getHours()*60+now.getMinutes();
return start<=end ? current>=start&&current<end : current>=start||current<end;
}
+7
View File
@@ -249,3 +249,10 @@ test('competitor watchlist keeps the saved roster when a stale or invalid write
assert.deepEqual((await request('/api/me/data')).data.veri[anahtar],[item]);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:first.data.revision})).status,200);
});
test('notification preferences reject stale writes and malformed quiet times',async()=>{
const anahtar='reminder',deger={pushEnabled:true,quietEnabled:true,quietStart:'22:00',quietEnd:'08:00'};
const first=await request('/api/me/data','PUT',{anahtar,deger,revision:null});assert.equal(first.status,200);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:{...deger,quietStart:'25:00'},revision:first.data.revision})).status,400);
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:{...deger,pushEnabled:false},revision:null})).status,409);
assert.deepEqual((await request('/api/me/data')).data.veri.reminder,deger);
});
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {validateReminderPreferences,quietNow} from '../src/services/reminderPreferences.js';
const prefs={pushEnabled:true,quietEnabled:true,quietStart:'22:00',quietEnd:'08:00'};
const at=(h,m)=>({getHours:()=>h,getMinutes:()=>m});
test('quiet hours handle midnight and boundaries in the device local clock',()=>{
assert.equal(quietNow(prefs,at(22,0)),true);assert.equal(quietNow(prefs,at(7,59)),true);
assert.equal(quietNow(prefs,at(8,0)),false);assert.equal(quietNow(prefs,at(21,59)),false);
assert.equal(quietNow({...prefs,quietStart:'08:00',quietEnd:'12:00'},at(10,0)),true);
assert.equal(quietNow({...prefs,quietStart:'08:00'},at(8,0)),false);
assert.equal(quietNow({...prefs,quietEnabled:false},at(23,0)),false);
});
test('invalid quiet preferences are rejected and cannot silently enable notifications',()=>{
for(const quietStart of ['', '24:00','08:60',null]){
assert.throws(()=>validateReminderPreferences({...prefs,quietStart}));
assert.equal(quietNow({...prefs,quietStart},at(12,0)),true);
}
assert.throws(()=>validateReminderPreferences({...prefs,pushEnabled:'true'}));
});