feat: Implement bulk activity operations and multi-child queries
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2025-10-04 21:18:15 +00:00
parent 9b17f5ec97
commit cdc4845f1a
5 changed files with 270 additions and 11 deletions

View File

@@ -54,6 +54,12 @@ export class Activity {
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@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;

View File

@@ -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(

View File

@@ -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<string, any>;
}
export { ActivityType };

View File

@@ -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: {

View File

@@ -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<Activity[]> {
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<Activity[]> {
// 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);
},
);
}
}