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: {} })
|
@Column({ type: 'jsonb', default: {} })
|
||||||
metadata: Record<string, any>;
|
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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
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) {
|
notifyFamilyActivityUpdated(familyId: string, activity: any) {
|
||||||
this.server.to(`family:${familyId}`).emit('activityUpdated', activity);
|
this.server.to(`family:${familyId}`).emit('activityUpdated', activity);
|
||||||
this.logger.log(
|
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 { TrackingService } from './tracking.service';
|
||||||
import { CreateActivityDto } from './dto/create-activity.dto';
|
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||||
import { UpdateActivityDto } from './dto/update-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')
|
@Controller('api/v1/activities')
|
||||||
export class TrackingController {
|
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()
|
@Get()
|
||||||
async findAll(
|
async findAll(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Query('childId') childId: string,
|
@Query('childId') childId?: string,
|
||||||
|
@Query('childIds') childIdsParam?: string,
|
||||||
@Query('type') type?: string,
|
@Query('type') type?: string,
|
||||||
@Query('startDate') startDate?: string,
|
@Query('startDate') startDate?: string,
|
||||||
@Query('endDate') endDate?: 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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { FamilyMember } from '../../database/entities/family-member.entity';
|
|||||||
import { FamiliesGateway } from '../families/families.gateway';
|
import { FamiliesGateway } from '../families/families.gateway';
|
||||||
import { CreateActivityDto } from './dto/create-activity.dto';
|
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||||
import { UpdateActivityDto } from './dto/update-activity.dto';
|
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||||
|
import { CreateBulkActivitiesDto } from './dto/create-bulk-activities.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackingService {
|
export class TrackingService {
|
||||||
@@ -332,4 +333,151 @@ export class TrackingService {
|
|||||||
byType,
|
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