From 3d9aa79624a4d2ff46aa94713393e7ccb0f9f2b2 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 14:01:28 +0300 Subject: [PATCH] fix: persist team changes only after versioned server confirmation --- docs/quality/uygulama-durumu.md | 7 +++++++ docs/quality/yedek-geri-yukleme.md | 2 ++ server/index.js | 6 ++++-- src/components/RightSidebar.jsx | 26 +++++++++++++++----------- src/context/AppContext.jsx | 27 ++++++++++++++++++++------- src/services/teamData.js | 12 ++++++++++++ tests/http-state.test.mjs | 12 ++++++++++++ tests/team-data.test.mjs | 8 ++++++++ 8 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 src/services/teamData.js create mode 100644 tests/team-data.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index f82d974..795e421 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -978,3 +978,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - İkinci sekme yenilendiğinde yalnız ilk sekmenin kaydı geldi; ikinci ürün güncel profil üzerine yeniden eklendi ve iki ürün birlikte başarıyla kaydedildi. Beş kontrol geçti. Kanıt: /Users/hikmettanriverdi/Documents/ChatGPT/ihale/rapor/qa-profile-concurrent-tabs.json. - Test sadece firma profili için gerçek iki sekme etkileşimini kapsar. Filtrelerin API çakışma kabulü önceki 222 testte mevcut; filtre UI çakışma/kurtarma senaryosu ayrıca açık. Üretim kodu değişmediğinden suite ve derleme tekrar edilmedi. - Sekmeler kapatıldı; bu test için açılan QA süreci sonlandırıldı (exit 0), geçici depo cleanup akışı tamamlandı. Canlı site veya gerçek firma hesabı değiştirilmedi. + +### Ekip kaydında sunucu onayı ve çakışma koruması — 6 Eylül 2026 +- Ekip listesinin iyimser state + effect otomatik kaydı kaldırıldı. Ekleme/çıkarma mevcut ekip içerik sürümüyle sunucuya yazılır; yalnız başarıdan sonra liste ve sürüm güncellenir. Kayıt sürerken işlem kilitlenir; hata ekip formunun yanında gösterilir, ekleme girdileri temizlenmez. +- API ekip listesi, benzersiz kimlik, ad, görev ve isteğe bağlı e-posta alanlarını doğrular; mevcut plan kotası ve sürüm karşılaştırması birlikte uygulanır. Eski sekme yazımı mevcut listeyi ezmez. +- Son ekip üyesini kaldırmayı engelleyen arayüz koşulu kaldırıldı; boş ekip listesi geçerlidir. Düğme ve form alanlarına açık etiketler, kayıt sürüyor durumu ve beklerken devre dışı bırakma eklendi. Bu kayıtlar hesap içi görev/sorumlu listesidir; davet gönderilmedi veya başka kullanıcı hesabı oluşturulmadı. +- 224 otomatik test ve derleme geçti. HTTP testi ekip ekleme, eski sürüm reddi, kota aşımında mevcut kaydın korunması ve son üyeyi silmeyi doğruladı. Geçersiz/yinelenen ekip verisi test edildi. +- Ekip UI hata/kurtarma ve iki sekmeli etkileşim kabulü henüz yapılmadı. Üye silinince geçmiş ihale sorumlu adı tarihsel kayıtta kalır; otomatik yeniden atama yapılmaz. Favoriler ve diğer genel kullanıcı anahtarları için çakışma koruması açık. Canlıya yayın yapılmadı. diff --git a/docs/quality/yedek-geri-yukleme.md b/docs/quality/yedek-geri-yukleme.md index 0299d36..206f0da 100644 --- a/docs/quality/yedek-geri-yukleme.md +++ b/docs/quality/yedek-geri-yukleme.md @@ -75,3 +75,5 @@ Kuyruktaki ilanın artık kaynakta bulunmaması, teklif zamanının değişmesi, ### Firma profili / kayıtlı filtre sürüm sözleşmesi `GET /api/me/data` artık `veri` ile aynı okumaya ait `revisions` döndürür. `firma_profil` ve `saved_filters` PUT/POST istekleri `revision` göndermelidir (yeni kayıt için null). Eksik sürüm 428, değişmiş sürüm 409 üretir; otomatik üzerine yazma veya otomatik tekrar yoktur. Frontend ve backend birlikte sürümlenmelidir. Eski açık sekme 428 alırsa kaydedilmemiş girdiler korunup sayfa yenilenmelidir. Veritabanı şeması değişmez; eski sunucuya dönmek bu iki anahtardaki çakışma korumasını kaldırır. Yayın kabulünde iki sekmenin aynı kaydı değiştirmesi ve reddedilen sekmenin girdilerinin korunması sınanmalıdır. + +Ekip kaydı (`ekip`) da aynı içerik sürümü sözleşmesine dahil edildi. Yeni ekip arayüzü sunucu onayını bekler. Eski istemciden sürümsüz ekip yazımı 428 ile reddedilir; frontend/backend birlikte güncellenmelidir. Ekip alanlarının geçersiz veya kimliklerinin yinelenmiş olduğu eski kayıtlar önce düzeltilmelidir; sessiz veri temizliği yapılmaz. diff --git a/server/index.js b/server/index.js index a1a6730..7ce5f79 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import {validateTeam} from '../src/services/teamData.js'; import { executeAnalysisJob } from './analysis-job.mjs'; import { resolveAiJobInput } from './ai-job-source.mjs'; import * as aiJobs from './ai-job-store.mjs'; @@ -1202,10 +1203,11 @@ const server = http.createServer(async (req, res) => { const k = body.anahtar || anahtar; if (!k) return json(res, 400, { success: false, error: 'anahtar gerekli' }); // Ekip kotası sunucu tarafında uygulanır - if (k === 'ekip' && Array.isArray(body.deger)) { + if (k === 'ekip') { + validateTeam(body.deger); kotaKontrol(user.email, 'ekip', 0, body.deger.length, { isAdmin: user.isAdmin }); } - const protectedKey=['firma_profil','saved_filters'].includes(k); + const protectedKey=['firma_profil','saved_filters','ekip'].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); diff --git a/src/components/RightSidebar.jsx b/src/components/RightSidebar.jsx index 8cc4a1a..ab96633 100644 --- a/src/components/RightSidebar.jsx +++ b/src/components/RightSidebar.jsx @@ -5,7 +5,7 @@ import { firmStats, companyStats } from '../services/analytics'; import { Briefcase, FileText, Calendar, History, Trophy, Eye, Kanban, Radar, ArrowRight, Users, Plus, Trash2 } from 'lucide-react'; export const RightSidebar = () => { - const { setActiveTab, setQuickFilter, tenders, teamMembers, addTeamMember, removeTeamMember } = useApp(); + const { setActiveTab, setQuickFilter, tenders, teamMembers, teamSaving, teamError, addTeamMember, removeTeamMember } = useApp(); const [tmName, setTmName] = useState(''); const [tmEmail, setTmEmail] = useState(''); const [tmRole, setTmRole] = useState('Uzman'); @@ -13,11 +13,11 @@ export const RightSidebar = () => { const cs = companyStats(tenders); const topFirms = firmStats(tenders).firms.slice(0, 3); - const handleAddMember = (e) => { + const handleAddMember = async (e) => { e.preventDefault(); if (!tmName.trim()) return; - addTeamMember({ name: tmName.trim(), email: tmEmail.trim(), role: tmRole }); - setTmName(''); setTmEmail(''); + const saved=await addTeamMember({ name: tmName.trim(), email: tmEmail.trim(), role: tmRole }); + if(saved){setTmName('');setTmEmail('');} }; const records = [ @@ -125,7 +125,7 @@ export const RightSidebar = () => { - {/* Ekip Yönetimi — gerçek (localStorage) */} + {/* Ekip Yönetimi — sunucu kaydı */}

