feat: Implement Phase 1 multi-child backend infrastructure
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

Database Migrations:
- V017: Add child display preferences (display_color, sort_order, nickname)
- V018: Create multi_child_preferences table for UI settings
- V019: Add bulk operation tracking to activities table

Backend Enhancements:
- Updated Child entity with display_color, sort_order, nickname fields
- Auto-assign colors to children based on family order (8-color palette)
- Allow custom color selection during child creation/update
- Added getFamilyStatistics() endpoint for UI view mode decisions
- Added userHasAccessToChild() helper for authorization
- Updated all API responses to include display fields

DTOs:
- Added displayColor (hex validation) and nickname to CreateChildDto
- UpdateChildDto inherits new fields automatically

API:
- GET /api/v1/children/family/:familyId/statistics - Family stats
- All child endpoints now return displayColor, sortOrder, nickname
- Custom colors validated with regex pattern

Color Palette:
Pink (#FF6B9D), Teal (#4ECDC4), Yellow (#FFD93D), Mint (#95E1D3)
Lavender (#C7CEEA), Orange (#FF8C42), Green (#A8E6CF), Purple (#B8B8FF)

🤖 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:13:05 +00:00
parent 95ef0e5e78
commit 9b17f5ec97
7 changed files with 338 additions and 0 deletions

View File

@@ -32,6 +32,15 @@ export class Child {
@Column({ name: 'photo_alt', type: 'text', nullable: true })
photoAlt?: string;
@Column({ name: 'display_color', length: 7, default: '#FF6B9D' })
displayColor: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ length: 50, nullable: true })
nickname?: string;
@Column({ name: 'medical_info', type: 'jsonb', default: {} })
medicalInfo: Record<string, any>;

View File

@@ -0,0 +1,43 @@
-- Migration V017: Add child display preferences
-- Created: 2025-10-04
-- Description: Adds display_color, sort_order, and nickname fields for multi-child UI support
-- Add display preferences columns to children table
ALTER TABLE children
ADD COLUMN display_color VARCHAR(7) DEFAULT '#FF6B9D',
ADD COLUMN sort_order INTEGER DEFAULT 0,
ADD COLUMN nickname VARCHAR(50);
-- Update existing children with colors based on creation order within each family
WITH numbered_children AS (
SELECT
id,
family_id,
ROW_NUMBER() OVER (PARTITION BY family_id ORDER BY created_at) - 1 AS row_num
FROM children
WHERE deleted_at IS NULL
)
UPDATE children c
SET
display_color = CASE (nc.row_num % 8)
WHEN 0 THEN '#FF6B9D' -- Pink
WHEN 1 THEN '#4ECDC4' -- Teal
WHEN 2 THEN '#FFD93D' -- Yellow
WHEN 3 THEN '#95E1D3' -- Mint
WHEN 4 THEN '#C7CEEA' -- Lavender
WHEN 5 THEN '#FF8C42' -- Orange
WHEN 6 THEN '#A8E6CF' -- Green
WHEN 7 THEN '#B8B8FF' -- Purple
END,
sort_order = nc.row_num
FROM numbered_children nc
WHERE c.id = nc.id;
-- Create composite index for efficient family queries with sorting
CREATE INDEX IF NOT EXISTS idx_children_family_sort
ON children(family_id, sort_order, deleted_at);
-- Add comments for documentation
COMMENT ON COLUMN children.display_color IS 'Hex color code for consistent UI display across charts and cards';
COMMENT ON COLUMN children.sort_order IS 'Display order within family (0-based, typically by birth order)';
COMMENT ON COLUMN children.nickname IS 'Optional shortened name for voice commands and quick selection';

View File

@@ -0,0 +1,78 @@
-- Migration V018: Create multi-child UI preferences table
-- Created: 2025-10-04
-- Description: Stores user preferences for multi-child UI behavior and default selections
CREATE TABLE IF NOT EXISTS multi_child_preferences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- View Mode Preferences
view_mode VARCHAR(20) DEFAULT 'auto', -- 'tabs', 'cards', 'auto'
-- Default Child Selection
default_child_id VARCHAR(20) REFERENCES children(id) ON DELETE SET NULL,
-- Per-Screen Last Selected Child
last_selected_per_screen JSONB DEFAULT '{}'::jsonb,
-- Example: {"track_feeding": "child_id", "track_sleep": "child_id", "analytics": "child_id"}
-- Comparison Preferences
comparison_default_children JSONB DEFAULT '[]'::jsonb,
-- Example: ["child_id_1", "child_id_2"]
comparison_default_metric VARCHAR(50) DEFAULT 'sleep-patterns',
-- Options: 'sleep-patterns', 'feeding-frequency', 'growth-curves', 'activities'
comparison_date_range VARCHAR(20) DEFAULT '7days',
-- Options: '7days', '30days', '90days'
-- Bulk Operation Preferences
show_bulk_confirm BOOLEAN DEFAULT TRUE,
remember_last_child BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Create index for quick user lookups
CREATE INDEX IF NOT EXISTS idx_multi_child_preferences_user
ON multi_child_preferences(user_id);
-- Create index for default child lookups
CREATE INDEX IF NOT EXISTS idx_multi_child_preferences_default_child
ON multi_child_preferences(default_child_id)
WHERE default_child_id IS NOT NULL;
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_multi_child_preferences_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER multi_child_preferences_updated_at_trigger
BEFORE UPDATE ON multi_child_preferences
FOR EACH ROW
EXECUTE FUNCTION update_multi_child_preferences_updated_at();
-- Insert default preferences for existing users with 2+ children
INSERT INTO multi_child_preferences (user_id, view_mode)
SELECT DISTINCT fm.user_id, 'auto'
FROM family_members fm
INNER JOIN children c ON fm.family_id = c.family_id
WHERE c.deleted_at IS NULL
GROUP BY fm.user_id, fm.family_id
HAVING COUNT(DISTINCT c.id) >= 2
ON CONFLICT (user_id) DO NOTHING;
-- Add comments for documentation
COMMENT ON TABLE multi_child_preferences IS 'Stores user preferences for multi-child UI behavior';
COMMENT ON COLUMN multi_child_preferences.view_mode IS 'Dashboard view mode: tabs (1-3 kids), cards (4+), or auto';
COMMENT ON COLUMN multi_child_preferences.default_child_id IS 'Default child for quick actions';
COMMENT ON COLUMN multi_child_preferences.last_selected_per_screen IS 'JSON object mapping screen paths to last selected child ID';
COMMENT ON COLUMN multi_child_preferences.comparison_default_children IS 'JSON array of child IDs to compare by default';

View File

@@ -0,0 +1,45 @@
-- Migration V019: Add bulk operation tracking for activities
-- Created: 2025-10-04
-- Description: Adds columns to track activities logged simultaneously for multiple children
-- Add bulk operation tracking columns to activities table
ALTER TABLE activities
ADD COLUMN bulk_operation_id UUID,
ADD COLUMN siblings_count INTEGER DEFAULT 1;
-- Create index for bulk operation queries
CREATE INDEX IF NOT EXISTS idx_activities_bulk_operation
ON activities(bulk_operation_id)
WHERE bulk_operation_id IS NOT NULL;
-- Add foreign key constraint for child_id if not already exists
-- This ensures all activities are linked to valid children
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_name = 'fk_activities_child'
AND table_name = 'activities'
) THEN
ALTER TABLE activities
ADD CONSTRAINT fk_activities_child
FOREIGN KEY (child_id)
REFERENCES children(id)
ON DELETE CASCADE;
END IF;
END $$;
-- Optimize index for multi-child queries (child_id, type, started_at)
-- Note: activities table uses started_at instead of timestamp
CREATE INDEX IF NOT EXISTS idx_activities_multi_child_query
ON activities(child_id, type, started_at DESC);
-- Create composite index for efficient filtering by multiple children
-- This supports queries like: WHERE child_id IN ('id1', 'id2', 'id3')
CREATE INDEX IF NOT EXISTS idx_activities_child_started_at
ON activities(child_id, started_at DESC);
-- Add comments for documentation
COMMENT ON COLUMN activities.bulk_operation_id IS 'UUID linking activities logged simultaneously for multiple children';
COMMENT ON COLUMN activities.siblings_count IS 'Total number of children this activity was logged for (for UI context)';

