feat: Implement Phase 1 multi-child backend infrastructure
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:
@@ -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>;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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)';
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user