Feature: Feeding Log
Version: 1.5.0 Last Reviewed: 2026-03-15 Status: Approved
User Story
As a caregiver, I can log a feeding in less than 5 seconds so I don't lose track during sleep-deprived nights.
MVP Scope
- Timestamp (defaults to "now", editable)
- Type: breast, bottle, or pump (toggle)
- Started side: left or right (breast and pump - which side feeding/pumping began on)
- Both sides: boolean flag (did the feeding/pumping use both sides?)
- Duration: optional number field (minutes, NOT a timer)
- Amount: optional number field (stored as mL Float, displayed as oz for US users, bottle and pump)
- Milk source: breast_milk or formula (bottle only, optional)
- Breast milk type: fresh, refrigerated, frozen, colostrum, donor, antibody_rich (bottle + breast_milk only, optional)
- Notes: optional free-text field per feeding (max 1000 chars)
- List feedings with filtering by time range
- Edit existing feeding
- Delete feeding
NOT in MVP
- Timer-based duration (user manually enters minutes after feeding completes)
- Feeding position (lying down, sitting, etc.)
- Configurable feeding preferences (breastfeeding-first vs bottle-first UI layout)
- Smart time-of-day defaults
- Feeding goals or targets
- Reminders for next feeding
- Milk bag lifecycle tracking (see Future: Milk Tracking below)
API Contract
Create Feeding
POST /api/v1/children/:childId/feedings
Authorization: Bearer <token>
Request (breast, single side):
{
"type": "breast",
"started_side": "left",
"both_sides": false,
"duration_minutes": 15
}
Request (breast, both sides):
{
"type": "breast",
"started_side": "left",
"both_sides": true,
"duration_minutes": 25
}
Request (bottle with milk source):
{
"type": "bottle",
"amount_ml": 120,
"duration_minutes": 12,
"milk_source": "breast_milk",
"breast_milk_type": "frozen"
}
Request (bottle with notes):
{
"type": "bottle",
"amount_ml": 90,
"notes": "Took it slowly, had some gas"
}
Request (pump, single side):
{
"type": "pump",
"started_side": "left",
"amount_ml": 80,
"duration_minutes": 20
}
Request (pump, both sides):
{
"type": "pump",
"started_side": "left",
"both_sides": true,
"amount_ml": 120
}
Response 201:
{
"feeding": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"child_id": "123e4567-e89b-12d3-a456-426614174000",
"timestamp": "2026-02-10T14:30:00.000Z",
"type": "breast",
"started_side": "left",
"both_sides": false,
"duration_minutes": 15,
"amount_ml": null,
"milk_source": null,
"breast_milk_type": null,
"notes": null,
"created_by": "789e4567-e89b-12d3-a456-426614174000",
"created_at": "2026-02-10T14:35:00.000Z",
"updated_by": "789e4567-e89b-12d3-a456-426614174000",
"updated_at": "2026-02-10T14:35:00.000Z"
}
}
Get Feeding by ID
GET /api/v1/children/:childId/feedings/:id
Authorization: Bearer <token>
Response 200:
{
"feeding": { ... }
}
List Feedings
GET /api/v1/children/:childId/feedings?since=2026-02-10T00:00:00.000Z
Authorization: Bearer <token>
Query Parameters (all optional):
- since: ISO8601 timestamp - return feedings after this time (exclusive)
- limit: integer - max number of results (default 100, max 500)
Response 200:
{
"feedings": [
{
"id": "...",
"child_id": "...",
"timestamp": "2026-02-10T15:00:00.000Z",
"type": "bottle",
"started_side": null,
"both_sides": false,
"duration_minutes": 12,
"amount_ml": 120.0,
"milk_source": "breast_milk",
"breast_milk_type": "frozen",
"notes": null,
"created_by": "...",
"created_at": "...",
"updated_by": "...",
"updated_at": "..."
}
],
"count": 1
}
Note: Results ordered by timestamp descending (most recent first).
Empty array if no feedings match the filter.
Update Feeding
PUT /api/v1/children/:childId/feedings/:id
Authorization: Bearer <token>
Request (all fields optional - partial update):
{
"timestamp": "2026-02-10T14:35:00.000Z",
"type": "bottle",
"amount_ml": 90,
"duration_minutes": 10,
"milk_source": "formula",
"notes": "Updated note"
}
Response 200:
{
"feeding": { ... }
}
Delete Feeding
DELETE /api/v1/children/:childId/feedings/:id
Authorization: Bearer <token>
Response 204: (No Content)
Error Responses
All error responses use the standard format:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body validation failed",
"details": [
{ "field": "started_side", "message": "Required when type is breast", "code": "required" }
]
}
}
Error codes:
- 400 VALIDATION_ERROR - Invalid or missing fields (with details array)
- 401 UNAUTHORIZED - Invalid or expired token
- 404 NOT_FOUND - Child not found, no access to child, or feeding not found (no access returns 404 to prevent child existence disclosure)
Business Rules
Validation Rules
- type: REQUIRED. Must be "breast", "bottle", or "pump".
- started_side: REQUIRED when type is "breast" or "pump". Must be "left" or "right". Ignored/stripped when type is "bottle".
- both_sides: Optional, defaults to false. Meaningful when type is "breast" or "pump". Ignored when type is "bottle".
- duration_minutes: Optional for all types. Must be a positive integer. Max 180 (3 hours).
- amount_ml: Optional when type is "pump" or "bottle" (pump may be omitted for comfort pumping/letdown collection). Not allowed when type is "breast". Must be a positive number (Float). Max 500 mL (~17 oz).
- milk_source: Optional. Must be "breast_milk" or "formula". Only valid when type is "bottle". Returns 400 if provided with type "breast" or "pump". Cleared when switching bottle→breast/pump.
- breast_milk_type: Optional. Must be one of "fresh", "refrigerated", "frozen", "colostrum", "donor", "antibody_rich". Only valid when type is "bottle" AND milk_source is "breast_milk". Returns 400 if provided with type "breast" or "pump", or when milk_source is "formula". Cleared when switching away from bottle type or when milk_source changes from "breast_milk".
- notes: Optional. Max 1000 characters. Empty string after trim is treated as null. Stored trimmed.
- timestamp: Optional, defaults to server's current time. Must be valid ISO8601.
Side Suggestion Logic
The app suggests which side to start on based on the last breast feeding:
- Always suggest the opposite of the last
started_side - This works identically for single-side and both-sides feedings
- Example: last was started_side="left" → suggest "right"
- Example: last was started_side="right", both_sides=true → still suggest "left"
- If no previous breast feedings exist, suggest "left" as default
- When user taps "Both" in the iOS quick buttons, log started_side as the suggested side, both_sides=true (assumes they started on the suggested side then switched)
Type Switching on Update
- breast → bottle: started_side and both_sides are cleared, amount_ml and milk_source can now be set
- bottle → breast: amount_ml, milk_source, and breast_milk_type are cleared, started_side is now required
- breast → pump: amount_ml now allowed (optional), milk_source remains null, started_side is now required
- pump → bottle: started_side and both_sides are cleared, milk_source can now be set
- bottle → pump: milk_source and breast_milk_type are cleared, started_side is now required
- pump → breast: amount_ml is cleared, milk_source remains null
- milk_source changed from breast_milk → formula: breast_milk_type is cleared
Access Control
- Child access verified via ChildAccess table (owner or caregiver role)
- No access → 404 NOT_FOUND (prevents information disclosure about child existence)
- Child not found → 404 NOT_FOUND
- Feeding not found → 404 NOT_FOUND
Audit Trail
- All CUD operations create an AuditLog entry
- Create: changes = full entity
- Update: changes = { before: {...}, after: {...} }
- Delete: changes = full entity being deleted
Query Behavior
- List orders by timestamp descending (most recent first)
- "since" filters to feedings where timestamp > since value
- Default limit 100, max 500
- Responses wrapped in named object:
{ "feedings": [...], "count": N }
Acceptance Criteria
- Can create a breast feeding with started_side and both_sides
- Can create a bottle feeding with milk_source=breast_milk and breast_milk_type
- breast_milk_type is rejected for breast and pump types
- breast_milk_type is rejected when milk_source=formula
- breast_milk_type is cleared when milk_source changes to formula
- breast_milk_type is cleared when type changes away from bottle
- Can create a bottle feeding with amount_ml
- started_side is required for breast, rejected for bottle
- amount_ml is rejected for breast, optional for bottle
- amount_ml max is 500 mL, duration_minutes max is 180
- Timestamp defaults to now if not provided
- Can edit a feeding (partial update, type switching works)
- Can delete a feeding (returns 204)
- List returns feedings wrapped in object with count
- List orders by timestamp descending
- Since filter works correctly
- Side suggestion logic: always opposite of last started_side
- Validation errors return 400 with details array
- No access returns 404 (same as not found, prevents existence disclosure)
- All CUD operations create AuditLog entries
Test Cases
Create - Happy Path
- Breast, left side only: type=breast, started_side=left, both_sides=false → 201
- Breast, right side only: type=breast, started_side=right → 201 (both_sides defaults false)
- Breast, both sides: type=breast, started_side=left, both_sides=true → 201
- Bottle with amount: type=bottle, amount_ml=120 → 201, started_side=null, both_sides=false
- Bottle with duration: type=bottle, duration_minutes=10 → 201
- Custom timestamp: timestamp="2026-02-09T12:00:00.000Z" → 201, timestamp matches
- No timestamp: omit timestamp → 201, server sets current time
- Minimal breast: type=breast, started_side=left → 201 (no duration)
- Minimal bottle: type=bottle → 201 (no amount, no duration)
Create - Validation Errors
- Missing type: {} → 400, details: [{field: "type"}]
- Invalid type: type=formula → 400
- Breast without started_side: type=breast → 400, details: [{field: "started_side"}]
- Breast with amount_ml: type=breast, started_side=left, amount_ml=100 → 400
- Negative duration: duration_minutes=-5 → 400
- Duration too high: duration_minutes=200 → 400 (max 180)
- Amount too high: type=bottle, amount_ml=600 → 400 (max 500)
- Invalid started_side: started_side=middle → 400
- Bottle with started_side: type=bottle, started_side=left → 201, started_side stripped to null
- Invalid timestamp: timestamp="not-a-date" → 400
Access Control
- No child access: user without ChildAccess → 404 (prevents child existence disclosure)
- Caregiver access: user with caregiver role → 201
- Non-existent child: invalid childId → 404
- No auth token: missing Authorization header → 401
- Expired token: expired session → 401
List
- List all: GET feedings → 200, { feedings: [...], count: N }
- List with since: feedings at T1, T2, T3; since=T1.5 → returns T2 and T3
- List empty: child with no feedings → 200, { feedings: [], count: 0 }
- List ordering: feedings at T2, T1, T3 → returned as [T3, T2, T1]
- List with limit: 5 feedings, limit=2 → returns 2 most recent
- List no access: → 404
- List non-existent child: → 404
Update
- Update timestamp: new timestamp → 200
- Update duration: duration_minutes=20 → 200
- Breast to bottle: type=bottle, amount_ml=100 → 200, started_side cleared
- Bottle to breast: type=breast, started_side=left → 200, amount_ml cleared
- Bottle to breast without side: type=breast (no started_side) → 400
- Update started_side: started_side=right → 200
- Update both_sides: both_sides=true → 200
- Partial update: only duration_minutes → 200, other fields unchanged
- Update non-existent: invalid id → 404
- Update no access: → 404
Delete
- Delete feeding: → 204
- Delete then list: feeding not in results
- Delete non-existent: → 404
- Delete no access: → 404
Audit Log
- Create audit: create feeding → AuditLog with action=create
- Update audit: update feeding → AuditLog with action=update, changes={before, after}
- Delete audit: delete feeding → AuditLog with action=delete
Side Suggestion (business logic, tested via dashboard API)
- Alternation: last started_side=left → suggest right
- Alternation after both: last started_side=right, both_sides=true → suggest left
- No history: no breast feedings → suggest left
- Bottle doesn't affect suggestion: last feed was bottle, before that breast started_side=left → suggest right (skip bottles)
- Multiple children isolated: Child A feedings don't affect Child B suggestions
breast_milk_type
- Bottle + breast_milk + type: type=bottle, milk_source=breast_milk, breast_milk_type=frozen → 201, field saved
- Bottle + formula + type: type=bottle, milk_source=formula, breast_milk_type=frozen → 400, field rejected
- Breast + type: type=breast, breast_milk_type=fresh → 400, field rejected
- Update clears on milk_source change: bottle+breast_milk+frozen, update milk_source=formula → breast_milk_type=null
- Update clears on type change: bottle+breast_milk+fresh, update type=breast → breast_milk_type=null
Edge Cases
- All optional fields present: breast with started_side, both_sides, duration_minutes, timestamp → 201
- Future timestamp: timestamp 1 hour ahead → 201 (allowed)
- Very old timestamp: timestamp 2 years ago → 201 (allowed for historical data)
- Amount_ml decimal: type=bottle, amount_ml=73.9 → 201 (Float stored precisely)
- Concurrent updates: two users update same feeding → last write wins, both create AuditLog
Boundaries
- No timer-based duration tracking (client-side concern)
- No formula vs pumped breastmilk distinction for bottle type (milk_source distinguishes these; breast_milk_type further classifies breast milk)
- No feeding position, location, or environmental notes
- No multi-feeding sessions (breast then bottle top-up as single event)
- Unit conversion (mL ↔ oz) is a client responsibility. API always stores mL.
- Side suggestion logic lives in the dashboard data API, not in the feeding endpoints themselves
- Delete returns 204 No Content (no body)
Future: Milk Tracking (Out of Scope)
Post-MVP feature: dedicated pumping log with milk bag lifecycle tracking.
- Each pumped bag gets a unique ID + timestamp
- States: fresh → frozen → thawed → used/discarded
- Usage linking: "Bottle feeding X used milk bag Y"
- Metadata: baby health status, mom health status, medications, alcohol
- Nutritional context: sick baby, sick mom, etc.
- This will be a separate entity/feature, not an extension of Feeding.