View File

@@ -51,6 +51,9 @@ export class ChildrenController {
gender: child.gender,
photoUrl: child.photoUrl,
photoAlt: child.photoAlt,
displayColor: child.displayColor,
sortOrder: child.sortOrder,
nickname: child.nickname,
medicalInfo: child.medicalInfo,
createdAt: child.createdAt,
},
@@ -58,6 +61,23 @@ export class ChildrenController {
};
}
@Get('family/:familyId/statistics')
@HttpCode(HttpStatus.OK)
async getFamilyStatistics(
@CurrentUser() user: any,
@Param('familyId') familyId: string,
) {
const stats = await this.childrenService.getFamilyStatistics(
user.sub,
familyId,
);
return {
success: true,
data: stats,
};
}
@Get()
@HttpCode(HttpStatus.OK)
async findAll(@CurrentUser() user: any, @Query('familyId') familyId: string) {
@@ -82,6 +102,9 @@ export class ChildrenController {
gender: child.gender,
photoUrl: child.photoUrl,
photoAlt: child.photoAlt,
displayColor: child.displayColor,
sortOrder: child.sortOrder,
nickname: child.nickname,
medicalInfo: child.medicalInfo,
createdAt: child.createdAt,
})),
@@ -105,6 +128,9 @@ export class ChildrenController {
gender: child.gender,
photoUrl: child.photoUrl,
photoAlt: child.photoAlt,
displayColor: child.displayColor,
sortOrder: child.sortOrder,
nickname: child.nickname,
medicalInfo: child.medicalInfo,
createdAt: child.createdAt,
},
@@ -151,6 +177,9 @@ export class ChildrenController {
gender: child.gender,
photoUrl: child.photoUrl,
photoAlt: child.photoAlt,
displayColor: child.displayColor,
sortOrder: child.sortOrder,
nickname: child.nickname,
medicalInfo: child.medicalInfo,
createdAt: child.createdAt,
},

