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ı.
|
||||
- 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ı.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
+19
-8
@@ -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))
|
||||
return json(res,400,{success:false,error:'Rakip listesi geçersiz veya yinelenen firma içeriyor.'});
|
||||
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)))))
|
||||
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);
|
||||
@@ -1514,15 +1515,25 @@ const server = http.createServer(async (req, res) => {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
store.ensureUser(user.email, user.name);
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req).catch(() => ({}));
|
||||
store.setData(user.email, 'brief', { kapali: body.kapali === true });
|
||||
return json(res, 200, { success: true, kapali: body.kapali === true });
|
||||
try {
|
||||
if(req.method==='POST'){
|
||||
const body=await readBody(req);
|
||||
if(typeof body.kapali!=='boolean')return json(res,400,{success:false,error:'Haftalık özet seçimi geçersiz.'});
|
||||
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();
|
||||
const b = briefOlustur(user.email, out.tenders);
|
||||
return json(res, 200, { success: true, kapali: !!(store.getData(user.email, 'brief')?.kapali), brief: b, metin: briefMetni(b) });
|
||||
if(req.method!=='GET')return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
|
||||
let preview={};
|
||||
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') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
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) --------------------------------
|
||||
|
||||
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',
|
||||
'iptal_geri_bildirim',
|
||||
'firma_profil', // yetkinlikler + uyum eşiği
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CalendarClock, Eye, Loader2 } from 'lucide-react';
|
||||
import { getToken } from '../../services/auth';
|
||||
|
||||
/**
|
||||
* 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 = () => {
|
||||
const [kapali, setKapali] = useState(false);
|
||||
const [metin, setMetin] = useState(null);
|
||||
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 () => {
|
||||
setMesgul(true);
|
||||
try { const d = await iste(); setMetin(d?.metin || 'Önizleme alınamadı.'); } catch { setMetin('Önizleme alınamadı.'); } finally { setMesgul(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-xs p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<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">
|
||||
{mesgul ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />} Önizle
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {CalendarClock} from 'lucide-react';
|
||||
import {getToken} from '../../services/auth';
|
||||
import {useApp} from '../../context/AppContext';
|
||||
import {getWeeklyBrief,saveWeeklyBrief} from '../../services/weeklyBrief';
|
||||
export const BriefTercihi=()=>{
|
||||
const {authUser}=useApp();
|
||||
return <BriefPanel key={`${authUser?.email}:${getToken()}`}/>;
|
||||
};
|
||||
function BriefPanel(){
|
||||
const [value,setValue]=useState(null),[metin,setMetin]=useState(null),[busy,setBusy]=useState(true),[error,setError]=useState(null),[attempt,setAttempt]=useState(0);
|
||||
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">
|
||||
<h3 className="font-bold text-sm flex gap-2"><CalendarClock className="w-4 h-4"/>Haftalık Brief</h3>
|
||||
<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 gap-3 flex-wrap">
|
||||
{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>}
|
||||
<button disabled={busy||!value} onClick={()=>run(true)} className="border rounded px-3 py-2 text-xs">Önizle</button>
|
||||
<button disabled={busy} onClick={()=>setAttempt(n=>n+1)} className="border rounded px-3 py-2 text-xs">Özet ayarını yenile</button>
|
||||
</div>
|
||||
{busy&&<p role="status" className="text-xs">Haftalık özet işlemi sürüyor…</p>}
|
||||
{error&&<p role="alert" className="text-xs text-red-700">{error}</p>}
|
||||
{metin!==null&&<pre className="whitespace-pre-wrap rounded border bg-slate-50 p-3 text-xs">{metin}</pre>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -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.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