normogen/web/normogen-web/src/store/useStore.ts
goose cd5a62c983 feat(web): Phase 3a — migrate CRA to Vite + make the app runnable
The frontend was a Create React App scaffold (deprecated) where App.tsx was
still the stock 'Learn React' boilerplate — the implemented login/register pages
and stores were never mounted (no router). This swaps to Vite/TS5, fixes the
broken API client, wires a real router + dashboard, and lands on a runnable app.

Tooling swap (CRA -> Vite):
* Dropped react-scripts, web-vitals, @types/jest; added vite 6 + @vitejs/plugin-react
  + vitest 3 + jsdom; bumped typescript 4.9 -> 5.6, @types/node 16 -> 22.
* New vite.config.ts (with dev-server /api proxy -> backend, no CORS in dev),
  tsconfig.json (target es2022, moduleResolution bundler, vite/client types),
  tsconfig.node.json, vite-env.d.ts, root index.html.
* .env / .env.example (VITE_API_URL + VITE_API_TARGET). Renamed index.tsx ->
  main.tsx (theme provider + CssBaseline). Deleted all CRA boilerplate
  (App.css, logo.svg, App.test.tsx, reportWebVitals.ts, react-app-env.d.ts,
  CRA index.html/logos/readme) + the orphaned empty web/src/ and mobile/ trees.
* node_modules 479MB -> 265MB; lockfile 695KB -> 198KB.

API client correctness (services/api.ts):
* Env var process.env.REACT_APP_* -> import.meta.env.VITE_*; dropped the
  hardcoded http://solaria:8001/api (now /api via the dev proxy).
* Fixed contract mismatches: getCurrentUser /auth/me -> /users/me;
  updateMedication PUT -> POST /:id; deleteMedication DELETE -> POST /:id/delete.
* Added refreshToken() (/auth/refresh) and server-side logout() (/auth/logout).
* Removed the /lab-results block (no backend route).
* 401 interceptor now attempts ONE silent refresh (deduped) before bouncing to
  /login, instead of hard-redirecting on every 401.
* CreateMedicationRequest type now matches the backend (required route +
  profile_id fields).

Router + dashboard:
* Real App.tsx with BrowserRouter: /login, /register (public), / (protected
  Dashboard), catch-all -> /.
* New Dashboard.tsx: MUI AppBar + welcome card, loads the current user on mount,
  logout button. The shell feature UIs get built into next.
* LoginPage/RegisterPage navigate to / (was /dashboard); store logout is now
  async; AuthTokens type includes refresh_token.
* Minimal MUI theme.ts.

Verified: npm run build clean (TS5 strict + Vite), dev server serves the app +
proxies /api to the backend, vitest runs. Solaria round-trip confirmed all
corrected endpoints (/users/me 200, /auth/refresh 200, /auth/logout 204,
medication create 200).
2026-06-28 00:47:19 -03:00

