/* ============================================================ STORE — server-backed (real auth + DB + governance). Reducer kept for instant optimistic UI; every action persists to the API and reconciles via /api/bootstrap. Same dispatch contract as the prototype. ============================================================ */ const { useReducer, useState, useEffect, useCallback, useRef } = React; /* ---------- helpers ---------- */ function timeAgo(ts){ const s=Math.floor((Date.now()-ts)/1000); if(s<60) return 'just now'; const m=Math.floor(s/60); if(m<60) return m+'m ago'; const h=Math.floor(m/60); if(h<24) return h+'h ago'; const d=Math.floor(h/24); if(d<30) return d+'d ago'; return new Date(ts).toLocaleDateString('en-IN',{day:'numeric',month:'short'}); } function fmtDate(ts){ return new Date(ts).toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}); } const uid = (p)=> p+Math.random().toString(36).slice(2,8); function audit(state, action, target, detail, actorId){ const entry={ id:uid('au'), action, actorId: actorId||state.sessionUserId, target, detail, at:Date.now() }; return [entry, ...state.audit]; } const EMPTY = { hospitals:[], users:[], requests:[], audit:[], settings:{reviewersCanDecide:false}, seq:118, sessionUserId:null, me:null, loaded:false }; /* ---------- API ---------- */ const J = { 'content-type':'application/json' }; async function apiGET(path){ return fetch(path,{credentials:'include'}); } async function apiSend(path, method, body){ return fetch(path,{method,credentials:'include',headers:J,body:body?JSON.stringify(body):undefined}); } /* map a dispatched action → the server endpoint that persists it */ async function persist(a){ switch(a.type){ case 'SUBMIT': return apiSend('/api/requests','POST',{urgency:a.data.urgency,patient:a.data.patient,request:a.data.request,files:a.data.files||[]}); case 'RESPOND': return apiSend(`/api/requests/${a.reqId}/respond`,'POST',{body:a.body,files:a.files||[]}); case 'ASSIGN': return apiSend(`/api/requests/${a.reqId}/assign`,'POST',{reviewerId:a.reviewerId}); case 'STATUS': return apiSend(`/api/requests/${a.reqId}/status`,'POST',{to:a.to,reason:a.reason||''}); case 'RECOMMEND':return apiSend(`/api/requests/${a.reqId}/recommend`,'POST',{rec:a.rec,body:a.body||''}); case 'NOTE': return apiSend(`/api/requests/${a.reqId}/notes`,'POST',{scope:a.scope,body:a.body}); case 'INVITE_USER': return apiSend('/api/users','POST',a.data); case 'UPDATE_USER': return apiSend(`/api/users/${a.userId}`,'PATCH',{patch:a.patch,detail:a.detail}); case 'ADD_PARTNER': return apiSend('/api/hospitals','POST',a.data); case 'UPDATE_PARTNER':return apiSend(`/api/hospitals/${a.id}`,'PATCH',{patch:a.patch,detail:a.detail}); case 'SETTINGS': return apiSend('/api/settings','PATCH',{reviewersCanDecide:a.patch.reviewersCanDecide}); default: return null; // LOGIN/LOGOUT/DOWNLOAD/SET_STATE handled elsewhere } } /* ---------- reducer (optimistic local; mirrors prototype) ---------- */ function reducer(state, a){ const actor = a.actorId || state.sessionUserId; switch(a.type){ case 'SET_STATE':{ const reqs=a.payload.requests||[]; const maxN=reqs.reduce((m,r)=>{const n=parseInt(String(r.id).replace(/\D/g,''),10);return isNaN(n)?m:Math.max(m,n);},117); return {...state, ...a.payload, seq:maxN+1, sessionUserId:a.payload.me?a.payload.me.id:null, loaded:true}; } case 'LOGOUT': return {...EMPTY, loaded:true}; case 'SUBMIT':{ const code='B-'+String(state.seq).padStart(3,'0'); const me=state.me; const req={ id:code, hospitalId:me.hospitalId, submittedById:actor, reviewerId:null, status:'submitted', urgency:a.data.urgency, patient:a.data.patient, request:a.data.request, files:(a.data.files||[]).map(f=>({id:uid('f'),name:f.name,type:f.type,by:actor,at:Date.now(),scope:'partner'})), notes:[], history:[{from:null,to:'submitted',byId:actor,at:Date.now(),reason:''}], createdAt:Date.now(), updatedAt:Date.now() }; return {...state, requests:[req,...state.requests], seq:state.seq+1, lastCreated:code, audit:audit(state,'submission',code,'New patient case submitted ('+a.data.request.type+')')}; } case 'RESPOND':{ const requests=state.requests.map(r=>{ if(r.id!==a.reqId) return r; const notes=[...r.notes,{id:uid('n'),authorId:actor,scope:'partner',body:a.body,at:Date.now()}]; const files=[...r.files,...(a.files||[]).map(f=>({id:uid('f'),name:f.name,type:f.type,by:actor,at:Date.now(),scope:'partner'}))]; const history=[...r.history,{from:'needs_info',to:'under_review',byId:actor,at:Date.now(),reason:'Partner responded'}]; return {...r,notes,files,status:'under_review',history,updatedAt:Date.now()}; }); return {...state, requests, audit:audit(state,'status_change',a.reqId,'Needs Information → Under Review · partner responded')}; } case 'ASSIGN':{ const rev=state.users.find(u=>u.id===a.reviewerId); const requests=state.requests.map(r=> r.id===a.reqId ? {...r,reviewerId:a.reviewerId,updatedAt:Date.now()} : r); return {...state, requests, audit:audit(state,'assignment',a.reqId,'Assigned reviewer: '+(rev?rev.name:'—'))}; } case 'STATUS':{ const requests=state.requests.map(r=>{ if(r.id!==a.reqId) return r; const history=[...r.history,{from:r.status,to:a.to,byId:actor,at:Date.now(),reason:a.reason||''}]; return {...r,status:a.to,history,updatedAt:Date.now()}; }); const lbl=window.GOV.STATUS; const cur=state.requests.find(r=>r.id===a.reqId); const detail=lbl[cur.status].label+' → '+lbl[a.to].label+(a.reason?' · “'+a.reason+'”':''); return {...state, requests, audit:audit(state,'status_change',a.reqId,detail)}; } case 'RECOMMEND':{ const requests=state.requests.map(r=> r.id===a.reqId ? {...r, notes:[...r.notes,{id:uid('n'),authorId:actor,scope:'internal',body:'Recommendation: '+a.rec.toUpperCase()+(a.body?' — '+a.body:''),at:Date.now()}], recommendation:{rec:a.rec,byId:actor,at:Date.now()}, updatedAt:Date.now()} : r); return {...state, requests, audit:audit(state,'recommendation',a.reqId,'Reviewer recommended: '+a.rec.toUpperCase())}; } case 'NOTE':{ const requests=state.requests.map(r=> r.id===a.reqId ? {...r, notes:[...r.notes,{id:uid('n'),authorId:actor,scope:a.scope,body:a.body,at:Date.now()}], updatedAt:Date.now()} : r); const aud = a.scope==='internal' ? audit(state,'note_internal',a.reqId,'Added internal-only note') : state.audit; return {...state, requests, audit:aud}; } case 'DOWNLOAD': return {...state, audit:audit(state,'file_download',a.reqId,'Downloaded '+a.name)}; case 'INVITE_USER':{ const u={id:uid('u'),...a.data,status:'invited',lastLogin:null}; return {...state, users:[...state.users,u], audit:audit(state,'user_change','—','Invited user · '+a.data.name+' ('+window.GOV.ROLES[a.data.role].label+')')}; } case 'UPDATE_USER':{ const users=state.users.map(u=> u.id===a.userId ? {...u,...a.patch} : u); return {...state, users, audit:audit(state,'user_change',a.userId,a.detail)}; } case 'ADD_PARTNER':{ const h={id:uid('h'),...a.data,status:'active'}; return {...state, hospitals:[...state.hospitals,h], audit:audit(state,'partner_change','—','Added partner · '+a.data.name)}; } case 'UPDATE_PARTNER':{ const hospitals=state.hospitals.map(h=> h.id===a.id ? {...h,...a.patch} : h); return {...state, hospitals, audit:audit(state,'partner_change',a.id,a.detail)}; } case 'SETTINGS': return {...state, settings:{...state.settings,...a.patch}, audit:audit(state,'setting_change','—',a.detail)}; default: return state; } } /* ---------- provider ---------- */ function StoreProvider({children}){ const [state, rawDispatch] = useReducer(reducer, EMPTY); const [route, setRoute] = useState({screen:'dashboard', id:null}); const [toasts, setToasts] = useState([]); const [navOpen, setNavOpen] = useState(false); const toast = useCallback((t)=>{ const id=uid('t'); setToasts(x=>[...x,{...t,id}]); setTimeout(()=> setToasts(x=>x.filter(z=>z.id!==id)), t.ms||3800); },[]); const bootstrap = useCallback(async ()=>{ try{ const r=await apiGET('/api/bootstrap'); if(!r.ok){ rawDispatch({type:'SET_STATE', payload:{...EMPTY, me:null}}); return null; } const data=await r.json(); rawDispatch({type:'SET_STATE', payload:data}); return data.me; }catch(e){ rawDispatch({type:'SET_STATE', payload:{...EMPTY, me:null}}); return null; } },[]); useEffect(()=>{ bootstrap(); },[bootstrap]); // optimistic local update, then persist + reconcile from server const dispatch = useCallback((a)=>{ rawDispatch(a); const p=persist(a); if(p){ p.then(async(res)=>{ if(!res.ok){ let msg='Could not save'; try{ msg=(await res.json()).error||msg; }catch(e){} toast({kind:'alert',icon:'shield',title:'Action blocked',desc:msg}); } bootstrap(); }).catch(()=>{ toast({kind:'alert',title:'Network error',desc:'Could not reach the server'}); bootstrap(); }); } },[bootstrap,toast]); const go = useCallback((screen,id=null)=>{ setRoute({screen,id}); setNavOpen(false); const c=document.querySelector('.content'); if(c) c.scrollTop=0; },[]); const signIn = useCallback(async (email,password)=>{ const r=await fetch('/api/auth/sign-in/email',{method:'POST',credentials:'include',headers:J,body:JSON.stringify({email,password})}); if(!r.ok){ let m='Invalid email or password'; try{m=(await r.json()).message||m;}catch(e){} return {ok:false,error:m}; } const me=await bootstrap(); if(!me) return {ok:false,error:'Your access has been disabled — please contact your administrator.'}; return {ok:true, me}; },[bootstrap]); const signOut = useCallback(async ()=>{ try{ await fetch('/api/auth/sign-out',{method:'POST',credentials:'include',headers:J}); }catch(e){} rawDispatch({type:'LOGOUT'}); setRoute({screen:'dashboard',id:null}); },[]); const me = state.me; const sel = { user:(id)=> state.users.find(u=>u.id===id), hospital:(id)=> state.hospitals.find(h=>h.id===id), request:(id)=> state.requests.find(r=>r.id===id), hospitalName:(id)=>{ const h=state.hospitals.find(x=>x.id===id); return h?h.name:'—'; }, userName:(id)=>{ const u=state.users.find(x=>x.id===id); return u?u.name:'—'; }, visibleRequests:()=> state.requests.filter(r=> window.GOV.canViewRequest(me,r)), }; const value={ state, dispatch, me, route, go, toast, toasts, sel, navOpen, setNavOpen, helpers:{timeAgo,fmtDate}, auth:{signIn,signOut}, bootstrap }; if(!state.loaded){ return React.createElement('div',{className:'login'}, React.createElement('div',{className:'login__card'}, React.createElement('div',{className:'login__strip'}), React.createElement('div',{className:'login__bd',style:{textAlign:'center',padding:'48px 32px',color:'var(--fg-3)'}},'Loading the BHF Partner Hub…'))); } return React.createElement(window.StoreCtx.Provider,{value},children); } Object.assign(window,{ StoreProvider, timeAgo, fmtDate });