Skip to content

Invitation Decline Feature - Future Enhancement

Date: November 6, 2025 Priority: LOW (Post-Beta) Status: Deferred until Week 2-3 of Beta


Current State

Invitation States: - ✅ Pending - Created, not yet accepted - ✅ Accepted - User joined team - ✅ Expired - 7 days passed - ✅ Cancelled - Owner/admin deleted invitation - ❌ Declined - User explicitly rejected invitation (NOT IMPLEMENTED)

UI: Decline button already exists in invitation acceptance page


Feature Request: Add Decline Functionality

User Story

As an invited user, I want to explicitly decline an invitation so that: 1. The inviter knows I saw the invitation 2. I stop receiving reminder emails 3. The invitation is closed properly

Implementation Plan

1. Database Changes

Add declinedAt column to team_invitations table:

ALTER TABLE team_invitations
ADD COLUMN declined_at TIMESTAMP WITH TIME ZONE;

Update schema.ts:

export const teamInvitations = pgTable('team_invitations', {
  // ... existing columns
  declinedAt: timestamp('declined_at', { withTimezone: true }),
});

2. API Endpoint

Create PATCH /api/invite/[token]/decline:

// src/app/api/invite/[token]/decline/route.ts
export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params;

  // Find invitation
  const [invitation] = await db
    .select()
    .from(teamInvitations)
    .where(eq(teamInvitations.token, token));

  if (!invitation) {
    return Response.json({ error: 'Invitation not found' }, { status: 404 });
  }

  // Check if already accepted, declined, or expired
  if (invitation.acceptedAt) {
    return Response.json({ error: 'Invitation already accepted' }, { status: 400 });
  }

  if (invitation.declinedAt) {
    return Response.json({ error: 'Invitation already declined' }, { status: 400 });
  }

  if (invitation.expiresAt < new Date()) {
    return Response.json({ error: 'Invitation expired' }, { status: 410 });
  }

  // Mark as declined
  await db
    .update(teamInvitations)
    .set({ declinedAt: new Date() })
    .where(eq(teamInvitations.id, invitation.id));

  // Get team and inviter info
  const [team] = await db.select().from(teams).where(eq(teams.id, invitation.teamId));
  const [inviter] = await db.select().from(users).where(eq(users.id, invitation.invitedBy));

  // Send notification email to inviter
  if (process.env.SPACEMAIL_SECRET && inviter) {
    await sendInvitationDeclinedEmail({
      to: inviter.email,
      inviterName: inviter.name || inviter.email,
      teamName: team.name,
      declinedEmail: invitation.email,
    }).catch(console.error);
  }

  return Response.json({
    success: true,
    message: 'Invitation declined'
  });
}

3. Email Template

Create sendInvitationDeclinedEmail() in spacemail-smtp-client.ts:

export async function sendInvitationDeclinedEmail({
  to,
  inviterName,
  teamName,
  declinedEmail,
}: {
  to: string;
  inviterName: string;
  teamName: string;
  declinedEmail: string;
}) {
  // Subject: "Invitation to TeamName was declined"
  // Body: "John Doe (john@example.com) declined your invitation to join TeamName."
}

4. UI Updates

Update /invite/[token]/page.tsx:

// Add decline handler
const handleDecline = async () => {
  const response = await fetch(`/api/invite/${token}/decline`, {
    method: 'PATCH',
  });

  if (response.ok) {
    // Show success message
    toast.success('Invitation declined');
    router.push('/');
  }
};

// Update UI
<div className="flex gap-4">
  <button onClick={handleAccept}>Accept Invitation</button>
  <button onClick={handleDecline} variant="outline">Decline</button>
</div>

5. Analytics

Track decline events in PostHog:

posthog.capture('invitation_declined', {
  teamId: invitation.teamId,
  role: invitation.role,
});


Testing Checklist

  • User can decline invitation via button
  • Declined invitation cannot be accepted later
  • Inviter receives decline notification email
  • Declined invitation shows in team settings (grayed out)
  • Cannot decline already accepted invitation
  • Cannot decline expired invitation
  • Analytics event captured correctly

Design Considerations

Q: Should declined users be able to re-invite?

A: Yes, owner/admin should be able to send a new invitation to the same email after decline.

Q: Should we show decline reason?

A: Optional, but could be valuable feedback:

[ ] Not interested
[ ] Wrong email
[ ] Already have an account
[ ] Other: ________

Q: Should declined invitations be deletable?

A: Yes, allow owner to delete declined invitations to clean up the list.


Priority

Post-Beta Enhancement - Implement after: 1. Beta users provide feedback 2. We observe invitation acceptance/ignore rates 3. We validate that users actually want this feature

Estimated Effort: 2-3 hours - 30 min: Database migration + API endpoint - 1 hour: Email template + UI updates - 30 min: Testing - 30 min: Analytics integration


Alternative: Soft Decline (Lower Priority)

Instead of explicit decline, track "invitation viewed" events: - When user opens /invite/[token], log view timestamp - Send reminder after 3 days if viewed but not accepted - Auto-cancel after 7 days

This is less intrusive and requires no user action.


Document Created: November 6, 2025 Status: Deferred to Post-Beta Review Date: Beta Week 2-3 (Nov 18-25)