fix: persist weekly brief preference and expose request failures

This commit is contained in:
2026-09-06 14:20:31 +03:00
parent 9db12a5eae
commit b2a416c908
7 changed files with 92 additions and 57 deletions
+7
View File
@@ -1040,3 +1040,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- İzole tarayıcı testinde push kapatma ve sessiz saat açma kalıcıydı, fakat 21:30 yazılan başlangıç yenilemede 22:00 dönüyordu. time input olayını da dinleyerek görünür değerin kayda aktarılmaması düzeltildi; bitiş alanına aynı düzeltme uygulandı. - İzole tarayıcı testinde push kapatma ve sessiz saat açma kalıcıydı, fakat 21:30 yazılan başlangıç yenilemede 22:00 dönüyordu. time input olayını da dinleyerek görünür değerin kayda aktarılmaması düzeltildi; bitiş alanına aynı düzeltme uygulandı.
- Yenileme sonrası 21:30 başlangıç, 07:45 bitiş, push kapalı ve sessiz saat açık durumları doğrulandı. E-posta yapılandırılmadı durumu, gönderim düğmesinin devre dışı olması ve işlevsiz genel e-posta kutusunun yokluğu da doğrulandı. Yedi kontrol geçti; kanıt qa-reminder-preferences.json önce/sonra görünümü içerir. - Yenileme sonrası 21:30 başlangıç, 07:45 bitiş, push kapalı ve sessiz saat açık durumları doğrulandı. E-posta yapılandırılmadı durumu, gönderim düğmesinin devre dışı olması ve işlevsiz genel e-posta kutusunun yokluğu da doğrulandı. Yedi kontrol geçti; kanıt qa-reminder-preferences.json önce/sonra görünümü içerir.
- Derleme geçti. İş mantığı değişmediğinden önceki 235 test tekrarlanmadı; bu değişiklik doğrudan tarayıcıda doğrulandı. İzin penceresi açılmadı ve gerçek gönderim yapılmadı. E-posta config ağ hatası yeniden deneme ve iki sekme tercih çatışması tarayıcı kabulü açık. Test sekmesi ve yeni QA süreci exit 0 ile kapatıldı. - Derleme geçti. İş mantığı değişmediğinden önceki 235 test tekrarlanmadı; bu değişiklik doğrudan tarayıcıda doğrulandı. İzin penceresi açılmadı ve gerçek gönderim yapılmadı. E-posta config ağ hatası yeniden deneme ve iki sekme tercih çatışması tarayıcı kabulü açık. Test sekmesi ve yeni QA süreci exit 0 ile kapatıldı.
### Haftalık özet tercihinin gerçek kaydı — 6 Eylül 2026
- API testi haftalık özet kaydının 400 döndüğünü ortaya çıkardı: brief anahtarı user-store izinli anahtarlar listesinde yoktu. Hem yazım hem getData okuması için izinli kayda eklendi; mevcut arayüz başarısız HTTP yanıtını kontrol etmediğinden anahtarı değişmiş gibi gösteriyordu.
- Haftalık özet paneli sunucu onayını bekler; ilk yüklemede açık varsayımı göstermez. Süre/oturum/iptal kontrollü istek, tek işlem kilidi, erişilebilir switch etiketi, hata ve ayar yenileme düğmesi eklendi. Eski sekmeden kayıt içerik sürümüyle reddedilir. Geçersiz POST gövdesi özeti yanlışlıkla açamaz.
- Tercih GET isteği artık ihale havuzunun yüklenmesine bağlı değildir; içerik yalnız preview=1 ile Önizle işleminde oluşturulur. Genel kullanıcı veri rotasında brief yazımı da sürüm/boolean kontrolünden geçer. Gönderim metni hizmet ve zamanlayıcı etkinliğine bağlı olduğunu açıklar.
- 236 otomatik test ve derleme geçti. HTTP testi başlangıç tercihi, gövde doğrulama, kapatma/geri okuma, eski sürüm reddi, başka hesabın ayrımı ve güncel sürümle açmayı doğruladı. İlk testteki 400 bu gerçek izinli-anahtar hatasını yakaladı; düzeltme sonrası tüm suite geçti.
- Yeni panelin tarayıcı hata/kurtarma ve önizleme kabulü, zamanlayıcıdan gerçek test teslimi açık. Bu tur dış özet gönderilmedi; canlıya yayın yapılmadı.
+2
View File
@@ -83,3 +83,5 @@ Favori kurumlar (`favori_kurumlar`) da sürümlü kullanıcı verisi yazımı is
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. 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. 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.
Haftalık özet `brief` anahtarı user-store tarafından artık kabul edilir. `/api/me/brief` GET tercih ve revision döndürür; metin/brief önizlemesi için `?preview=1` gerekir. POST boolean kapali ve okunan revision ister. Frontend/backend birlikte güncellenmelidir; eski sürüme dönmek brief kaydını tekrar reddeder ve opt-out tercihini okuyamayabilir.
+18 -7
View File
@@ -1213,7 +1213,8 @@ const server = http.createServer(async (req, res) => {
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)) 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.'}); return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'});
if(k==='reminder')validateReminderPreferences(body.deger); if(k==='reminder')validateReminderPreferences(body.deger);
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder'].includes(k); if(k==='brief'&&typeof body.deger?.kapali!=='boolean')return json(res,400,{success:false,error:'Haftalık özet seçimi geçersiz.'});
const protectedKey=['firma_profil','saved_filters','ekip','favori_kurumlar','rakip_izleme','reminder','brief'].includes(k);
if(protectedKey && (!Object.hasOwn(body,'revision') || (body.revision!==null && (typeof body.revision!=='string' || !/^[a-f0-9]{64}$/.test(body.revision))))) 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.'}); 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); const revision=store.setData(user.email, k, body.deger, protectedKey ? body.revision : undefined);
@@ -1514,15 +1515,25 @@ const server = http.createServer(async (req, res) => {
const user = userFromAuthHeader(req.headers['authorization']); const user = userFromAuthHeader(req.headers['authorization']);
if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
store.ensureUser(user.email, user.name); store.ensureUser(user.email, user.name);
try {
if(req.method==='POST'){ if(req.method==='POST'){
const body = await readBody(req).catch(() => ({})); const body=await readBody(req);
store.setData(user.email, 'brief', { kapali: body.kapali === true }); if(typeof body.kapali!=='boolean')return json(res,400,{success:false,error:'Haftalık özet seçimi geçersiz.'});
return json(res, 200, { success: true, kapali: body.kapali === true }); if(!Object.hasOwn(body,'revision')||(body.revision!==null&&(typeof body.revision!=='string'||!/^[a-f0-9]{64}$/.test(body.revision))))return json(res,428,{success:false,error:'Güncel özet ayarını yükleyin.'});
const revision=store.setData(user.email,'brief',{kapali:body.kapali},body.revision);
return json(res,200,{success:true,kapali:body.kapali,revision});
} }
const out = await tendersPayload(); if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
const b = briefOlustur(user.email, out.tenders); let preview={};
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) }); if(url.searchParams.get('preview')==='1'){
const out=await tendersPayload(),b=briefOlustur(user.email,out.tenders);
preview={brief:b,metin:briefMetni(b)};
} }
const snapshot=store.getDataSnapshot(user.email);
return json(res,200,{success:true,kapali:!!snapshot.veri.brief?.kapali,revision:snapshot.revisions.brief ?? null,...preview});
}catch(e){return json(res,e.kod==='VERSION_CONFLICT'?409:400,{success:false,error:e.message,kod:e.kod});}
}
if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') { if (url.pathname === '/api/admin/brief/gonder' && req.method === 'POST') {
const user = userFromAuthHeader(req.headers['authorization']); const user = userFromAuthHeader(req.headers['authorization']);
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' }); if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yetki yok' });
+1 -1
View File
@@ -184,7 +184,7 @@ export function findByStripe({ customerId, subId }) {
// --- Kullanıcı verisi (localStorage yerine) -------------------------------- // --- Kullanıcı verisi (localStorage yerine) --------------------------------
const IZINLI_ANAHTARLAR = new Set([ const IZINLI_ANAHTARLAR = new Set([
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder', 'favori_kurumlar', 'ekip', 'saved_filters', 'reminder', 'brief',
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban', 'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
'iptal_geri_bildirim', 'iptal_geri_bildirim',
'firma_profil', // yetkinlikler + uyum eşiği 'firma_profil', // yetkinlikler + uyum eşiği
+36 -43
View File
@@ -1,48 +1,41 @@
import React, { useEffect, useState } from 'react'; import React,{useEffect,useRef,useState} from 'react';
import { CalendarClock, Eye, Loader2 } from 'lucide-react'; import {CalendarClock} from 'lucide-react';
import {getToken} from '../../services/auth'; import {getToken} from '../../services/auth';
import {useApp} from '../../context/AppContext';
/** import {getWeeklyBrief,saveWeeklyBrief} from '../../services/weeklyBrief';
* Haftalık brief tercihi (Pazartesi 08:00): bu hafta yapılacak takipli
* ihaleler + geçen hafta sonuçlanan takipliler — Telegram ve e-posta.
* "Önizle" şu anki içeriği gösterir; anahtar kapatır.
*/
const iste = (opts = {}) => fetch('/api/me/brief', { headers: { Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' }, ...opts }).then((r) => r.json());
export const BriefTercihi=()=>{ export const BriefTercihi=()=>{
const [kapali, setKapali] = useState(false); const {authUser}=useApp();
const [metin, setMetin] = useState(null); return <BriefPanel key={`${authUser?.email}:${getToken()}`}/>;
const [mesgul, setMesgul] = useState(false);
useEffect(() => { iste().then((d) => { if (d?.success) setKapali(!!d.kapali); }).catch(() => {}); }, []);
const degistir = async () => {
const yeni = !kapali; setKapali(yeni);
try { await iste({ method: 'POST', body: JSON.stringify({ kapali: yeni }) }); } catch { setKapali(!yeni); }
}; };
const onizle = async () => { function BriefPanel(){
setMesgul(true); const [value,setValue]=useState(null),[metin,setMetin]=useState(null),[busy,setBusy]=useState(true),[error,setError]=useState(null),[attempt,setAttempt]=useState(0);
try { const d = await iste(); setMetin(d?.metin || 'Önizleme alınamadı.'); } catch { setMetin('Önizleme alınamadı.'); } finally { setMesgul(false); } const lifetime=useRef(null),lock=useRef(false);
useEffect(()=>{
const controller=new AbortController();lifetime.current=controller;lock.current=true;setBusy(true);setError(null);
getWeeklyBrief({signal:controller.signal}).then(data=>{if(!controller.signal.aborted)setValue(data);})
.catch(e=>{if(!controller.signal.aborted)setError(e.message);})
.finally(()=>{if(!controller.signal.aborted){lock.current=false;setBusy(false);}});
return()=>controller.abort();
},[attempt]);
const run=async preview=>{
if(lock.current||!value)return;
const signal=lifetime.current.signal;lock.current=true;setBusy(true);setError(null);
try{
const data=preview?await getWeeklyBrief({preview:true,signal}):await saveWeeklyBrief(!value.kapali,value.revision,{signal});
if(!signal.aborted){setValue(data);if(preview)setMetin(data.metin);}
}catch(e){if(!signal.aborted)setError(e.message);}
finally{if(!signal.aborted){lock.current=false;setBusy(false);}}
}; };
return <section aria-label="Haftalık özet ayarı" className="bg-white rounded-2xl border border-slate-200 p-5 space-y-3">
return ( <h3 className="font-bold text-sm flex gap-2"><CalendarClock className="w-4 h-4"/>Haftalık Brief</h3>
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-3"> <p className="text-xs text-slate-500">Haftanın takipli ihaleleri ve geçen hafta sonuçlanan takipliler için özet tercihi. Gönderim, sunucu zamanlayıcısının ve ilgili e-posta/Telegram hizmetinin etkin olmasına bağlıdır.</p>
<div className="flex items-start justify-between gap-3"> <div className="flex gap-3 flex-wrap">
<div> {value&&<button role="switch" aria-label="Haftalık özet gönderimi" aria-checked={!value.kapali} disabled={busy} onClick={()=>run(false)} className="border rounded px-3 py-2 text-xs">{value.kapali?'Haftalık özet kapalı':'Haftalık özet açık'}</button>}
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2"><CalendarClock className="w-4 h-4 text-amber-500" /> Haftalık Brief</h3> <button disabled={busy||!value} onClick={()=>run(true)} className="border rounded px-3 py-2 text-xs">Önizle</button>
<p className="text-[11px] text-slate-500 mt-1">Her Pazartesi 08:00: bu hafta yapılacak takipli ihaleler ve geçen hafta sonuçlanan takipliler — Telegram (bağlıysa) ve e-posta ile tek mesaj.</p> <button disabled={busy} onClick={()=>setAttempt(n=>n+1)} className="border rounded px-3 py-2 text-xs">Özet ayarını yenile</button>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> {busy&&<p role="status" className="text-xs">Haftalık özet işlemi sürüyor…</p>}
<button onClick={onizle} disabled={mesgul} className="text-[11px] flex items-center gap-1 border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50"> {error&&<p role="alert" className="text-xs text-red-700">{error}</p>}
{mesgul ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />} Önizle {metin!==null&&<pre className="whitespace-pre-wrap rounded border bg-slate-50 p-3 text-xs">{metin}</pre>}
</button> </section>;
<button role="switch" aria-checked={!kapali} onClick={degistir} }
className={`relative inline-flex h-5 w-9 items-center rounded-full transition ${kapali ? 'bg-slate-300' : 'bg-emerald-500'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition ${kapali ? 'translate-x-0.5' : 'translate-x-4'}`} />
</button>
</div>
</div>
{metin && <pre className="whitespace-pre-wrap rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 font-sans leading-relaxed">{metin}</pre>}
</div>
);
};
+13
View File
@@ -0,0 +1,13 @@
import {accountRequest} from './accountRequest.js';
const validated=data=>{
if(typeof data.kapali!=='boolean'||(data.revision!==null&&(typeof data.revision!=='string'||!/^[a-f0-9]{64}$/.test(data.revision))))throw new Error('Haftalık özet ayarı doğrulanamadı.');
return data;
};
export async function getWeeklyBrief({preview=false,signal}={}) {
const data=validated(await accountRequest('/api/me/brief'+(preview?'?preview=1':''),{signal}));
if(preview&&typeof data.metin!=='string')throw new Error('Haftalık özet önizlemesi doğrulanamadı.');
return data;
}
export async function saveWeeklyBrief(kapali,revision,{signal}={}) {
return validated(await accountRequest('/api/me/brief',{method:'POST',signal,body:JSON.stringify({kapali,revision})}));
}
+9
View File
@@ -256,3 +256,12 @@ test('notification preferences reject stale writes and malformed quiet times',as
assert.equal((await request('/api/me/data','PUT',{anahtar,deger:{...deger,pushEnabled:false},revision:null})).status,409); 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); assert.deepEqual((await request('/api/me/data')).data.veri.reminder,deger);
}); });
test('weekly brief preference loads without feed preview and changes only with a current revision',async()=>{
const first=await request('/api/me/brief');assert.equal(first.status,200);assert.equal(first.data.kapali,false);assert.equal(first.data.revision,null);assert.equal(first.data.metin,undefined);
assert.equal((await request('/api/me/brief','POST',{})).status,400);
const saved=await request('/api/me/brief','POST',{kapali:true,revision:null});assert.equal(saved.status,200);assert.equal(saved.data.kapali,true);
assert.equal((await request('/api/me/brief','POST',{kapali:false,revision:null})).status,409);
assert.equal((await request('/api/me/brief')).data.kapali,true);
assert.equal((await request('/api/me/brief','GET',null,other)).data.kapali,false);
assert.equal((await request('/api/me/brief','POST',{kapali:false,revision:saved.data.revision})).status,200);
});