feat: Implement bulk activity operations and multi-child queries
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user