385 lines
10 KiB
TypeScript

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { User, Medication, HealthStat, DrugInteraction } from '../types/api';
import apiService from '../services/api';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
loadUser: () => Promise<void>;
}
interface MedicationState {
medications: Medication[];
selectedMedication: Medication | null;
isLoading: boolean;
error: string | null;
// Actions
loadMedications: () => Promise<void>;
createMedication: (data: any) => Promise<void>;
updateMedication: (id: string, data: any) => Promise<void>;
deleteMedication: (id: string) => Promise<void>;
selectMedication: (medication: Medication | null) => void;
clearError: () => void;
}
interface HealthState {
stats: HealthStat[];
trends: any[];
isLoading: boolean;
error: string | null;
// Actions
loadStats: () => Promise<void>;
createStat: (data: any) => Promise<void>;
updateStat: (id: string, data: any) => Promise<void>;
deleteStat: (id: string) => Promise<void>;
loadTrends: () => Promise<void>;
clearError: () => void;
}
interface InteractionState {
interactions: DrugInteraction[];
isChecking: boolean;
error: string | null;
// Actions
checkInteractions: (medications: string[]) => Promise<void>;
checkNewMedication: (name: string, dosage: string) => Promise<void>;
clearInteractions: () => void;
clearError: () => void;
}
// Auth Store
export const useAuthStore = create<AuthState>()(
devtools(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await apiService.login(email, password);
set({
user: {
user_id: response.user_id,
username: response.username,
email: email,
role: 'patient',
},
token: response.token,
isAuthenticated: true,
isLoading: false,
});
} catch (error: any) {
set({
error: error.message || 'Login failed',
isLoading: false,
isAuthenticated: false,
});
throw error;
}
},
register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await apiService.register({
username,
email,
password,
role: 'patient',
});
set({
user: {
user_id: response.user_id,
username: response.username,
email: email,
role: 'patient',
},
token: response.token,
isAuthenticated: true,
isLoading: false,
});
} catch (error: any) {
set({
error: error.message || 'Registration failed',
isLoading: false,
isAuthenticated: false,
});
throw error;
}
},
logout: async () => {
await apiService.logout();
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
});
},
clearError: () => set({ error: null }),
loadUser: async () => {
const token = get().token;
if (!token) return;
set({ isLoading: true });
try {
const user = await apiService.getCurrentUser();
set({
user,
isAuthenticated: true,
isLoading: false,
});
} catch (error: any) {
set({
error: error.message,
isLoading: false,
isAuthenticated: false,
});
}
},
}),
{
name: 'normogen-auth',
partialize: (state) => ({
token: state.token,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
)
);
// Medication Store
export const useMedicationStore = create<MedicationState>()(
devtools((set, get) => ({
medications: [],
selectedMedication: null,
isLoading: false,
error: null,
loadMedications: async () => {
set({ isLoading: true, error: null });
try {
const medications = await apiService.getMedications();
set({ medications, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to load medications',
isLoading: false,
});
}
},
createMedication: async (data: any) => {
set({ isLoading: true, error: null });
try {
const medication = await apiService.createMedication(data);
set((state) => ({
medications: [...state.medications, medication],
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to create medication',
isLoading: false,
});
throw error;
}
},
updateMedication: async (id: string, data: any) => {
set({ isLoading: true, error: null });
try {
const updated = await apiService.updateMedication(id, data);
set((state) => ({
medications: state.medications.map((med) =>
med.medication_id === id ? updated : med
),
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to update medication',
isLoading: false,
});
throw error;
}
},
deleteMedication: async (id: string) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteMedication(id);
set((state) => ({
medications: state.medications.filter(
(med) => med.medication_id !== id
),
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to delete medication',
isLoading: false,
});
throw error;
}
},
selectMedication: (medication) => {
set({ selectedMedication: medication });
},
clearError: () => set({ error: null }),
}))
);
// Health Store
export const useHealthStore = create<HealthState>()(
devtools((set, get) => ({
stats: [],
trends: [],
isLoading: false,
error: null,
loadStats: async () => {
set({ isLoading: true, error: null });
try {
const stats = await apiService.getHealthStats();
set({ stats, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to load health stats',
isLoading: false,
});
}
},
createStat: async (data: any) => {
set({ isLoading: true, error: null });
try {
const stat = await apiService.createHealthStat(data);
set((state) => ({
stats: [...state.stats, stat],
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to create stat',
isLoading: false,
});
throw error;
}
},
updateStat: async (id: string, data: any) => {
set({ isLoading: true, error: null });
try {
const updated = await apiService.updateHealthStat(id, data);
set((state) => ({
stats: state.stats.map((stat) =>
stat.stat_id === id ? updated : stat
),
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to update stat',
isLoading: false,
});
throw error;
}
},
deleteStat: async (id: string) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteHealthStat(id);
set((state) => ({
stats: state.stats.filter((stat) => stat.stat_id !== id),
isLoading: false,
}));
} catch (error: any) {
set({
error: error.message || 'Failed to delete stat',
isLoading: false,
});
throw error;
}
},
loadTrends: async () => {
set({ isLoading: true, error: null });
try {
const trends = await apiService.getHealthTrends();
set({ trends, isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to load trends',
isLoading: false,
});
}
},
clearError: () => set({ error: null }),
}))
);
// Interaction Store (Phase 2.8)
export const useInteractionStore = create<InteractionState>()(
devtools((set, get) => ({
interactions: [],
isChecking: false,
error: null,
checkInteractions: async (medications: string[]) => {
set({ isChecking: true, error: null });
try {
const interactions = await apiService.checkInteractions(medications);
set({ interactions, isChecking: false });
} catch (error: any) {
set({
error: error.message || 'Failed to check interactions',
isChecking: false,
});
}
},
checkNewMedication: async (name: string, dosage: string) => {
set({ isChecking: true, error: null });
try {
const interactions = await apiService.checkNewMedication(name, dosage);
set({ interactions, isChecking: false });
} catch (error: any) {
set({
error: error.message || 'Failed to check medication',
isChecking: false,
});
}
},
clearInteractions: () => set({ interactions: [] }),
clearError: () => set({ error: null }),
}))
);