View File

@@ -13,6 +13,17 @@ import { UpdateChildDto } from './dto/update-child.dto';
@Injectable()
export class ChildrenService {
private readonly CHILD_COLORS = [
'#FF6B9D', // Pink
'#4ECDC4', // Teal
'#FFD93D', // Yellow
'#95E1D3', // Mint
'#C7CEEA', // Lavender
'#FF8C42', // Orange
'#A8E6CF', // Green
'#B8B8FF', // Purple
];
constructor(
@InjectRepository(Child)
private childRepository: Repository<Child>,
@@ -40,11 +51,25 @@ export class ChildrenService {
);
}
// Get family's existing children count to determine color and sort order
const existingChildren = await this.childRepository.find({
where: { familyId, deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
const sortOrder = existingChildren.length;
// Use custom color if provided, otherwise auto-assign
const displayColor = createChildDto.displayColor ||
this.CHILD_COLORS[sortOrder % this.CHILD_COLORS.length];
// Create child
const child = this.childRepository.create({
...createChildDto,
familyId,
birthDate: new Date(createChildDto.birthDate),
displayColor,
sortOrder,
});
return await this.childRepository.save(child);
@@ -135,6 +160,15 @@ export class ChildrenService {
if (updateChildDto.photoUrl !== undefined) {
child.photoUrl = updateChildDto.photoUrl;
}
if (updateChildDto.photoAlt !== undefined) {
child.photoAlt = updateChildDto.photoAlt;
}
if (updateChildDto.displayColor !== undefined) {
child.displayColor = updateChildDto.displayColor;
}
if (updateChildDto.nickname !== undefined) {
child.nickname = updateChildDto.nickname;
}
if (updateChildDto.medicalInfo !== undefined) {
child.medicalInfo = updateChildDto.medicalInfo;
}
@@ -215,4 +249,90 @@ export class ChildrenService {
.orderBy('child.birthDate', 'DESC')
.getMany();
}
/**
* Get family statistics for multi-child UI decisions
*/
async getFamilyStatistics(
userId: string,
familyId: string,
): Promise<{
totalChildren: number;
viewMode: 'tabs' | 'cards';
ageRange: { youngest: number; oldest: number } | null;
genderDistribution: { male: number; female: number; other: number };
}> {
// Verify user has access to family
const membership = await this.familyMemberRepository.findOne({
where: { userId, familyId },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this family');
}
const children = await this.findAll(userId, familyId);
if (children.length === 0) {
return {
totalChildren: 0,
viewMode: 'tabs',
ageRange: null,
genderDistribution: { male: 0, female: 0, other: 0 },
};
}
// Calculate ages in months
const ages = children.map((child) => {
const now = new Date();
const birthDate = new Date(child.birthDate);
return (
(now.getFullYear() - birthDate.getFullYear()) * 12 +
(now.getMonth() - birthDate.getMonth())
);
});
// Gender distribution
const genderDistribution = children.reduce(
(acc, child) => {
if (child.gender === 'male') acc.male++;
else if (child.gender === 'female') acc.female++;
else acc.other++;
return acc;
},
{ male: 0, female: 0, other: 0 },
);
return {
totalChildren: children.length,
viewMode: children.length <= 3 ? 'tabs' : 'cards',
ageRange: {
youngest: Math.min(...ages),
oldest: Math.max(...ages),
},
genderDistribution,
};
}
/**
* Check if user has access to a specific child
*/
async userHasAccessToChild(
userId: string,
childId: string,
): Promise<boolean> {
const child = await this.childRepository.findOne({
where: { id: childId, deletedAt: IsNull() },
});
if (!child) {
return false;
}
const membership = await this.familyMemberRepository.findOne({
where: { userId, familyId: child.familyId },
});
return !!membership;
}
}

View File

@@ -6,6 +6,8 @@ import {
IsEnum,
MinLength,
MaxLength,
Matches,
Length,
} from 'class-validator';
export enum Gender {
@@ -36,6 +38,18 @@ export class CreateChildDto {
@IsString()
photoAlt?: string;
@IsOptional()
@IsString()
@Matches(/^#[0-9A-F]{6}$/i, {
message: 'displayColor must be a valid hex color code (e.g., #FF6B9D)',
})
displayColor?: string;
@IsOptional()
@IsString()
@Length(1, 50)
nickname?: string;
@IsOptional()
@IsObject()
medicalInfo?: Record<string, any>;