From cdc4845f1a8f7d459972dbe298b6f7d12442fe2e Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 4 Oct 2025 21:18:15 +0000 Subject: [PATCH] feat: Implement bulk activity operations and multi-child queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking API Enhancements: - POST /api/v1/activities/bulk - Create activities for multiple children simultaneously - GET /api/v1/activities?childIds=id1,id2,id3 - Query activities for multiple children - Backward compatible with single childId queries DTO: - CreateBulkActivitiesDto with childIds array validation - Supports all activity types (feeding, sleep, diaper, etc.) Service: - createBulkActivities() - Validates access, generates UUID for bulk operations - getActivitiesForMultipleChildren() - Query builder for multi-child filtering - Authorization checks for all children in bulk operations Activity Entity: - Added bulkOperationId (UUID) to link related activities - Added siblingsCount to show how many children activity was logged for WebSocket: - notifyFamilyBulkActivitiesCreated() broadcasts bulk operations to family members - Includes count, childrenCount, and bulkOperationId in event Security: - Verifies user has access to ALL children before bulk operations - Family-level permission checks - Cross-family bulk operations supported Use Cases: - "Both kids took a nap" - Log sleep for 2+ children at once - "All children had lunch" - Bulk feeding log - Multi-child analytics and comparisons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/database/entities/activity.entity.ts | 6 + .../src/modules/families/families.gateway.ts | 16 ++ .../dto/create-bulk-activities.dto.ts | 40 +++++ .../modules/tracking/tracking.controller.ts | 71 +++++++-- .../src/modules/tracking/tracking.service.ts | 148 ++++++++++++++++++ 5 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 maternal-app/maternal-app-backend/src/modules/tracking/dto/create-bulk-activities.dto.ts diff --git a/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts index 0d64f34..8b406e2 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts @@ -54,6 +54,12 @@ export class Activity { @Column({ type: 'jsonb', default: {} }) metadata: Record; + @Column({ name: 'bulk_operation_id', type: 'uuid', nullable: true }) + bulkOperationId: string | null; + + @Column({ name: 'siblings_count', type: 'integer', default: 1 }) + siblingsCount: number; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/maternal-app/maternal-app-backend/src/modules/families/families.gateway.ts b/maternal-app/maternal-app-backend/src/modules/families/families.gateway.ts index 8f8de7c..824bc66 100644 --- a/maternal-app/maternal-app-backend/src/modules/families/families.gateway.ts +++ b/maternal-app/maternal-app-backend/src/modules/families/families.gateway.ts @@ -173,6 +173,22 @@ export class FamiliesGateway ); } + notifyFamilyBulkActivitiesCreated( + familyId: string, + activities: any[], + childrenCount: number, + ) { + this.server.to(`family:${familyId}`).emit('bulkActivitiesCreated', { + activities, + count: activities.length, + childrenCount, + bulkOperationId: activities[0]?.bulkOperationId, + }); + this.logger.log( + `Bulk activities created notification sent to family: ${familyId} (${activities.length} activities for ${childrenCount} children)`, + ); + } + notifyFamilyActivityUpdated(familyId: string, activity: any) { this.server.to(`family:${familyId}`).emit('activityUpdated', activity); this.logger.log( diff --git a/maternal-app/maternal-app-backend/src/modules/tracking/dto/create-bulk-activities.dto.ts b/maternal-app/maternal-app-backend/src/modules/tracking/dto/create-bulk-activities.dto.ts new file mode 100644 index 0000000..2910356 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/tracking/dto/create-bulk-activities.dto.ts @@ -0,0 +1,40 @@ +import { + IsEnum, + IsString, + IsOptional, + IsDateString, + IsObject, + IsNotEmpty, + IsArray, + ArrayMinSize, +} from 'class-validator'; +import { ActivityType } from '../../../database/entities/activity.entity'; + +export class CreateBulkActivitiesDto { + @IsArray() + @ArrayMinSize(1, { message: 'At least one child ID is required' }) + @IsString({ each: true }) + childIds: string[]; + + @IsEnum(ActivityType) + @IsNotEmpty() + type: ActivityType; + + @IsDateString() + @IsNotEmpty() + startedAt: string; + + @IsDateString() + @IsOptional() + endedAt?: string; + + @IsString() + @IsOptional() + notes?: string; + + @IsObject() + @IsOptional() + metadata?: Record; +} + +export { ActivityType }; diff --git a/maternal-app/maternal-app-backend/src/modules/tracking/tracking.controller.ts b/maternal-app/maternal-app-backend/src/modules/tracking/tracking.controller.ts index a0710be..fa5944b 100644 --- a/maternal-app/maternal-app-backend/src/modules/tracking/tracking.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/tracking/tracking.controller.ts @@ -14,6 +14,8 @@ import { import { TrackingService } from './tracking.service'; import { CreateActivityDto } from './dto/create-activity.dto'; import { UpdateActivityDto } from './dto/update-activity.dto'; +import { CreateBulkActivitiesDto } from './dto/create-bulk-activities.dto'; +import { ActivityType } from '../../database/entities/activity.entity'; @Controller('api/v1/activities') export class TrackingController { @@ -49,32 +51,79 @@ export class TrackingController { }; } + @Post('bulk') + @HttpCode(HttpStatus.CREATED) + async createBulk( + @Req() req: any, + @Body() createBulkDto: CreateBulkActivitiesDto, + ) { + const activities = await this.trackingService.createBulkActivities( + req.user.userId, + createBulkDto, + ); + + return { + success: true, + data: { + activities, + count: activities.length, + bulkOperationId: activities[0]?.bulkOperationId || null, + }, + }; + } + @Get() async findAll( @Req() req: any, - @Query('childId') childId: string, + @Query('childId') childId?: string, + @Query('childIds') childIdsParam?: string, @Query('type') type?: string, @Query('startDate') startDate?: string, @Query('endDate') endDate?: string, ) { - if (!childId) { + // Support both single childId and multiple childIds (comma-separated) + let activities; + + if (childIdsParam) { + // Multi-child query + const childIds = childIdsParam.split(',').filter(id => id.trim()); + + if (childIds.length === 0) { + return { + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'At least one childId is required', + }, + }; + } + + activities = await this.trackingService.getActivitiesForMultipleChildren( + req.user.userId, + childIds, + type as ActivityType, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + ); + } else if (childId) { + // Single child query (backward compatibility) + activities = await this.trackingService.findAll( + req.user.userId, + childId, + type, + startDate, + endDate, + ); + } else { return { success: false, error: { code: 'VALIDATION_ERROR', - message: 'childId query parameter is required', + message: 'childId or childIds query parameter is required', }, }; } - const activities = await this.trackingService.findAll( - req.user.userId, - childId, - type, - startDate, - endDate, - ); - return { success: true, data: { diff --git a/maternal-app/maternal-app-backend/src/modules/tracking/tracking.service.ts b/maternal-app/maternal-app-backend/src/modules/tracking/tracking.service.ts index e967df6..1394f3a 100644 --- a/maternal-app/maternal-app-backend/src/modules/tracking/tracking.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/tracking/tracking.service.ts @@ -18,6 +18,7 @@ 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'; +import { CreateBulkActivitiesDto } from './dto/create-bulk-activities.dto'; @Injectable() export class TrackingService { @@ -332,4 +333,151 @@ export class TrackingService { byType, }; } + + /** + * Create activities for multiple children simultaneously (bulk logging) + */ + async createBulkActivities( + userId: string, + createBulkDto: CreateBulkActivitiesDto, + ): Promise { + const { childIds, ...activityData } = createBulkDto; + + // Verify all children exist and user has access + const children = await this.childRepository + .createQueryBuilder('child') + .where('child.id IN (:...childIds)', { childIds }) + .getMany(); + + if (children.length !== childIds.length) { + throw new NotFoundException( + 'One or more children not found', + ); + } + + // Verify user has permission for all children + const familyIds = [...new Set(children.map((c) => c.familyId))]; + for (const familyId of familyIds) { + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership) { + throw new ForbiddenException( + `You do not have access to one or more children`, + ); + } + + if (!membership.permissions['canLogActivities']) { + throw new ForbiddenException( + `You do not have permission to log activities for this family`, + ); + } + } + + // Generate bulk operation ID to link activities + const bulkOperationId = this.generateUUID(); + + // Create activities for all children + const activities = childIds.map((childId) => + this.activityRepository.create({ + ...activityData, + childId, + loggedBy: userId, + startedAt: new Date(activityData.startedAt), + endedAt: activityData.endedAt + ? new Date(activityData.endedAt) + : null, + bulkOperationId, + siblingsCount: childIds.length, + }), + ); + + const savedActivities = await this.activityRepository.save(activities); + + // Emit WebSocket events for each family + if (this.familiesGateway) { + for (const familyId of familyIds) { + const familyActivities = savedActivities.filter((activity) => + children.some( + (c) => c.id === activity.childId && c.familyId === familyId, + ), + ); + + this.familiesGateway.notifyFamilyBulkActivitiesCreated( + familyId, + familyActivities, + childIds.length, + ); + } + } + + return savedActivities; + } + + /** + * Get activities for multiple children (supports multi-child queries) + */ + async getActivitiesForMultipleChildren( + userId: string, + childIds: string[], + type?: ActivityType, + startDate?: Date, + endDate?: Date, + ): Promise { + // Verify all children exist and user has access + const children = await this.childRepository + .createQueryBuilder('child') + .where('child.id IN (:...childIds)', { childIds }) + .getMany(); + + if (children.length === 0) { + return []; + } + + // Verify user has permission for all children + const familyIds = [...new Set(children.map((c) => c.familyId))]; + for (const familyId of familyIds) { + const membership = await this.familyMemberRepository.findOne({ + where: { userId, familyId }, + }); + + if (!membership) { + throw new ForbiddenException( + 'You do not have access to one or more children', + ); + } + } + + // Build query + const queryBuilder = this.activityRepository + .createQueryBuilder('activity') + .where('activity.childId IN (:...childIds)', { childIds }) + .orderBy('activity.startedAt', 'DESC'); + + if (type) { + queryBuilder.andWhere('activity.type = :type', { type }); + } + + if (startDate) { + queryBuilder.andWhere('activity.startedAt >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('activity.startedAt <= :endDate', { endDate }); + } + + return queryBuilder.getMany(); + } + + private generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + } }