Skip to main content

Overview

Family invitations allow family owners and admins to invite others to join their family via email. Invitations are time-limited and can be accepted or declined by recipients.

Schema

The familyInvitations table stores all invitation records:
familyInvitations: defineTable({
  familyId: v.id("families"),
  email: v.string(),
  role: v.string(),
  invitedBy: v.string(),
  status: v.string(),
  createdAt: v.string(),
  expiresAt: v.string(),
})
  .index("by_familyId", ["familyId"])
  .index("by_familyId_status", ["familyId", "status"])
  .index("by_email", ["email"])
  .index("by_email_status", ["email", "status"])

Status Values

  • pending - Invitation sent, awaiting response
  • accepted - User accepted and joined the family
  • declined - User declined the invitation
  • expired - Invitation passed expiration date

Sending Invitations

Only users with owner or admin roles can send invitations.
1

Check permissions

Verify the requesting user is a family member with owner or admin role:
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");
}
2

Check for duplicate invitations

Prevent sending multiple pending invitations to the same email:
const existing = await ctx.db
  .query("familyInvitations")
  .withIndex("by_email_status", (q) =>
    q.eq("email", args.email).eq("status", "pending")
  )
  .collect();

const alreadyInvited = existing.find((i) => i.familyId === args.familyId);
if (alreadyInvited) {
  throw new Error("This email has already been invited to this family");
}
3

Create invitation with expiration

Invitations expire after 7 days:
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);

const inviteId = await ctx.db.insert("familyInvitations", {
  familyId: args.familyId,
  email: args.email,
  role: args.role ?? "caregiver",
  invitedBy: user._id,
  status: "pending",
  createdAt: now.toISOString(),
  expiresAt: expiresAt.toISOString(),
});

Usage Example

import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

const inviteCaregiver = useMutation(api.families.inviteCaregiver);

await inviteCaregiver({
  familyId: family._id,
  email: "caregiver@example.com",
  role: "caregiver"
});

Viewing Invitations

For Family Admins

List all pending invitations for a family:
export const listPendingInvitations = query({
  args: { familyId: v.id("families") },
  handler: async (ctx, args) => {
    // Verify membership
    const membership = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", args.familyId).eq("userId", user._id)
      )
      .first();

    if (!membership) return [];

    return await ctx.db
      .query("familyInvitations")
      .withIndex("by_familyId_status", (q) =>
        q.eq("familyId", args.familyId).eq("status", "pending")
      )
      .collect();
  },
});

For Recipients

Users can view invitations sent to their email address:
export const listMyInvitations = query({
  args: {},
  handler: async (ctx) => {
    const user = await authComponent.safeGetAuthUser(ctx);
    if (!user) return [];

    const invitations = await ctx.db
      .query("familyInvitations")
      .withIndex("by_email_status", (q) =>
        q.eq("email", user.email).eq("status", "pending")
      )
      .collect();

    // Enrich with family name
    const enriched = await Promise.all(
      invitations.map(async (inv) => {
        const family = await ctx.db.get(inv.familyId);
        return { ...inv, familyName: family?.name ?? "Unknown" };
      })
    );

    return enriched;
  },
});

Accepting Invitations

1

Validate invitation

Check that the invitation exists, is pending, and belongs to the user:
const invitation = await ctx.db.get(args.invitationId);
if (!invitation) throw new Error("Invitation not found");
if (invitation.status !== "pending") {
  throw new Error("Invitation is no longer pending");
}
if (invitation.email !== user.email) {
  throw new Error("This invitation is for a different email");
}
2

Check expiration

Automatically expire invitations that are past their expiration date:
if (new Date(invitation.expiresAt) < new Date()) {
  await ctx.db.patch(args.invitationId, { status: "expired" });
  throw new Error("Invitation has expired");
}
3

Create family membership

Add the user as a family member with the invited role:
await ctx.db.insert("familyMembers", {
  familyId: invitation.familyId,
  userId: user._id,
  role: invitation.role,
  joinedAt: new Date().toISOString(),
});

await ctx.db.patch(args.invitationId, { status: "accepted" });

Full Implementation

convex/families.ts
export const acceptInvitation = mutation({
  args: { invitationId: v.id("familyInvitations") },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    const invitation = await ctx.db.get(args.invitationId);
    if (!invitation) throw new Error("Invitation not found");
    if (invitation.status !== "pending") {
      throw new Error("Invitation is no longer pending");
    }
    if (invitation.email !== user.email) {
      throw new Error("This invitation is for a different email");
    }

    if (new Date(invitation.expiresAt) < new Date()) {
      await ctx.db.patch(args.invitationId, { status: "expired" });
      throw new Error("Invitation has expired");
    }

    // Check if already a member
    const existing = await ctx.db
      .query("familyMembers")
      .withIndex("by_familyId_userId", (q) =>
        q.eq("familyId", invitation.familyId).eq("userId", user._id)
      )
      .first();

    if (existing) {
      await ctx.db.patch(args.invitationId, { status: "accepted" });
      return existing.familyId;
    }

    await ctx.db.insert("familyMembers", {
      familyId: invitation.familyId,
      userId: user._id,
      role: invitation.role,
      joinedAt: new Date().toISOString(),
    });

    await ctx.db.patch(args.invitationId, { status: "accepted" });

    return invitation.familyId;
  },
});

Declining Invitations

Users can decline invitations they don’t wish to accept:
convex/families.ts
export const declineInvitation = mutation({
  args: { invitationId: v.id("familyInvitations") },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    const invitation = await ctx.db.get(args.invitationId);
    if (!invitation) throw new Error("Invitation not found");
    if (invitation.email !== user.email) {
      throw new Error("Not your invitation");
    }

    await ctx.db.patch(args.invitationId, { status: "declined" });
  },
});

Invitation Expiration

Invitations automatically expire 7 days after creation. The expiration is checked when users attempt to accept an invitation.

Expiration Logic

const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
When accepting, the system checks:
if (new Date(invitation.expiresAt) < new Date()) {
  await ctx.db.patch(args.invitationId, { status: "expired" });
  throw new Error("Invitation has expired");
}

Best Practices

Always check for existing pending invitations before creating a new one to avoid spam:
const existing = await ctx.db
  .query("familyInvitations")
  .withIndex("by_email_status", (q) =>
    q.eq("email", args.email).eq("status", "pending")
  )
  .collect();

const alreadyInvited = existing.find((i) => i.familyId === args.familyId);
if (alreadyInvited) {
  throw new Error("This email has already been invited to this family");
}
If a user accepts an invitation but is already a member, update the invitation status without creating a duplicate membership:
if (existing) {
  await ctx.db.patch(args.invitationId, { status: "accepted" });
  return existing.familyId;
}
Ensure the invitation email matches the authenticated user’s email:
if (invitation.email !== user.email) {
  throw new Error("This invitation is for a different email");
}

Family Roles

Learn about role permissions and capabilities

Caregivers

Set up caregiver profiles for event tracking

Build docs developers (and LLMs) love