From b2a416c908ca8886826549ac5f0b4ba90e600d97 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 14:20:31 +0300 Subject: [PATCH] fix: persist weekly brief preference and expose request failures --- docs/quality/uygulama-durumu.md | 7 +++ docs/quality/yedek-geri-yukleme.md | 2 + server/index.js | 29 ++++++--- server/user-store.mjs | 2 +- src/components/tools/BriefTercihi.jsx | 87 ++++++++++++--------------- src/services/weeklyBrief.js | 13 ++++ tests/http-state.test.mjs | 9 +++ 7 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 src/services/weeklyBrief.js diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index fc1b33f..f120591 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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ı. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index bc22f46..cfb5aca 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -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. diff --git a/server/index.js b/server/index.js index 17cb0d0..5bcfe77 100644 --- a/server/index.js +++ b/server/index.js @@ -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 }); - } - 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) }); + 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}); + } + 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' }); diff --git a/server/user-store.mjs b/server/user-store.mjs index 6f94fda..89114f2 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -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 diff --git a/src/components/tools/BriefTercihi.jsx b/src/components/tools/BriefTercihi.jsx index 0122221..8992f0c 100644 --- a/src/components/tools/BriefTercihi.jsx +++ b/src/components/tools/BriefTercihi.jsx @@ -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 ( -
-
-
-

Haftalık Brief

-

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.

-
-
- - -
-
- {metin &&
{metin}
} -
- ); +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 ; }; +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
+

Haftalık Brief

+

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.

+
+ {value&&} + + +
+ {busy&&

Haftalık özet işlemi sürüyor…

} + {error&&

{error}

} + {metin!==null&&
{metin}
} +
; +} diff --git a/src/services/weeklyBrief.js b/src/services/weeklyBrief.js new file mode 100644 index 0000000..b74c8f8 --- /dev/null +++ b/src/services/weeklyBrief.js @@ -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})})); +} diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index dd5b92b..8051e24 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -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); +});