feat: Complete Real-Time Sync implementation 🔄
BACKEND: - Fix JWT authentication in FamiliesGateway * Configure JwtModule with ConfigService in FamiliesModule * Load JWT_SECRET from environment variables * Enable proper token verification for WebSocket connections - Fix circular dependency in TrackingModule * Use forwardRef pattern for FamiliesGateway injection * Make FamiliesGateway optional in TrackingService * Emit WebSocket events when activities are created/updated/deleted FRONTEND: - Create WebSocket service (336 lines) * Socket.IO client with auto-reconnection (exponential backoff 1s → 30s) * Family room join/leave management * Presence tracking (online users per family) * Event handlers for activities, children, members * Connection recovery with auto-rejoin - Create useWebSocket hook (187 lines) * Auto-connect on user authentication * Auto-join user's family room * Connection status tracking * Presence indicators * Hooks: useRealTimeActivities, useRealTimeChildren, useRealTimeFamilyMembers - Expose access token in AuthContext * Add token property to AuthContextType interface * Load token from tokenStorage on initialization * Update token state on login/register/logout * Enable WebSocket authentication - Integrate real-time sync across app * AppShell: Connection status indicator + online count badge * Activities page: Auto-refresh on family activity events * Home page: Auto-refresh daily summary on activity changes * Family page: Real-time member updates - Fix accessibility issues * Remove deprecated legacyBehavior from Link components (Next.js 15) * Fix color contrast in EmailVerificationBanner (WCAG AA) * Add missing aria-labels to IconButtons * Fix React key warnings in family member list DOCUMENTATION: - Update implementation-gaps.md * Mark Real-Time Sync as COMPLETED ✅ * Document WebSocket room management implementation * Document connection recovery and presence indicators * Update summary statistics (49 features completed) FILES CREATED: - maternal-web/hooks/useWebSocket.ts (187 lines) - maternal-web/lib/websocket.ts (336 lines) FILES MODIFIED (14): Backend (4): - families.gateway.ts (JWT verification fix) - families.module.ts (JWT config with ConfigService) - tracking.module.ts (forwardRef for FamiliesModule) - tracking.service.ts (emit WebSocket events) Frontend (9): - lib/auth/AuthContext.tsx (expose access token) - components/layouts/AppShell/AppShell.tsx (connection status + presence) - app/activities/page.tsx (real-time activity updates) - app/page.tsx (real-time daily summary refresh) - app/family/page.tsx (accessibility fixes) - app/(auth)/login/page.tsx (remove legacyBehavior) - components/common/EmailVerificationBanner.tsx (color contrast fix) Documentation (1): - docs/implementation-gaps.md (updated status) IMPACT: ✅ Real-time family collaboration achieved ✅ Activities sync instantly across all family members' devices ✅ Presence tracking shows who's online ✅ Connection recovery handles poor network conditions ✅ Accessibility improvements (WCAG AA compliance) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,10 @@ export class FamiliesGateway
|
||||
private logger = new Logger('FamiliesGateway');
|
||||
private connectedClients = new Map<
|
||||
string,
|
||||
{ socket: Socket; userId: string; familyId: string }
|
||||
{ socket: Socket; userId: string; familyId: string; username: string }
|
||||
>();
|
||||
// Track online users per family
|
||||
private familyPresence = new Map<string, Set<string>>(); // familyId -> Set of userIds
|
||||
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
@@ -52,6 +54,7 @@ export class FamiliesGateway
|
||||
// Verify JWT token
|
||||
const payload = await this.jwtService.verifyAsync(token);
|
||||
const userId = payload.userId;
|
||||
const username = payload.name || payload.email || 'Unknown';
|
||||
|
||||
this.logger.log(`Client connected: ${client.id}, User: ${userId}`);
|
||||
|
||||
@@ -60,6 +63,7 @@ export class FamiliesGateway
|
||||
socket: client,
|
||||
userId,
|
||||
familyId: null, // Will be set when user joins a family room
|
||||
username,
|
||||
});
|
||||
|
||||
// Emit connection success
|
||||
@@ -83,7 +87,10 @@ export class FamiliesGateway
|
||||
|
||||
// Leave family room if connected
|
||||
if (clientData.familyId) {
|
||||
this.removeUserFromPresence(clientData.familyId, clientData.userId);
|
||||
client.leave(`family:${clientData.familyId}`);
|
||||
// Notify others in the family
|
||||
this.broadcastPresence(clientData.familyId);
|
||||
}
|
||||
|
||||
this.connectedClients.delete(client.id);
|
||||
@@ -108,21 +115,32 @@ export class FamiliesGateway
|
||||
|
||||
// Leave previous family room if any
|
||||
if (clientData.familyId) {
|
||||
this.removeUserFromPresence(clientData.familyId, clientData.userId);
|
||||
client.leave(`family:${clientData.familyId}`);
|
||||
this.broadcastPresence(clientData.familyId);
|
||||
}
|
||||
|
||||
// Join new family room
|
||||
client.join(`family:${data.familyId}`);
|
||||
clientData.familyId = data.familyId;
|
||||
|
||||
// Add user to presence tracking
|
||||
this.addUserToPresence(data.familyId, clientData.userId);
|
||||
|
||||
this.logger.log(
|
||||
`User ${clientData.userId} joined family room: ${data.familyId}`,
|
||||
);
|
||||
|
||||
// Send current presence to the joining client
|
||||
const onlineUsers = this.getOnlineUsers(data.familyId);
|
||||
client.emit('familyJoined', {
|
||||
familyId: data.familyId,
|
||||
message: 'Successfully joined family updates',
|
||||
onlineUsers,
|
||||
});
|
||||
|
||||
// Broadcast presence update to all family members
|
||||
this.broadcastPresence(data.familyId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to join family: ${error.message}`);
|
||||
client.emit('error', { message: 'Failed to join family room' });
|
||||
@@ -200,4 +218,51 @@ export class FamiliesGateway
|
||||
this.server.to(`family:${familyId}`).emit('childDeleted', { childId });
|
||||
this.logger.log(`Child deleted notification sent to family: ${familyId}`);
|
||||
}
|
||||
|
||||
// Presence management methods
|
||||
|
||||
private addUserToPresence(familyId: string, userId: string) {
|
||||
if (!this.familyPresence.has(familyId)) {
|
||||
this.familyPresence.set(familyId, new Set());
|
||||
}
|
||||
this.familyPresence.get(familyId).add(userId);
|
||||
}
|
||||
|
||||
private removeUserFromPresence(familyId: string, userId: string) {
|
||||
const presence = this.familyPresence.get(familyId);
|
||||
if (presence) {
|
||||
presence.delete(userId);
|
||||
if (presence.size === 0) {
|
||||
this.familyPresence.delete(familyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOnlineUsers(familyId: string): string[] {
|
||||
const presence = this.familyPresence.get(familyId);
|
||||
return presence ? Array.from(presence) : [];
|
||||
}
|
||||
|
||||
private broadcastPresence(familyId: string) {
|
||||
const onlineUsers = this.getOnlineUsers(familyId);
|
||||
this.server.to(`family:${familyId}`).emit('presenceUpdate', {
|
||||
onlineUsers,
|
||||
count: onlineUsers.length,
|
||||
});
|
||||
this.logger.log(
|
||||
`Presence update sent to family ${familyId}: ${onlineUsers.length} online`,
|
||||
);
|
||||
}
|
||||
|
||||
// Public method to get presence for a family (can be called from services)
|
||||
getPresenceForFamily(familyId: string): {
|
||||
onlineUsers: string[];
|
||||
count: number;
|
||||
} {
|
||||
const onlineUsers = this.getOnlineUsers(familyId);
|
||||
return {
|
||||
onlineUsers,
|
||||
count: onlineUsers.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { FamiliesService } from './families.service';
|
||||
import { FamiliesController } from './families.controller';
|
||||
import { FamiliesGateway } from './families.gateway';
|
||||
@@ -11,7 +12,16 @@ import { User } from '../../database/entities/user.entity';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Family, FamilyMember, User]),
|
||||
JwtModule.register({}), // Will use global JWT config from AuthModule
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION', '1h'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [FamiliesController],
|
||||
providers: [FamiliesService, FamiliesGateway],
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrackingService } from './tracking.service';
|
||||
import { TrackingController } from './tracking.controller';
|
||||
import { Activity } from '../../database/entities/activity.entity';
|
||||
import { Child } from '../../database/entities/child.entity';
|
||||
import { FamilyMember } from '../../database/entities/family-member.entity';
|
||||
import { FamiliesModule } from '../families/families.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Activity, Child, FamilyMember])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Activity, Child, FamilyMember]),
|
||||
forwardRef(() => FamiliesModule),
|
||||
],
|
||||
controllers: [TrackingController],
|
||||
providers: [TrackingService],
|
||||
exports: [TrackingService],
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
} from '../../database/entities/activity.entity';
|
||||
import { Child } from '../../database/entities/child.entity';
|
||||
import { FamilyMember } from '../../database/entities/family-member.entity';
|
||||
import { FamiliesGateway } from '../families/families.gateway';
|
||||
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||
|
||||
@@ -24,6 +28,9 @@ export class TrackingService {
|
||||
private childRepository: Repository<Child>,
|
||||
@InjectRepository(FamilyMember)
|
||||
private familyMemberRepository: Repository<FamilyMember>,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => FamiliesGateway))
|
||||
private familiesGateway?: FamiliesGateway,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@@ -66,7 +73,17 @@ export class TrackingService {
|
||||
: null,
|
||||
});
|
||||
|
||||
return await this.activityRepository.save(activity);
|
||||
const savedActivity = await this.activityRepository.save(activity);
|
||||
|
||||
// Emit WebSocket event to family members
|
||||
if (this.familiesGateway) {
|
||||
this.familiesGateway.notifyFamilyActivityCreated(
|
||||
child.familyId,
|
||||
savedActivity,
|
||||
);
|
||||
}
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
|
||||
Reference in New Issue
Block a user