From 5f0934acc0f7dde119be9f0a42a42742ec128377 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Sun, 23 Nov 2025 00:54:38 +0000
Subject: feat: Add invitation approval for shared lists (#2152)

* feat: Add invitation approval system for collaborative lists

- Add database schema changes to support pending invitations
  - Add status field (pending/accepted/declined) to listCollaborators
  - Add invitedAt and invitedEmail fields for tracking
  - Add index on status for efficient queries

- Update List model with invitation workflow methods
  - Modify addCollaboratorByEmail to create pending invitations
  - Add acceptInvitation() for users to accept invites
  - Add declineInvitation() for users to decline invites
  - Add revokeInvitation() for owners to revoke pending invites
  - Add getPendingInvitations() to get user's pending invites

- Implement privacy protection for pending invitations
  - Mask user names as "Pending User" until invitation is accepted
  - Only show email to list owner for pending invitations

- Update getSharedWithUser to only include accepted collaborations
  - Ensures lists only appear after invitation is accepted

* feat: Add tRPC procedures and email notifications for list invitations

- Add new tRPC procedures for invitation workflow
  - acceptInvitation: Allow users to accept pending invitations
  - declineInvitation: Allow users to decline invitations
  - revokeInvitation: Allow owners to revoke pending invitations
  - getPendingInvitations: Get all pending invitations for current user

- Update getCollaborators output schema
  - Add status, invitedAt fields to collaborator objects
  - Support privacy-masked user info for pending invitations

- Add sendListInvitationEmail function
  - Email notification when user is invited to collaborate
  - Includes list name, inviter name, and link to view invitation
  - Gracefully handles missing SMTP configuration

- Integrate email sending into invitation workflow
  - Send email when new invitation is created
  - Send email when declined invitation is renewed
  - Catch and log errors without failing the invitation

* feat: Add UI for list invitation approval workflow

- Update ManageCollaboratorsModal to support pending invitations
  - Show "Pending" badge for pending invitations
  - Add revoke button for owners to cancel pending invitations
  - Update success message to reflect invitation sent
  - Disable role change and remove buttons for pending invitations

- Create PendingInvitationsCard component
  - Display all pending invitations for the current user
  - Show list name, description, inviter, and role
  - Provide Accept and Decline buttons
  - Auto-hide when no pending invitations exist

- Add PendingInvitationsCard to lists page
  - Show at the top of the lists page
  - Only renders when user has pending invitations

* fix: Add missing translation keys and fix TypeScript errors

- Add translation keys for invitation system
  - lists.collaborators.invitation_sent
  - lists.collaborators.pending
  - lists.collaborators.revoke
  - lists.collaborators.invitation_revoked
  - lists.collaborators.failed_to_revoke
  - lists.invitations.* (all invitation-related keys)

- Fix TypeScript errors in email sending
  - Handle optional user.name with fallback to 'A user'

* wip

* fixes

* more fixes

* fix revoke

* more improvements

* comment fix

* fix email url

* fix schemas

* split pending invites into components

* more fixes

* test

* test fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
---
 .../dashboard/lists/PendingInvitationsCard.tsx     | 158 +++++++++++++++++++++
 1 file changed, 158 insertions(+)
 create mode 100644 apps/web/components/dashboard/lists/PendingInvitationsCard.tsx

(limited to 'apps/web/components/dashboard/lists/PendingInvitationsCard.tsx')

diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx
new file mode 100644
index 00000000..c453a91f
--- /dev/null
+++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "@/components/ui/card";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { Check, Loader2, Mail, X } from "lucide-react";
+
+interface Invitation {
+  id: string;
+  role: string;
+  list: {
+    name: string;
+    icon?: string;
+    description?: string | null;
+    owner?: {
+      name?: string;
+    } | null;
+  };
+}
+
+function InvitationRow({ invitation }: { invitation: Invitation }) {
+  const { t } = useTranslation();
+  const utils = api.useUtils();
+
+  const acceptInvitation = api.lists.acceptInvitation.useMutation({
+    onSuccess: async () => {
+      toast({
+        description: t("lists.invitations.accepted"),
+      });
+      await Promise.all([
+        utils.lists.getPendingInvitations.invalidate(),
+        utils.lists.list.invalidate(),
+      ]);
+    },
+    onError: (error) => {
+      toast({
+        variant: "destructive",
+        description: error.message || t("lists.invitations.failed_to_accept"),
+      });
+    },
+  });
+
+  const declineInvitation = api.lists.declineInvitation.useMutation({
+    onSuccess: async () => {
+      toast({
+        description: t("lists.invitations.declined"),
+      });
+      await utils.lists.getPendingInvitations.invalidate();
+    },
+    onError: (error) => {
+      toast({
+        variant: "destructive",
+        description: error.message || t("lists.invitations.failed_to_decline"),
+      });
+    },
+  });
+
+  return (
+    <div className="flex items-center justify-between rounded-lg border p-4">
+      <div className="flex-1">
+        <div className="flex items-center gap-2">
+          <span className="font-medium">{invitation.list.name}</span>
+          <span className="text-xs text-muted-foreground">
+            {invitation.list.icon}
+          </span>
+        </div>
+        {invitation.list.description && (
+          <div className="mt-1 text-sm text-muted-foreground">
+            {invitation.list.description}
+          </div>
+        )}
+        <div className="mt-2 text-sm text-muted-foreground">
+          {t("lists.invitations.invited_by")}{" "}
+          <span className="font-medium">
+            {invitation.list.owner?.name || "Unknown"}
+          </span>
+          {" • "}
+          <span className="capitalize">{invitation.role}</span>
+        </div>
+      </div>
+      <div className="flex items-center gap-2">
+        <Button
+          size="sm"
+          variant="outline"
+          onClick={() =>
+            declineInvitation.mutate({ invitationId: invitation.id })
+          }
+          disabled={declineInvitation.isPending || acceptInvitation.isPending}
+        >
+          {declineInvitation.isPending ? (
+            <Loader2 className="h-4 w-4 animate-spin" />
+          ) : (
+            <>
+              <X className="mr-1 h-4 w-4" />
+              {t("lists.invitations.decline")}
+            </>
+          )}
+        </Button>
+        <Button
+          size="sm"
+          onClick={() =>
+            acceptInvitation.mutate({ invitationId: invitation.id })
+          }
+          disabled={acceptInvitation.isPending || declineInvitation.isPending}
+        >
+          {acceptInvitation.isPending ? (
+            <Loader2 className="h-4 w-4 animate-spin" />
+          ) : (
+            <>
+              <Check className="mr-1 h-4 w-4" />
+              {t("lists.invitations.accept")}
+            </>
+          )}
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+export function PendingInvitationsCard() {
+  const { t } = useTranslation();
+
+  const { data: invitations, isLoading } =
+    api.lists.getPendingInvitations.useQuery();
+
+  if (isLoading) {
+    return null;
+  }
+
+  if (!invitations || invitations.length === 0) {
+    return null;
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2">
+          <Mail className="h-5 w-5" />
+          {t("lists.invitations.pending")} ({invitations.length})
+        </CardTitle>
+        <CardDescription>{t("lists.invitations.description")}</CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        {invitations.map((invitation) => (
+          <InvitationRow key={invitation.id} invitation={invitation} />
+        ))}
+      </CardContent>
+    </Card>
+  );
+}
-- 
cgit v1.2.3-70-g09d2

