Skip to main content

Overview

Family member roles control what actions users can perform within a family. Zen Nurture uses a role-based access control system with three primary roles: owner, admin, and caregiver.

Schema

The familyMembers table links users to families with specific roles:
familyMembers: defineTable({
  familyId: v.id("families"),
  userId: v.string(),
  role: v.string(),
  joinedAt: v.string(),
})
  .index("by_familyId", ["familyId"])
  .index("by_userId", ["userId"])
  .index("by_familyId_userId", ["familyId", "userId"])

Role Types

Owner

  • Full administrative control
  • Cannot be removed
  • One per family
  • Automatically assigned at family creation

Admin

  • Send invitations
  • Remove members (except owner)
  • Manage family settings
  • Full data access

Caregiver

  • View family data
  • Log baby events
  • Create caregiver profiles
  • Limited administrative access

Role Permissions

Inviting Members

Only owner and admin roles can send family invitations.
convex/families.ts
export const inviteCaregiver = mutation({
  args: {
    familyId: v.id("families"),
    email: v.string(),
    role: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    const membership = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", args.familyId).eq("userId", user._id)
      )
      .first();

    if (!membership || !["owner", "admin"].includes(membership.role)) {
      throw new Error("Only owners and admins can invite caregivers");
    }

    // Create invitation...
  },
});

Removing Members

Owners cannot be removed from a family. Only owners and admins can remove other members.
convex/families.ts
export const removeFamilyMember = mutation({
  args: {
    familyId: v.id("families"),
    memberId: v.id("familyMembers"),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    // Check requester permissions
    const myMembership = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", args.familyId).eq("userId", user._id)
      )
      .first();

    if (!myMembership || !["owner", "admin"].includes(myMembership.role)) {
      throw new Error("Only owners and admins can remove members");
    }

    // Verify target member
    const target = await ctx.db.get(args.memberId);
    if (!target || target.familyId !== args.familyId) {
      throw new Error("Member not found in this family");
    }
    
    if (target.role === "owner") {
      throw new Error("Cannot remove the family owner");
    }

    await ctx.db.delete(args.memberId);
  },
});

Creating Families

When a user creates a family, they automatically become the owner:
convex/families.ts
export const createFamily = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const now = new Date().toISOString();

    // Create family record
    const familyId = await ctx.db.insert("families", {
      name: args.name,
      ownerId: user._id,
      createdAt: now,
    });

    // Automatically add creator as owner
    await ctx.db.insert("familyMembers", {
      familyId,
      userId: user._id,
      role: "owner",
      joinedAt: now,
    });

    return familyId;
  },
});

Listing Family Members

Retrieve all members of a family with enriched user information:
convex/families.ts
export const listFamilyMembers = query({
  args: { familyId: v.id("families") },
  handler: async (ctx, args) => {
    const user = await authComponent.safeGetAuthUser(ctx);
    if (!user) return [];

    // Verify requesting user is a member
    const myMembership = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", args.familyId).eq("userId", user._id)
      )
      .first();

    if (!myMembership) return [];

    // Get all family members
    const members = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId", (q) => q.eq("familyId", args.familyId))
      .collect();

    // Enrich with user details
    const enriched = await Promise.all(
      members.map(async (m) => {
        const memberUser = await authComponent.getAnyUserById(ctx, m.userId);
        return {
          ...m,
          userName: memberUser?.name ?? "Unknown",
          userEmail: memberUser?.email ?? "",
        };
      })
    );

    return enriched;
  },
});

Access Control Pattern

Use a consistent pattern for role-based access control:
1

Authenticate user

const user = await requireAuth(ctx);
2

Fetch user's membership

const membership = await ctx.db
  .query("familyMembers")
  .withIndex("by_familyId_userId", (q) =>
    q.eq("familyId", args.familyId).eq("userId", user._id)
  )
  .first();
3

Check role permissions

if (!membership || !["owner", "admin"].includes(membership.role)) {
  throw new Error("Insufficient permissions");
}
4

Perform authorized action

// Execute the privileged operation

Viewing User’s Families

Users can be members of multiple families. List all families for the current user:
convex/families.ts
export const listMyFamilies = query({
  args: {},
  handler: async (ctx) => {
    const user = await authComponent.safeGetAuthUser(ctx);
    if (!user) return [];

    // Find all family memberships
    const memberships = await ctx.db
      .query("familyMembers")
      .withIndex("by_userId", (q) => q.eq("userId", user._id))
      .collect();

    // Fetch family details with role
    const families = await Promise.all(
      memberships.map(async (m) => {
        const family = await ctx.db.get(m.familyId);
        return family ? { ...family, role: m.role } : null;
      })
    );

    return families.filter(Boolean);
  },
});

Role Assignment on Invitation

When inviting members, you can specify their role (defaults to “caregiver”):
// Invite as caregiver (default)
await inviteCaregiver({
  familyId: family._id,
  email: "[email protected]"
});

// Invite as admin
await inviteCaregiver({
  familyId: family._id,
  email: "[email protected]",
  role: "admin"
});
The role is stored in the invitation and applied when the user accepts:
const inviteId = await ctx.db.insert("familyInvitations", {
  familyId: args.familyId,
  email: args.email,
  role: args.role ?? "caregiver", // Default to caregiver
  invitedBy: user._id,
  status: "pending",
  createdAt: now.toISOString(),
  expiresAt: expiresAt.toISOString(),
});

Checking Family Access

Helper function for verifying a user has access to a baby through family membership:
lib/auth.ts
export async function requireBabyAccess(
  ctx: QueryCtx | MutationCtx,
  babyId: Id<"babyProfiles">,
  userId: string
) {
  const babyProfile = await ctx.db.get(babyId);
  if (!babyProfile) throw new Error("Baby profile not found");
  if (!babyProfile.familyId) throw new Error("Baby not associated with family");

  const familyIds = await getUserFamilyIds(ctx, userId);
  if (!familyIds.includes(babyProfile.familyId)) {
    throw new Error("Not a member of this family");
  }
}

Best Practices

Even for read-only operations, check that the user is a family member:
const myMembership = await ctx.db
  .query("familyMembers")
  .withIndex("by_familyId_userId", (q) =>
    q.eq("familyId", args.familyId).eq("userId", user._id)
  )
  .first();

if (!myMembership) return [];
Make permission checks readable and maintainable:
if (!["owner", "admin"].includes(membership.role)) {
  throw new Error("Insufficient permissions");
}
Always prevent owner deletion:
if (target.role === "owner") {
  throw new Error("Cannot remove the family owner");
}
Include the user’s role when returning family information:
const family = await ctx.db.get(args.familyId);
return family ? { ...family, role: membership.role } : null;

Permission Matrix

ActionOwnerAdminCaregiver
Create family
View family data
Send invitations
Remove members
Remove owner
Log baby events
Create caregivers
Delete family

Family Invitations

Learn how to invite new members to your family

Caregivers

Set up caregiver profiles for event attribution

Build docs developers (and LLMs) love