Ekip Yönetimi ({teamMembers.length}) @@ -137,28 +137,32 @@ export const RightSidebar = () => {
{m.name}
{m.role}{m.email ? ` · ${m.email}` : ''}

- {teamMembers.length > 1 && ( - )} ))} + {teamError&&

{teamError}

} + {teamSaving&&

Ekip kaydediliyor…

}
- setTmName(e.target.value)} placeholder="Ad Soyad" +
+ setTmName(e.target.value)} placeholder="Ad Soyad" className="w-full bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs" />
- setTmEmail(e.target.value)} placeholder="E-posta (ops.)" + setTmEmail(e.target.value)} placeholder="E-posta (ops.)" className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs" /> - setTmRole(e.target.value)} className="bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
- +
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 2ded78f..ea72638 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,3 +1,4 @@ +import {validateTeam} from '../services/teamData'; import {savedFilterCriteria} from '../services/savedFilterCriteria'; import {mergeServerNotifications,acknowledgeNotifications} from '../services/notifications'; import { searchTender } from '../services/searchTender'; @@ -165,7 +166,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={}; + dataRevisions.current={};setTeamError(null); setVeriSenkron(false); setPlan(null); setSaveError(null); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser)); @@ -367,11 +368,23 @@ export const AppProvider = ({ children }) => { // --- Ekip yönetimi (sunucu kaydı) --- const [teamMembers, setTeamMembers] = useState([]); - useEffect(() => { - if (veriSenkron) ilkYazmaDegil('ekip', teamMembers); - }, [teamMembers, veriSenkron]); - const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]); - const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id)); + const teamSaveLock=useRef(false); + const [teamSaving,setTeamSaving]=useState(false); + const [teamError,setTeamError]=useState(null); + const persistTeam=async next=>{ + if(!authUser||!veriSenkron){setTeamError('Hesap verileri yüklenmeden ekip kaydedilemez.');return false;} + if(teamSaveLock.current)return false; + const session=getToken();teamSaveLock.current=true;setTeamSaving(true);setTeamError(null); + try { + validateTeam(next); + const saved=await setUserData('ekip',next,dataRevisions.current.ekip ?? null); + if(getToken()!==session)return false; + dataRevisions.current.ekip=saved.revision;setTeamMembers(next);return true; + } catch(error){if(getToken()===session)setTeamError(error.message);return false;} + finally{teamSaveLock.current=false;setTeamSaving(false);} + }; + const addTeamMember = member => persistTeam([...teamMembers,{...member,id:'tm-'+crypto.randomUUID()}]); + const removeTeamMember = id => persistTeam(teamMembers.filter(member=>member.id!==id)); // Belge kasası — kullanıcı kendi belgelerini ekler (hesaba özel sunucu kaydı) const [vaultDocuments, setVaultDocuments] = useState([]); @@ -813,7 +826,7 @@ export const AppProvider = ({ children }) => { isQuietNow, favoriteKurumlar, toggleFavoriteKurum, - teamMembers, + teamMembers,teamSaving,teamError, addTeamMember, removeTeamMember, toggleTrackTender, diff --git a/src/services/teamData.js b/src/services/teamData.js new file mode 100644 index 0000000..fc2d1da --- /dev/null +++ b/src/services/teamData.js @@ -0,0 +1,12 @@ +export function validateTeam(members) { + if(!Array.isArray(members))throw new Error('Ekip listesi geçersiz.'); + const ids=new Set(); + for(const member of members){ + if(!member || typeof member.id!=='string' || !member.id.trim() || member.id.length>100 || ids.has(member.id))throw new Error('Ekip üyesi kimliği eksik veya yineleniyor.'); + ids.add(member.id); + if(typeof member.name!=='string' || !member.name.trim() || member.name.length>200)throw new Error('Ekip üyesinin adını girin (en fazla 200 karakter).'); + if(member.email && (typeof member.email!=='string' || member.email.length>254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email)))throw new Error('Ekip üyesinin e-posta adresini kontrol edin.'); + if(typeof member.role!=='string' || !member.role.trim() || member.role.length>100)throw new Error('Ekip üyesinin görevini kontrol edin.'); + } + return members; +} diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index 80c08bb..23559f8 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -217,3 +217,15 @@ test('profile and saved-filter writes require the loaded revision and reject con assert.equal((await request('/api/me/data','PUT',{anahtar,deger,revision:null},other)).status,200); } }); +test('team membership rejects stale and over-quota writes and permits removing last member',async()=>{ + const anahtar='ekip',member={id:'qa-team',name:'QA ekip üyesi',role:'Uzman',email:''}; + const initial=await request('/api/me/data'); + const revision=initial.data.revisions.ekip ?? null; + assert.equal((await request('/api/me/data','PUT',{anahtar,deger:{},revision})).status,400); + const saved=await request('/api/me/data','PUT',{anahtar,deger:[member],revision});assert.equal(saved.status,200); + const stale=await request('/api/me/data','PUT',{anahtar,deger:[],revision});assert.equal(stale.status,409); + assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[member,{...member,id:'qa-2'}],revision:saved.data.revision})).status,402); + assert.deepEqual((await request('/api/me/data')).data.veri.ekip,[member]); + assert.equal((await request('/api/me/data','PUT',{anahtar,deger:[],revision:saved.data.revision})).status,200); + assert.deepEqual((await request('/api/me/data')).data.veri.ekip,[]); +}); diff --git a/tests/team-data.test.mjs b/tests/team-data.test.mjs new file mode 100644 index 0000000..60a1e6c --- /dev/null +++ b/tests/team-data.test.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {validateTeam} from '../src/services/teamData.js'; +test('team data accepts empty roster and rejects malformed or duplicate members',()=>{ + const valid={id:'qa',name:'QA Uzman',role:'Uzman',email:'qa@example.test'}; + assert.deepEqual(validateTeam([]),[]);assert.deepEqual(validateTeam([valid]),[valid]); + for(const bad of [{},null,[{}],[{...valid,name:' '}],[{...valid,email:'invalid'}],[{...valid,role:null}],[valid,valid]])assert.throws(()=>validateTeam(bad)); +});