feat: migrate app state to bootstrap endpoint
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Use GET /me/bootstrap as the primary authenticated state source per ADR-0003. AuthContext now fetches and exposes typed bootstrapData, Header and class/detail admin checks use bootstrap permissions/roles, dashboard surfaces onboarding/calendar/timetable/graph status, and graph navigation derives school setup state from bootstrap instead of /school/status. Refs: t_44353587
This commit is contained in:
parent
67e47fc47f
commit
65ce1bede8
@ -5,6 +5,8 @@ import { CCUser, CCUserMetadata, authService } from '../services/auth/authServic
|
|||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { supabase } from '../supabaseClient';
|
import { supabase } from '../supabaseClient';
|
||||||
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
import { storageService, StorageKeys } from '../services/auth/localStorageService';
|
||||||
|
import { fetchBootstrapData, BootstrapResponse, invalidateBootstrapCache } from '../services/bootstrap/bootstrapService';
|
||||||
|
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: CCUser | null;
|
user: CCUser | null;
|
||||||
@ -15,6 +17,7 @@ export interface AuthContextType {
|
|||||||
signIn: (email: string, password: string) => Promise<void>;
|
signIn: (email: string, password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
bootstrapData: BootstrapResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType>({
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
@ -25,7 +28,8 @@ export const AuthContext = createContext<AuthContextType>({
|
|||||||
error: null,
|
error: null,
|
||||||
signIn: async () => {},
|
signIn: async () => {},
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
clearError: () => {}
|
clearError: () => {},
|
||||||
|
bootstrapData: null
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
@ -35,8 +39,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [bootstrapData, setBootstrapData] = useState<BootstrapResponse | null>(null);
|
||||||
|
|
||||||
const apiBase = import.meta.env.VITE_API_BASE as string;
|
|
||||||
|
|
||||||
const persistSession = useCallback((session: Session | null) => {
|
const persistSession = useCallback((session: Session | null) => {
|
||||||
if (session) {
|
if (session) {
|
||||||
@ -71,15 +75,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return { user: resolvedUser, role: resolvedRole };
|
return { user: resolvedUser, role: resolvedRole };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const triggerUserInit = useCallback((token: string) => {
|
const loadBootstrap = useCallback(async (token: string | null | undefined) => {
|
||||||
fetch(`${apiBase}/user/init`, {
|
if (!token) {
|
||||||
method: 'POST',
|
setBootstrapData(null);
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
return null;
|
||||||
})
|
}
|
||||||
.then(r => r.json())
|
const bootstrap = await fetchBootstrapData(token);
|
||||||
.then(data => logger.debug('auth-context', '✅ User init', data))
|
setBootstrapData(bootstrap);
|
||||||
.catch(err => logger.warn('auth-context', '⚠️ User init failed', { err }));
|
return bootstrap;
|
||||||
}, [apiBase]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
@ -94,7 +98,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
setAccessToken(session.access_token ?? null);
|
setAccessToken(session.access_token ?? null);
|
||||||
triggerUserInit(session.access_token);
|
await loadBootstrap(session.access_token);
|
||||||
} catch (buildError) {
|
} catch (buildError) {
|
||||||
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -119,6 +123,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
setAccessToken(session.access_token ?? null);
|
setAccessToken(session.access_token ?? null);
|
||||||
|
await loadBootstrap(session.access_token);
|
||||||
} catch (buildError) {
|
} catch (buildError) {
|
||||||
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
logger.error('auth-context', '❌ Failed to build user from session', { event, error: buildError });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -140,13 +145,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
|
setBootstrapData(null);
|
||||||
|
invalidateBootstrapCache();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [buildUserFromSupabase, persistSession, triggerUserInit]);
|
}, [buildUserFromSupabase, persistSession, loadBootstrap]);
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
@ -167,6 +174,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(resolvedUser);
|
setUser(resolvedUser);
|
||||||
setUserRole(role);
|
setUserRole(role);
|
||||||
setAccessToken(data.session?.access_token ?? null);
|
setAccessToken(data.session?.access_token ?? null);
|
||||||
|
await loadBootstrap(data.session?.access_token ?? null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('auth-context', '❌ Sign in failed', { error });
|
logger.error('auth-context', '❌ Sign in failed', { error });
|
||||||
@ -205,7 +213,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
error,
|
error,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
clearError
|
clearError,
|
||||||
|
bootstrapData
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
@ -38,54 +38,24 @@ import { HEADER_HEIGHT } from './Layout';
|
|||||||
import { logger } from '../debugConfig';
|
import { logger } from '../debugConfig';
|
||||||
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
import { GraphNavigator } from '../components/navigation/GraphNavigator';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, signOut, accessToken } = useAuth();
|
const { user, signOut, bootstrapData } = useAuth();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [isPlatformAdmin, setIsPlatformAdmin] = useState(false);
|
|
||||||
const [schoolRole, setSchoolRole] = useState<string | null>(null);
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
const [isAuthenticated, setIsAuthenticated] = useState(!!user);
|
||||||
const showGraphNavigation = location.pathname === '/single-player';
|
const showGraphNavigation = location.pathname === '/single-player';
|
||||||
|
const isPlatformAdmin = bootstrapData?.permissions?.platform_admin ?? false;
|
||||||
|
const schoolRole = bootstrapData?.active_institute?.membership_role ?? null;
|
||||||
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
|
const isSchoolAdmin = schoolRole === 'school_admin' || schoolRole === 'department_head';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAuthenticated(!!user);
|
setIsAuthenticated(!!user);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Check platform admin status and school role once on login
|
|
||||||
const checkAdminStatus = useCallback(async () => {
|
|
||||||
if (!accessToken) return;
|
|
||||||
// Platform admin check
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/admin/stats`, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
setIsPlatformAdmin(res.ok);
|
|
||||||
} catch {
|
|
||||||
setIsPlatformAdmin(false);
|
|
||||||
}
|
|
||||||
// School role check
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/school/status`, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setSchoolRole(data.user_role || null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setSchoolRole(null);
|
|
||||||
}
|
|
||||||
}, [accessToken]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (accessToken) checkAdminStatus();
|
|
||||||
else { setIsPlatformAdmin(false); setSchoolRole(null); }
|
|
||||||
}, [accessToken, checkAdminStatus]);
|
|
||||||
|
|
||||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget);
|
setAnchorEl(event.currentTarget);
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function AddStudentDialog({ open, onClose, onAdd, accessToken, existingIds }: Ad
|
|||||||
const ClassDetailPage: React.FC = () => {
|
const ClassDetailPage: React.FC = () => {
|
||||||
const { classId } = useParams<{ classId: string }>();
|
const { classId } = useParams<{ classId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { accessToken, user } = useAuth();
|
const { accessToken, user, bootstrapData } = useAuth();
|
||||||
|
|
||||||
const [cls, setCls] = useState<ClassDetail | null>(null);
|
const [cls, setCls] = useState<ClassDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -131,24 +131,19 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [clsRes, roleRes] = await Promise.all([
|
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
|
||||||
fetch(`${API_BASE}/classes/${classId}`, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
}).then(r => r.json()),
|
}).then(r => r.json());
|
||||||
fetch(`${API_BASE}/school/status`, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
}).then(r => r.json()),
|
|
||||||
]);
|
|
||||||
if (clsRes.id) setCls(clsRes);
|
if (clsRes.id) setCls(clsRes);
|
||||||
else setError(clsRes.detail || 'Class not found');
|
else setError(clsRes.detail || 'Class not found');
|
||||||
const role = roleRes.user_role || '';
|
const role = bootstrapData?.active_institute?.membership_role || '';
|
||||||
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
setIsAdmin(role === 'school_admin' || role === 'department_head');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [accessToken, classId]);
|
}, [accessToken, classId, bootstrapData]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Container,
|
Container,
|
||||||
Grid,
|
Grid,
|
||||||
Paper,
|
Paper,
|
||||||
@ -10,17 +12,28 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser, bootstrapData } = useAuth();
|
||||||
const { profile, loading } = useUser();
|
const { profile, loading } = useUser();
|
||||||
|
|
||||||
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
|
const displayName = profile?.display_name || authUser?.display_name || authUser?.username || 'Member';
|
||||||
const emailAddress = profile?.email || authUser?.email || '';
|
const emailAddress = profile?.email || authUser?.email || '';
|
||||||
const userType = profile?.user_type || authUser?.user_type || '';
|
const userType = profile?.user_type || authUser?.user_type || '';
|
||||||
|
|
||||||
|
const activeInstitute = bootstrapData?.active_institute;
|
||||||
|
const activeMembership = bootstrapData?.memberships?.find(
|
||||||
|
(m) => m.institute_id === activeInstitute?.id && m.is_active
|
||||||
|
);
|
||||||
|
const schoolName = activeMembership?.institute?.name;
|
||||||
|
const onboarding = bootstrapData?.onboarding;
|
||||||
|
const calendarOk = bootstrapData?.calendar_status?.available && !bootstrapData?.calendar_status?.needs_setup;
|
||||||
|
const timetableOk = bootstrapData?.timetable_status?.available && !bootstrapData?.timetable_status?.needs_setup;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
@ -88,6 +101,79 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
{/* Bootstrap Status Section */}
|
||||||
|
{bootstrapData && (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h5" component="h2">
|
||||||
|
Your school
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper elevation={2} sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Institute
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="body1" fontWeight={600}>
|
||||||
|
{schoolName || 'No school assigned'}
|
||||||
|
</Typography>
|
||||||
|
{activeInstitute?.membership_role && (
|
||||||
|
<Chip
|
||||||
|
label={activeInstitute.membership_role.replace(/_/g, ' ')}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Status: {bootstrapData.school_status}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper elevation={2} sx={{ p: 3, height: '100%' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
System status
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{calendarOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Calendar: {calendarOk ? 'Ready' : 'Needs setup'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{timetableOk ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Timetable: {timetableOk ? 'Ready' : 'Needs setup'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{bootstrapData?.graph_status?.available ? <CheckCircleIcon color="success" fontSize="small" /> : <WarningIcon color="warning" fontSize="small" />}
|
||||||
|
<Typography variant="body2">
|
||||||
|
Graph projection: {bootstrapData?.graph_status?.projection_state || 'unknown'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{onboarding && onboarding.next_step !== 'complete' && (
|
||||||
|
<Alert severity="info" sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{onboarding.message}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||||
|
Next step: {onboarding.next_step.replace(/_/g, ' ')}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
123
src/services/bootstrap/bootstrapService.ts
Normal file
123
src/services/bootstrap/bootstrapService.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
||||||
|
|
||||||
|
export interface BootstrapProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
user_type: string;
|
||||||
|
school_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapMembership {
|
||||||
|
institute_id: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
is_active: boolean;
|
||||||
|
institute: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urn: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapActiveInstitute {
|
||||||
|
id: string | null;
|
||||||
|
source: string;
|
||||||
|
membership_role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapPermissions {
|
||||||
|
platform_admin: boolean;
|
||||||
|
platform_super_admin: boolean;
|
||||||
|
can_create_school: boolean;
|
||||||
|
can_manage_school: boolean;
|
||||||
|
can_manage_calendar: boolean;
|
||||||
|
can_manage_timetable: boolean;
|
||||||
|
can_invite_staff: boolean;
|
||||||
|
can_manage_classes: boolean;
|
||||||
|
can_view_student_data: boolean;
|
||||||
|
can_use_canvas: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapOnboarding {
|
||||||
|
next_step: string;
|
||||||
|
required: string[];
|
||||||
|
optional: string[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapCalendarStatus {
|
||||||
|
available: boolean;
|
||||||
|
academic_year_count: number;
|
||||||
|
term_count: number;
|
||||||
|
current_academic_year_id: string | null;
|
||||||
|
needs_setup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapTimetableStatus {
|
||||||
|
available: boolean;
|
||||||
|
teacher_timetable_id: string | null;
|
||||||
|
slot_count: number;
|
||||||
|
needs_setup: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapGraphStatus {
|
||||||
|
available: boolean;
|
||||||
|
user_db: string | null;
|
||||||
|
institute_db: string | null;
|
||||||
|
projection_state: string;
|
||||||
|
needs_rebuild: boolean;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapResponse {
|
||||||
|
profile: BootstrapProfile;
|
||||||
|
memberships: BootstrapMembership[];
|
||||||
|
active_institute: BootstrapActiveInstitute;
|
||||||
|
permissions: BootstrapPermissions;
|
||||||
|
school_status: string;
|
||||||
|
onboarding: BootstrapOnboarding;
|
||||||
|
calendar_status: BootstrapCalendarStatus;
|
||||||
|
timetable_status: BootstrapTimetableStatus;
|
||||||
|
graph_status: BootstrapGraphStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedBootstrap: BootstrapResponse | null = null;
|
||||||
|
let cacheTimestamp = 0;
|
||||||
|
const CACHE_TTL_MS = 60_000; // 60 second cache
|
||||||
|
|
||||||
|
export async function fetchBootstrapData(accessToken: string): Promise<BootstrapResponse | null> {
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
// Return cached data if fresh
|
||||||
|
if (cachedBootstrap && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
|
||||||
|
return cachedBootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/me/bootstrap`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn('bootstrap-service', `Bootstrap fetch failed: ${res.status}`);
|
||||||
|
return cachedBootstrap; // Return stale cache on error
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BootstrapResponse = await res.json();
|
||||||
|
cachedBootstrap = data;
|
||||||
|
cacheTimestamp = Date.now();
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('bootstrap-service', 'Bootstrap fetch error', { err });
|
||||||
|
return cachedBootstrap; // Return stale cache on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateBootstrapCache(): void {
|
||||||
|
cachedBootstrap = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
@ -483,11 +483,10 @@ function TreeItem({ node, depth, onSelect, onExpand }: TreeItemProps) {
|
|||||||
// ─── Main Panel ───────────────────────────────────────────────────────────────
|
// ─── Main Panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CCGraphNavPanel() {
|
export function CCGraphNavPanel() {
|
||||||
const { accessToken } = useAuth();
|
const { accessToken, bootstrapData } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { navigateToNeoNode, context } = useNavigationStore();
|
const { navigateToNeoNode, context } = useNavigationStore();
|
||||||
const [tree, setTree] = useState<TreeNode | null>(null);
|
const [tree, setTree] = useState<TreeNode | null>(null);
|
||||||
const [schoolStatus, setSchoolStatus] = useState<SchoolStatus | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -526,19 +525,31 @@ export function CCGraphNavPanel() {
|
|||||||
}
|
}
|
||||||
}, [accessToken, apiBase]);
|
}, [accessToken, apiBase]);
|
||||||
|
|
||||||
const fetchSchoolStatus = useCallback(async () => {
|
const schoolStatus = useMemo<SchoolStatus | null>(() => {
|
||||||
if (!accessToken) return;
|
if (!bootstrapData) return null;
|
||||||
try {
|
const membershipRole = bootstrapData.active_institute?.membership_role ?? undefined;
|
||||||
const res = await fetch(`${apiBase}/school/status`, {
|
const activeMembership = bootstrapData.memberships?.find(
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
(m) => m.institute_id === bootstrapData.active_institute?.id && m.is_active
|
||||||
});
|
);
|
||||||
if (!res.ok) return;
|
return {
|
||||||
const data = await res.json();
|
status: bootstrapData.school_status,
|
||||||
setSchoolStatus(data);
|
user_role: membershipRole,
|
||||||
} catch {
|
school_id: bootstrapData.active_institute?.id ?? undefined,
|
||||||
// non-fatal — panel still works without school status
|
school_has_calendar: bootstrapData.calendar_status.available && !bootstrapData.calendar_status.needs_setup,
|
||||||
}
|
teacher_has_timetable: bootstrapData.timetable_status.available && !bootstrapData.timetable_status.needs_setup,
|
||||||
}, [accessToken, apiBase]);
|
timetable_id: bootstrapData.timetable_status.teacher_timetable_id,
|
||||||
|
periods_template: [],
|
||||||
|
school_info: activeMembership?.institute ? {
|
||||||
|
name: activeMembership.institute.name,
|
||||||
|
urn: activeMembership.institute.urn ?? '',
|
||||||
|
website: '',
|
||||||
|
address: {},
|
||||||
|
headteacher: '',
|
||||||
|
term_dates_url: '',
|
||||||
|
staff_list_url: '',
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
}, [bootstrapData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessToken && !tree) fetchTree();
|
if (accessToken && !tree) fetchTree();
|
||||||
@ -553,9 +564,6 @@ export function CCGraphNavPanel() {
|
|||||||
}
|
}
|
||||||
}, [tree]);
|
}, [tree]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (accessToken && !schoolStatus) fetchSchoolStatus();
|
|
||||||
}, [accessToken, schoolStatus, fetchSchoolStatus]);
|
|
||||||
|
|
||||||
// Fetch timetable term nodes when switching to term view
|
// Fetch timetable term nodes when switching to term view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -637,7 +645,6 @@ export function CCGraphNavPanel() {
|
|||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
setTree(null);
|
setTree(null);
|
||||||
setSchoolStatus(null);
|
|
||||||
setAcademicCalendarStatus('idle');
|
setAcademicCalendarStatus('idle');
|
||||||
setAcademicTerms([]);
|
setAcademicTerms([]);
|
||||||
}, []);
|
}, []);
|
||||||
@ -726,9 +733,8 @@ export function CCGraphNavPanel() {
|
|||||||
onClose={() => setOnboardingWizardOpen(false)}
|
onClose={() => setOnboardingWizardOpen(false)}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
setOnboardingWizardOpen(false);
|
setOnboardingWizardOpen(false);
|
||||||
// Reload tree + school status after successful onboarding
|
// Reload tree after successful onboarding; bootstrap state refreshes from AuthContext
|
||||||
setTree(null);
|
setTree(null);
|
||||||
setSchoolStatus(null);
|
|
||||||
}}
|
}}
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user