Phase 1 & 2: Authentication and Children Management
Completed Features:
- Full JWT authentication system with refresh tokens
- User registration and login with device fingerprinting
- Child profile CRUD operations with permission-based access
- Family management with roles and permissions
- Database migrations for core auth and family structure
- Comprehensive test coverage (37 unit + E2E tests)
Tech Stack:
- NestJS backend with TypeORM
- PostgreSQL database
- JWT authentication with Passport
- bcrypt password hashing
- Docker Compose for infrastructure
🤖 Generated with Claude Code
This commit is contained in:
724
docs/maternal-app-state-management.md
Normal file
724
docs/maternal-app-state-management.md
Normal file
@@ -0,0 +1,724 @@
|
||||
# State Management Schema - Maternal Organization App
|
||||
|
||||
## Store Architecture Overview
|
||||
|
||||
### Redux Toolkit Structure
|
||||
```typescript
|
||||
// Core principles:
|
||||
// - Single source of truth
|
||||
// - Normalized state shape
|
||||
// - Offline-first design
|
||||
// - Optimistic updates
|
||||
// - Automatic sync queue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Root Store Structure
|
||||
|
||||
```typescript
|
||||
interface RootState {
|
||||
auth: AuthState;
|
||||
user: UserState;
|
||||
family: FamilyState;
|
||||
children: ChildrenState;
|
||||
activities: ActivitiesState;
|
||||
ai: AIState;
|
||||
sync: SyncState;
|
||||
offline: OfflineState;
|
||||
ui: UIState;
|
||||
notifications: NotificationState;
|
||||
analytics: AnalyticsState;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
tokenExpiry: number | null;
|
||||
deviceFingerprint: string;
|
||||
trustedDevices: string[];
|
||||
authStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
error: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Actions
|
||||
```typescript
|
||||
// authSlice.ts
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginStart: (state) => {
|
||||
state.authStatus = 'loading';
|
||||
},
|
||||
loginSuccess: (state, action) => {
|
||||
state.isAuthenticated = true;
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.tokenExpiry = action.payload.expiresAt;
|
||||
state.authStatus = 'succeeded';
|
||||
},
|
||||
loginFailure: (state, action) => {
|
||||
state.authStatus = 'failed';
|
||||
state.error = action.payload;
|
||||
},
|
||||
tokenRefreshed: (state, action) => {
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.tokenExpiry = action.payload.expiresAt;
|
||||
},
|
||||
logout: (state) => {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface UserState {
|
||||
currentUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
photoUrl?: string;
|
||||
preferences: UserPreferences;
|
||||
} | null;
|
||||
subscription: {
|
||||
tier: 'free' | 'premium' | 'plus';
|
||||
expiresAt?: string;
|
||||
aiQueriesUsed: number;
|
||||
aiQueriesLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserPreferences {
|
||||
darkMode: 'auto' | 'light' | 'dark';
|
||||
notifications: {
|
||||
push: boolean;
|
||||
email: boolean;
|
||||
quietHoursStart?: string;
|
||||
quietHoursEnd?: string;
|
||||
};
|
||||
measurementUnit: 'metric' | 'imperial';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Family Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface FamilyState {
|
||||
currentFamily: {
|
||||
id: string;
|
||||
name: string;
|
||||
shareCode: string;
|
||||
createdBy: string;
|
||||
} | null;
|
||||
members: {
|
||||
byId: Record<string, FamilyMember>;
|
||||
allIds: string[];
|
||||
};
|
||||
invitations: Invitation[];
|
||||
loadingStatus: LoadingStatus;
|
||||
}
|
||||
|
||||
interface FamilyMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'parent' | 'caregiver' | 'viewer';
|
||||
permissions: Permissions;
|
||||
lastActive: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Normalized Actions
|
||||
```typescript
|
||||
const familySlice = createSlice({
|
||||
name: 'family',
|
||||
initialState,
|
||||
reducers: {
|
||||
memberAdded: (state, action) => {
|
||||
const member = action.payload;
|
||||
state.members.byId[member.id] = member;
|
||||
state.members.allIds.push(member.id);
|
||||
},
|
||||
memberUpdated: (state, action) => {
|
||||
const { id, changes } = action.payload;
|
||||
state.members.byId[id] = {
|
||||
...state.members.byId[id],
|
||||
...changes,
|
||||
};
|
||||
},
|
||||
memberRemoved: (state, action) => {
|
||||
const id = action.payload;
|
||||
delete state.members.byId[id];
|
||||
state.members.allIds = state.members.allIds.filter(mid => mid !== id);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Children Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface ChildrenState {
|
||||
children: {
|
||||
byId: Record<string, Child>;
|
||||
allIds: string[];
|
||||
};
|
||||
activeChildId: string | null;
|
||||
milestones: {
|
||||
byChildId: Record<string, Milestone[]>;
|
||||
};
|
||||
}
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string;
|
||||
gender?: string;
|
||||
photoUrl?: string;
|
||||
medical: {
|
||||
bloodType?: string;
|
||||
allergies: string[];
|
||||
conditions: string[];
|
||||
medications: Medication[];
|
||||
};
|
||||
metrics: {
|
||||
currentWeight?: Measurement;
|
||||
currentHeight?: Measurement;
|
||||
headCircumference?: Measurement;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Activities Slice (Normalized)
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface ActivitiesState {
|
||||
activities: {
|
||||
byId: Record<string, Activity>;
|
||||
allIds: string[];
|
||||
byChild: Record<string, string[]>; // childId -> activityIds
|
||||
byDate: Record<string, string[]>; // date -> activityIds
|
||||
};
|
||||
activeTimers: {
|
||||
[childId: string]: ActiveTimer;
|
||||
};
|
||||
filters: {
|
||||
childId?: string;
|
||||
dateRange?: { start: string; end: string };
|
||||
types?: ActivityType[];
|
||||
};
|
||||
pagination: {
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
childId: string;
|
||||
type: ActivityType;
|
||||
timestamp: string;
|
||||
duration?: number;
|
||||
details: ActivityDetails;
|
||||
loggedBy: string;
|
||||
syncStatus: 'synced' | 'pending' | 'error';
|
||||
version: number; // For conflict resolution
|
||||
}
|
||||
|
||||
interface ActiveTimer {
|
||||
activityType: ActivityType;
|
||||
startTime: number;
|
||||
pausedDuration: number;
|
||||
isPaused: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Activity Actions
|
||||
```typescript
|
||||
const activitiesSlice = createSlice({
|
||||
name: 'activities',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Optimistic update
|
||||
activityLogged: (state, action) => {
|
||||
const activity = {
|
||||
...action.payload,
|
||||
syncStatus: 'pending',
|
||||
};
|
||||
state.activities.byId[activity.id] = activity;
|
||||
state.activities.allIds.unshift(activity.id);
|
||||
|
||||
// Update indexes
|
||||
if (!state.activities.byChild[activity.childId]) {
|
||||
state.activities.byChild[activity.childId] = [];
|
||||
}
|
||||
state.activities.byChild[activity.childId].unshift(activity.id);
|
||||
},
|
||||
|
||||
// Sync confirmed
|
||||
activitySynced: (state, action) => {
|
||||
const { localId, serverId } = action.payload;
|
||||
state.activities.byId[localId].id = serverId;
|
||||
state.activities.byId[localId].syncStatus = 'synced';
|
||||
},
|
||||
|
||||
// Timer management
|
||||
timerStarted: (state, action) => {
|
||||
const { childId, activityType } = action.payload;
|
||||
state.activeTimers[childId] = {
|
||||
activityType,
|
||||
startTime: Date.now(),
|
||||
pausedDuration: 0,
|
||||
isPaused: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface AIState {
|
||||
conversations: {
|
||||
byId: Record<string, Conversation>;
|
||||
activeId: string | null;
|
||||
};
|
||||
insights: {
|
||||
byChildId: Record<string, Insight[]>;
|
||||
pending: Insight[];
|
||||
};
|
||||
predictions: {
|
||||
byChildId: Record<string, Predictions>;
|
||||
};
|
||||
quotas: {
|
||||
dailyQueries: number;
|
||||
dailyLimit: number;
|
||||
resetAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
childId?: string;
|
||||
messages: Message[];
|
||||
context: ConversationContext;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
interface Predictions {
|
||||
nextNapTime?: { time: string; confidence: number };
|
||||
nextFeedingTime?: { time: string; confidence: number };
|
||||
growthSpurt?: { likelihood: number; expectedIn: string };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sync Slice (Critical for Offline)
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface SyncState {
|
||||
queue: SyncQueueItem[];
|
||||
conflicts: ConflictItem[];
|
||||
lastSync: {
|
||||
[entityType: string]: string; // ISO timestamp
|
||||
};
|
||||
syncStatus: 'idle' | 'syncing' | 'error' | 'offline';
|
||||
retryCount: number;
|
||||
webSocket: {
|
||||
connected: boolean;
|
||||
reconnectAttempts: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncQueueItem {
|
||||
id: string;
|
||||
type: 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
entity: 'activity' | 'child' | 'family';
|
||||
payload: any;
|
||||
timestamp: string;
|
||||
retries: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ConflictItem {
|
||||
id: string;
|
||||
localVersion: any;
|
||||
serverVersion: any;
|
||||
strategy: 'manual' | 'local' | 'server' | 'merge';
|
||||
}
|
||||
```
|
||||
|
||||
### Sync Actions
|
||||
```typescript
|
||||
const syncSlice = createSlice({
|
||||
name: 'sync',
|
||||
initialState,
|
||||
reducers: {
|
||||
addToQueue: (state, action) => {
|
||||
state.queue.push({
|
||||
id: nanoid(),
|
||||
...action.payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
retries: 0,
|
||||
});
|
||||
},
|
||||
|
||||
removeFromQueue: (state, action) => {
|
||||
state.queue = state.queue.filter(item => item.id !== action.payload);
|
||||
},
|
||||
|
||||
conflictDetected: (state, action) => {
|
||||
state.conflicts.push(action.payload);
|
||||
},
|
||||
|
||||
conflictResolved: (state, action) => {
|
||||
const { id, resolution } = action.payload;
|
||||
state.conflicts = state.conflicts.filter(c => c.id !== id);
|
||||
// Apply resolution...
|
||||
},
|
||||
|
||||
syncCompleted: (state, action) => {
|
||||
state.lastSync[action.payload.entity] = new Date().toISOString();
|
||||
state.syncStatus = 'idle';
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Offline Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface OfflineState {
|
||||
isOnline: boolean;
|
||||
queuedActions: OfflineAction[];
|
||||
cachedData: {
|
||||
[key: string]: {
|
||||
data: any;
|
||||
timestamp: string;
|
||||
ttl: number;
|
||||
};
|
||||
};
|
||||
retryPolicy: {
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
backoffMultiplier: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface OfflineAction {
|
||||
id: string;
|
||||
action: AnyAction;
|
||||
meta: {
|
||||
offline: {
|
||||
effect: any;
|
||||
commit: AnyAction;
|
||||
rollback: AnyAction;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Slice
|
||||
|
||||
### State Shape
|
||||
```typescript
|
||||
interface UIState {
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
activeScreen: string;
|
||||
modals: {
|
||||
[modalId: string]: {
|
||||
isOpen: boolean;
|
||||
data?: any;
|
||||
};
|
||||
};
|
||||
loading: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
errors: {
|
||||
[key: string]: ErrorInfo;
|
||||
};
|
||||
toasts: Toast[];
|
||||
bottomSheet: {
|
||||
isOpen: boolean;
|
||||
content: 'quickActions' | 'activityDetails' | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
duration: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Middleware Configuration
|
||||
|
||||
### Store Setup
|
||||
```typescript
|
||||
// store/index.ts
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
FLUSH,
|
||||
REHYDRATE,
|
||||
PAUSE,
|
||||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
} from 'redux-persist';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['auth', 'user', 'children', 'activities'],
|
||||
blacklist: ['ui', 'sync'], // Don't persist UI state
|
||||
};
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
user: userReducer,
|
||||
family: familyReducer,
|
||||
children: childrenReducer,
|
||||
activities: activitiesReducer,
|
||||
ai: aiReducer,
|
||||
sync: syncReducer,
|
||||
offline: offlineReducer,
|
||||
ui: uiReducer,
|
||||
notifications: notificationsReducer,
|
||||
analytics: analyticsReducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: persistedReducer,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
|
||||
},
|
||||
}).concat([
|
||||
syncMiddleware,
|
||||
offlineMiddleware,
|
||||
analyticsMiddleware,
|
||||
conflictResolutionMiddleware,
|
||||
]),
|
||||
});
|
||||
|
||||
export const persistor = persistStore(store);
|
||||
```
|
||||
|
||||
### Sync Middleware
|
||||
```typescript
|
||||
// middleware/syncMiddleware.ts
|
||||
export const syncMiddleware: Middleware = (store) => (next) => (action) => {
|
||||
const result = next(action);
|
||||
|
||||
// Queue actions for sync
|
||||
if (action.type.includes('activities/') && !action.meta?.skipSync) {
|
||||
const state = store.getState();
|
||||
|
||||
if (!state.offline.isOnline) {
|
||||
store.dispatch(addToQueue({
|
||||
type: 'UPDATE',
|
||||
entity: 'activity',
|
||||
payload: action.payload,
|
||||
}));
|
||||
} else {
|
||||
// Sync immediately
|
||||
syncActivity(action.payload);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
```
|
||||
|
||||
### Offline Middleware
|
||||
```typescript
|
||||
// middleware/offlineMiddleware.ts
|
||||
export const offlineMiddleware: Middleware = (store) => (next) => (action) => {
|
||||
// Check network status
|
||||
if (action.type === 'network/statusChanged') {
|
||||
const isOnline = action.payload;
|
||||
|
||||
if (isOnline && store.getState().sync.queue.length > 0) {
|
||||
// Process offline queue
|
||||
store.dispatch(processOfflineQueue());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle optimistic updates
|
||||
if (action.meta?.offline) {
|
||||
const { effect, commit, rollback } = action.meta.offline;
|
||||
|
||||
// Apply optimistic update
|
||||
next(action);
|
||||
|
||||
// Attempt sync
|
||||
effect()
|
||||
.then(() => next(commit))
|
||||
.catch(() => next(rollback));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selectors
|
||||
|
||||
### Memoized Selectors
|
||||
```typescript
|
||||
// selectors/activities.ts
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const selectActivitiesByChild = createSelector(
|
||||
[(state: RootState) => state.activities.activities.byId,
|
||||
(state: RootState, childId: string) => state.activities.activities.byChild[childId]],
|
||||
(byId, activityIds = []) =>
|
||||
activityIds.map(id => byId[id]).filter(Boolean)
|
||||
);
|
||||
|
||||
export const selectTodaysSummary = createSelector(
|
||||
[(state: RootState, childId: string) => selectActivitiesByChild(state, childId)],
|
||||
(activities) => {
|
||||
const today = new Date().toDateString();
|
||||
const todaysActivities = activities.filter(
|
||||
a => new Date(a.timestamp).toDateString() === today
|
||||
);
|
||||
|
||||
return {
|
||||
feedings: todaysActivities.filter(a => a.type === 'feeding').length,
|
||||
sleepHours: calculateSleepHours(todaysActivities),
|
||||
diapers: todaysActivities.filter(a => a.type === 'diaper').length,
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
### Conflict Resolution Strategy
|
||||
```typescript
|
||||
// utils/conflictResolution.ts
|
||||
export const resolveConflict = (
|
||||
local: Activity,
|
||||
remote: Activity
|
||||
): Activity => {
|
||||
// Last write wins for simple conflicts
|
||||
if (local.version === remote.version) {
|
||||
return local.timestamp > remote.timestamp ? local : remote;
|
||||
}
|
||||
|
||||
// Server version is higher - merge changes
|
||||
if (remote.version > local.version) {
|
||||
return {
|
||||
...remote,
|
||||
// Preserve local notes if different
|
||||
details: {
|
||||
...remote.details,
|
||||
notes: local.details.notes !== remote.details.notes
|
||||
? `${remote.details.notes}\n---\n${local.details.notes}`
|
||||
: remote.details.notes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Local version is higher
|
||||
return local;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Normalized State Updates
|
||||
```typescript
|
||||
// Batch updates for performance
|
||||
const activitiesSlice = createSlice({
|
||||
name: 'activities',
|
||||
reducers: {
|
||||
activitiesBatchUpdated: (state, action) => {
|
||||
const activities = action.payload;
|
||||
|
||||
// Use immer's batching
|
||||
activities.forEach(activity => {
|
||||
state.activities.byId[activity.id] = activity;
|
||||
if (!state.activities.allIds.includes(activity.id)) {
|
||||
state.activities.allIds.push(activity.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
```typescript
|
||||
// Lazy load historical data
|
||||
export const loadMoreActivities = createAsyncThunk(
|
||||
'activities/loadMore',
|
||||
async ({ cursor, limit = 20 }, { getState }) => {
|
||||
const response = await api.getActivities({ cursor, limit });
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
condition: (_, { getState }) => {
|
||||
const state = getState() as RootState;
|
||||
return !state.activities.pagination.isLoading;
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
Reference in New Issue
Block a user