fix: persist weekly brief preference and expose request failures
This commit is contained in:
@@ -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ı.
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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})}));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user