Feature: Families & Sharing
Version: 1.0.0 Last Reviewed: 2026-02-25 Status: Draft Supersedes: share-links.md v1.2.0
Overview
Families are the organizational unit for Baby Basics. A family groups children and members together. Members have roles (parent or caregiver) that control what they can manage. Parents invite others via time-limited, single-use deep links shared over SMS.
Data Model
New Tables
Family
id UUID (PK)
name VARCHAR(100) -- e.g. "Johnny's Family"
created_by UUID (FK → users)
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
FamilyMember
id UUID (PK)
family_id UUID (FK → families, CASCADE)
user_id UUID (FK → users, CASCADE)
role VARCHAR(20) -- "parent" or "caregiver"
joined_at TIMESTAMPTZ
UNIQUE(family_id, user_id)
Modified Tables
Child
+ family_id UUID (FK → families, CASCADE) -- NEW, replaces ChildAccess
(all other fields unchanged)
ShareLink
- child_id REMOVED
+ family_id UUID (FK → families, CASCADE) -- invites are per-family now
- token REMOVED (was plaintext)
+ token_hash VARCHAR(64) UNIQUE -- SHA-256 hash of raw token
+ role VARCHAR(20) DEFAULT "caregiver" -- role granted on acceptance
+ expires_at TIMESTAMPTZ -- 7 days from creation
(id, created_by, used_at, used_by, created_at unchanged)
User
+ family_memberships FamilyMember[] -- relation
Dropped Tables
ChildAccess -- replaced by FamilyMember
Migration Strategy
No production data exists yet. Dev has only test data. Migration is clean:
- Create
familiesandfamily_memberstables - Add
family_idtochildren(NOT NULL after backfill) - For each existing user with
ChildAccess.role = "owner": create a Family, create FamilyMember(parent), update their children'sfamily_id - For each existing
ChildAccess.role = "caregiver": create FamilyMember(caregiver) in the child's family - Modify
share_links: addfamily_id,token_hash,role,expires_at; dropchild_id,token - Drop
child_accesstable
Roles & Permissions
Two roles exist at the family level. All members of a family see all children in that family.
| Operation | parent | caregiver |
|---|---|---|
| View children + tracking data | yes | yes |
| Log events (feedings, diapers, sleeps, notes) | yes | yes |
| Edit/delete events | yes | yes |
| View dashboard & timeline | yes | yes |
| Add children to family | yes | no |
| Edit/delete children | yes | no |
| Edit family name | yes | no |
| Invite family members | yes | no |
| Remove family members | yes | no |
| Delete family | yes | no |
Future (post-MVP): Per-child restriction. An additive ChildRestriction(family_member_id, child_id) table would limit specific members to specific children. Absence of restrictions = full family access (current behavior).
Future (post-MVP): Viewer role (read-only). Defined but not implemented.
API Contract
Families
Create Family
POST /api/v1/families
Authorization: Bearer <token>
Request:
{
"name": "Johnny's Family"
}
Response 201:
{
"family": {
"id": "uuid",
"name": "Johnny's Family",
"created_at": "2026-02-25T12:00:00.000Z",
"updated_at": "2026-02-25T12:00:00.000Z"
}
}
Notes:
- Creates the family and adds the requesting user as a member with role "parent".
- Name is required, 1-100 chars, trimmed.
- A user can create multiple families.
List User's Families
GET /api/v1/families
Authorization: Bearer <token>
Response 200:
{
"families": [
{
"id": "uuid",
"name": "Johnny's Family",
"role": "parent",
"children_count": 1,
"members_count": 2,
"created_at": "2026-02-25T12:00:00.000Z"
}
],
"count": 1
}
Notes:
- Returns all families the user is a member of.
- `role` is the requesting user's role in each family.
- Sorted by created_at ascending (oldest first).
Get Family Details
GET /api/v1/families/:familyId
Authorization: Bearer <token>
Response 200:
{
"family": {
"id": "uuid",
"name": "Johnny's Family",
"role": "parent",
"members": [
{
"user_id": "uuid",
"name": "Johnny",
"email": "johnny@example.com",
"role": "parent",
"joined_at": "2026-02-25T12:00:00.000Z"
},
{
"user_id": "uuid",
"name": "Maria",
"email": "maria@example.com",
"role": "caregiver",
"joined_at": "2026-02-26T10:00:00.000Z"
}
],
"children": [
{
"id": "uuid",
"name": "Baby Bretz",
"date_of_birth": "2026-03-15"
}
],
"created_at": "2026-02-25T12:00:00.000Z",
"updated_at": "2026-02-25T12:00:00.000Z"
}
}
Response 403 (not a member):
{ "error": { "code": "FORBIDDEN", "message": "Not a member of this family", "details": [] } }
Update Family
PATCH /api/v1/families/:familyId
Authorization: Bearer <token>
Request:
{
"name": "The Bretz Family"
}
Response 200:
{
"family": {
"id": "uuid",
"name": "The Bretz Family",
"created_at": "...",
"updated_at": "..."
}
}
Response 403 (caregiver):
{ "error": { "code": "FORBIDDEN", "message": "Only parents can update family settings", "details": [] } }
Delete Family
DELETE /api/v1/families/:familyId
Authorization: Bearer <token>
Response 204: (No Content)
Response 403 (caregiver):
{ "error": { "code": "FORBIDDEN", "message": "Only parents can delete a family", "details": [] } }
Notes:
- Cascade deletes: all FamilyMembers, Children (and their Feedings, Diapers, Sleeps, Notes), ShareLinks.
- AuditLog entries remain.
Members
List Members
GET /api/v1/families/:familyId/members
Authorization: Bearer <token>
Response 200:
{
"members": [
{
"user_id": "uuid",
"name": "Johnny",
"email": "johnny@example.com",
"role": "parent",
"joined_at": "2026-02-25T12:00:00.000Z"
}
],
"count": 1
}
Notes:
- Any family member can list members (parents and caregivers both).
- Sorted by joined_at ascending.
Remove Member
DELETE /api/v1/families/:familyId/members/:userId
Authorization: Bearer <token>
Response 204: (No Content)
Response 400 (removing self):
{ "error": { "code": "VALIDATION_ERROR", "message": "Cannot remove yourself. Leave the family or delete it instead.", "details": [] } }
Response 403 (caregiver attempting removal):
{ "error": { "code": "FORBIDDEN", "message": "Only parents can remove family members", "details": [] } }
Response 404 (user not a member):
{ "error": { "code": "NOT_FOUND", "message": "Member not found", "details": [] } }
Notes:
- Only parents can remove members.
- Parents cannot remove themselves (must delete family or transfer ownership post-MVP).
- Events logged by the removed member are preserved (created_by still references them).
Invites
Create Invite
POST /api/v1/families/:familyId/invites
Authorization: Bearer <token>
Request:
{
"role": "caregiver"
}
Response 201:
{
"invite": {
"id": "uuid",
"join_url": "https://baby.bretzfam.com/join/xK9mLp2qR5tN8vW3yBcd4A",
"role": "caregiver",
"expires_at": "2026-03-04T12:00:00.000Z",
"created_at": "2026-02-25T12:00:00.000Z"
}
}
Response 403 (caregiver):
{ "error": { "code": "FORBIDDEN", "message": "Only parents can invite family members", "details": [] } }
Notes:
- `role` is required: "parent" or "caregiver".
- Token: 128 bits via crypto.randomBytes(16), encoded as base64url (22 chars).
- Token is returned ONCE in the join_url. Only the SHA-256 hash is stored in the DB.
- join_url format: {BASE_URL}/join/{token}
- Expires 7 days from creation.
- If an unused, non-expired invite with the same role exists, return it (idempotent).
- If the only existing invite is expired or used, generate a new one.
- Only parents can create invites.
Accept Invite
POST /api/v1/invites/accept
Authorization: Bearer <token>
Request:
{
"token": "xK9mLp2qR5tN8vW3yBcd4A"
}
Response 201:
{
"family": {
"id": "uuid",
"name": "Johnny's Family",
"role": "caregiver"
},
"invited_by": {
"name": "Johnny"
}
}
Response 404 (invalid, expired, or used token):
{ "error": { "code": "NOT_FOUND", "message": "Invalid or expired invite link", "details": [] } }
Response 409 (already a member):
{ "error": { "code": "CONFLICT", "message": "You are already a member of this family", "details": [] } }
Response 400 (accepting own invite):
{ "error": { "code": "VALIDATION_ERROR", "message": "Cannot accept your own invite", "details": [] } }
Notes:
- Server hashes the incoming token with SHA-256, looks up by token_hash.
- Checks: token exists, not used (used_at is null), not expired (expires_at > now).
- Invalid, used, and expired tokens all return 404 (no enumeration).
- Creates FamilyMember with the role stored on the ShareLink.
- Marks ShareLink as used: sets used_at and used_by.
- Rate limited: 5 requests/minute per IP.
- This endpoint is NOT nested under /families/:familyId because the accepting user doesn't know the family ID.
Children (Modified)
Create Child
POST /api/v1/families/:familyId/children
Authorization: Bearer <token>
Request:
{
"name": "Baby Bretz",
"date_of_birth": "2026-03-15"
}
Response 201:
{
"child": {
"id": "uuid",
"family_id": "uuid",
"name": "Baby Bretz",
"date_of_birth": "2026-03-15",
"created_at": "...",
"updated_at": "..."
}
}
Response 403 (caregiver):
{ "error": { "code": "FORBIDDEN", "message": "Only parents can add children", "details": [] } }
Notes:
- Replaces POST /api/v1/children. Child is created under the specified family.
- Only parents can add children.
List Children
GET /api/v1/children
Authorization: Bearer <token>
Response 200:
{
"children": [
{
"id": "uuid",
"family_id": "uuid",
"family_name": "Johnny's Family",
"name": "Baby Bretz",
"date_of_birth": "2026-03-15",
"role": "parent",
"created_at": "...",
"updated_at": "..."
}
],
"count": 1
}
Notes:
- Returns all children across all families the user belongs to.
- `role` is the user's role in the child's family.
- Used by the child switcher in the iOS app.
Get, Update, Delete Child
Existing endpoints (GET/PUT/DELETE /api/v1/children/:childId) remain unchanged in URL. Internally, access is verified via family membership instead of ChildAccess. Delete is parent-only.
Tracking Routes (Unchanged)
All tracking routes remain at their current URLs and are unaffected:
/api/v1/children/:childId/feedings/api/v1/children/:childId/diapers/api/v1/children/:childId/sleeps/api/v1/children/:childId/notes/api/v1/children/:childId/dashboard/api/v1/children/:childId/timeline
Access verification changes internally (queries FamilyMember instead of ChildAccess) but the external contract is identical.
Invite Link Security
| Parameter | Value |
|---|---|
| Token entropy | 128 bits (crypto.randomBytes(16)) |
| Token format | Base64url, 22 characters |
| Token storage | SHA-256 hash in DB; raw token returned once at creation |
| Expiry | 7 days from creation (fixed, not configurable) |
| Usage | Single-use (used_at set on acceptance) |
| Error responses | 404 for invalid, expired, AND used tokens (no enumeration) |
| Rate limit | 5 req/min per IP on /invites/accept |
| HTTPS | Required (enforced at infrastructure level) |
| Referrer policy | no-referrer on any web page serving the join URL |
iOS Flows
Onboarding: New User (Organic)
1. Register (name, email, password)
↓
2. Setup (one screen):
┌─────────────────────────────┐
│ Welcome, Johnny! │
│ │
│ Family: [Johnny's Family ] │
│ (pre-filled, editable) │
│ │
│ Baby's name: [ ] │
│ Date of birth: [ ] │
│ │
│ [Get Started] │
└─────────────────────────────┘
↓
3. Dashboard (with new child active)
- Family name defaults to "{First Name}'s Family"
- User can edit the family name or just tap through
- "Get Started" calls: POST /families → POST /families/:id/children → navigate to dashboard
Onboarding: New User (Via Invite Link)
1. User taps link: baby.bretzfam.com/join/xK9mLp2...
↓
2. App opens (universal link), stores token in UserDefaults
↓
3. Not authenticated → Register screen
(context banner: "You've been invited to a family!")
↓
4. After registration, app detects pending token
↓
5. POST /invites/accept with stored token
↓
6. Success → navigate to shared family's dashboard
(skip family+child setup entirely)
↓
7. Clear stored token
- If accept fails (expired/used): show "This invite link is no longer valid" and continue to normal onboarding
- Token stored in UserDefaults (temporary, low-sensitivity)
Onboarding: Existing User (Via Invite Link)
1. User taps link while logged in
↓
2. App opens, detects authenticated user
↓
3. POST /invites/accept immediately
↓
4. Success → show banner: "You joined Johnny's Family!"
Navigate to/switch to the new family
Settings: Family Management
Settings > Family
┌─────────────────────────────┐
│ Johnny's Family [Edit]│
│ │
│ Members │
│ ┌─────────────────────────┐│
│ │ 👤 Johnny Parent ││
│ │ 👤 Sarah Parent ││
│ │ 👤 Maria Caregiver ││
│ │ [Remove] ││
│ └─────────────────────────┘│
│ │
│ Children │
│ ┌─────────────────────────┐│
│ │ 👶 Baby Bretz ││
│ │ Born: Mar 15, 2026 ││
│ └─────────────────────────┘│
│ │
│ [+ Invite Family Member] │
│ [+ Add Child] │
│ │
│ ─── Other Families ─── │
│ [+ Create New Family] │
└─────────────────────────────┘
Caregivers see the same screen but without Edit, Remove, Invite, Add Child, or Create New Family buttons.
Settings: Invite Creation Flow
Step 1: Choose role
┌─────────────────────────┐
│ Invite Family Member │
│ │
│ Role: │
│ [██ Parent] [Caregiver]│
│ │
│ Parents can invite and │
│ manage family settings. │
│ │
│ [Create Invite Link] │
└─────────────────────────┘
Step 2: Share link
┌─────────────────────────┐
│ ✅ Invite Created │
│ │
│ baby.bretzfam.com/join/ │
│ xK9mLp2qR5tN8vW3yBcd4A │
│ │
│ Role: Parent │
│ Expires: Mar 4, 2026 │
│ │
│ [📋 Copy] [📤 Share...]│
│ │
│ [Done] │
└─────────────────────────┘
Share sheet pre-fills: "Join Johnny's Family on Baby Basics! {join_url}"
Family Switching
When a user belongs to multiple families, the active family determines which children appear in the dashboard and child switcher. Switching is done via Settings > Families (tap a different family).
The app stores the active family ID in @AppStorage("activeFamilyId").
Universal Link Handling
The iOS app registers for the /join/* path pattern in the AASA (Apple App Site Association) file.
When the app handles a universal link:
- Extract the token from the URL path (
/join/{token}) - If authenticated: call
POST /invites/acceptimmediately - If not authenticated: store token in
UserDefaults("pendingInviteToken"), show auth flow - After auth: check for pending token, call accept, clear token
AASA update needed:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.bretzfam.babybasics",
"paths": ["/join/*"]
}
]
}
}
Audit Logging
| Event | entity_type | action |
|---|---|---|
| Family created | family | create |
| Family updated | family | update |
| Family deleted | family | delete |
| Member joined (via invite) | family_member | create |
| Member removed | family_member | delete |
| Invite created | share_link | create |
| Invite accepted | share_link | update |
| Child created in family | child | create |
Acceptance Criteria
Families
- Can create a family with a name (201, returns family object)
- Can list all families the user belongs to (with role, counts)
- Can get family details including members and children
- Parents can update family name
- Caregivers cannot update family name (403)
- Parents can delete family (cascades all data)
- Caregivers cannot delete family (403)
Members
- Can list all members of a family (any member)
- Parents can remove caregivers (204)
- Parents cannot remove themselves (400)
- Caregivers cannot remove anyone (403)
- Removed member can no longer access family's children (returns 404, not 403 — no information disclosure)
- Events logged by removed member are preserved
Invites
- Parents can create invite with role selection (201)
- Caregivers cannot create invites (403)
- Invite token is 22-char base64url (128 bits entropy)
- Token is SHA-256 hashed before storage; raw returned once
- join_url uses BASE_URL from environment
- Invite generation is idempotent (returns existing unused non-expired invite with same role)
- Expired invite is skipped; new one generated
- Invite expires after 7 days
- Accept creates FamilyMember with specified role (201)
- Accept marks ShareLink as used (used_at, used_by)
- Expired token returns 404
- Used token returns 404
- Invalid token returns 404
- Already a member returns 409
- Own invite returns 400
- Rate limited at 5 req/min per IP
Children (Modified)
- POST /families/:familyId/children creates child in family (201)
- Only parents can add children (403 for caregivers)
- GET /children lists children across all user's families
- Children include family_id and family_name in response
- Existing tracking routes work with family-based access verification
- verifyChildAccess checks FamilyMember instead of ChildAccess
iOS
- New user onboarding: Register → Family+Child setup (one screen) → Dashboard
- Family name defaults to "{First Name}'s Family"
- Invited user (new): Register → auto-accept → shared family dashboard
- Invited user (existing): auto-accept → banner + navigate to family
- Universal link handles /join/{token} pattern
- Pending token stored in UserDefaults for deferred accept
- Settings shows family management (name, members, children)
- Parents see invite/remove/edit controls; caregivers don't
- Invite creation: role picker → create link → show with Copy + Share
- Share sheet pre-fills "Join {Family} on Baby Basics! {url}"
- Family switching via Settings (multi-family support)
- Active family stored in @AppStorage
Boundaries (NOT in MVP)
- Per-child access restrictions (post-MVP: additive ChildRestriction table)
- Viewer role (read-only access)
- Transfer family ownership
- PIN/OTP verification on invite accept
- Configurable invite expiry (fixed 7 days)
- Multi-server account support (single server for MVP)
- Email notifications on invite accept/member removal
- Multiple active invites per family (one unused per role)
- Leave family (self-removal, vs being removed by parent)
Test Cases
Family CRUD
- Create family → 201, user becomes parent member
- List families → returns all families with role and counts
- Get family details → includes members and children
- Update name (parent) → 200
- Update name (caregiver) → 403
- Delete family (parent) → 204, cascades everything
- Delete family (caregiver) → 403
- Non-member access → 403
Member Management
- List members → returns all with roles, sorted by joined_at
- Remove caregiver (parent) → 204
- Remove parent (parent) → 204 (can remove other parents)
- Remove self → 400
- Remove (caregiver attempting) → 403
- Removed user cannot access children → 403
- Removed user's events preserved in timeline
Invite Flow
- Create invite as parent → 201, join_url with token
- Create invite as caregiver → 403
- Idempotent: create same role invite twice → same token returned
- Create invite with different role → new token (both can coexist)
- Accept valid invite → 201, FamilyMember created with correct role
- Accept expired invite → 404
- Accept used invite → 404
- Accept invalid token → 404
- Accept own invite → 400
- Accept when already member → 409
- Token hash matches SHA-256 of raw token
- Expired invite regeneration: after old expires, new one created
Children Under Family
- Create child in family (parent) → 201
- Create child (caregiver) → 403
- List children → shows children across all families
- Existing CRUD/tracking routes work with family-based access
- verifyChildAccess correctly resolves family membership
Onboarding
- New user → register + setup → family and child created
- Invited user (new) → register → auto-accept → lands on shared family
- Invited user (existing) → accept → joins family
- Failed accept (expired) → friendly error, continues to normal onboarding
Migration
- Existing users with children → auto-migrated to families
- Existing caregivers → migrated to family members
- All tracking data preserved and accessible after migration