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.
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" );
}
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" );
}
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
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" );
}
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" );
}
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
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:
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
Prevent duplicate invitations
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" );
}
Handle existing members gracefully
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