aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models/listInvitations.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-23 00:54:38 +0000
committerGitHub <noreply@github.com>2025-11-23 00:54:38 +0000
commit5f0934acc0f7dde119be9f0a42a42742ec128377 (patch)
treef13bd90961eab0c694eed101db0eea96e0fc4725 /packages/trpc/models/listInvitations.ts
parentdaee8e7a4f764f188e1773a9def1542513bf66e1 (diff)
downloadkarakeep-5f0934acc0f7dde119be9f0a42a42742ec128377.tar.zst
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>
Diffstat (limited to 'packages/trpc/models/listInvitations.ts')
-rw-r--r--packages/trpc/models/listInvitations.ts398
1 files changed, 398 insertions, 0 deletions
diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts
new file mode 100644
index 00000000..6bdc8ffa
--- /dev/null
+++ b/packages/trpc/models/listInvitations.ts
@@ -0,0 +1,398 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+
+import { listCollaborators, listInvitations } from "@karakeep/db/schema";
+
+import type { AuthedContext } from "..";
+
+type Role = "viewer" | "editor";
+type InvitationStatus = "pending" | "declined";
+
+interface InvitationData {
+ id: string;
+ listId: string;
+ userId: string;
+ role: Role;
+ status: InvitationStatus;
+ invitedAt: Date;
+ invitedEmail: string | null;
+ invitedBy: string | null;
+ listOwnerUserId: string;
+}
+
+export class ListInvitation {
+ protected constructor(
+ protected ctx: AuthedContext,
+ protected invitation: InvitationData,
+ ) {}
+
+ get id() {
+ return this.invitation.id;
+ }
+
+ /**
+ * Load an invitation by ID
+ * Can be accessed by:
+ * - The invited user (userId matches)
+ * - The list owner (via list ownership check)
+ */
+ static async fromId(
+ ctx: AuthedContext,
+ invitationId: string,
+ ): Promise<ListInvitation> {
+ const invitation = await ctx.db.query.listInvitations.findFirst({
+ where: eq(listInvitations.id, invitationId),
+ with: {
+ list: {
+ columns: {
+ userId: true,
+ },
+ },
+ },
+ });
+
+ if (!invitation) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ // Check if user has access to this invitation
+ const isInvitedUser = invitation.userId === ctx.user.id;
+ const isListOwner = invitation.list.userId === ctx.user.id;
+
+ if (!isInvitedUser && !isListOwner) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ return new ListInvitation(ctx, {
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ invitedEmail: invitation.invitedEmail,
+ invitedBy: invitation.invitedBy,
+ listOwnerUserId: invitation.list.userId,
+ });
+ }
+
+ /**
+ * Ensure the current user is the invited user
+ */
+ ensureIsInvitedUser() {
+ if (this.invitation.userId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the invited user can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Ensure the current user is the list owner
+ */
+ ensureIsListOwner() {
+ if (this.invitation.listOwnerUserId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the list owner can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Accept the invitation
+ */
+ async accept(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be accepted",
+ });
+ }
+
+ await this.ctx.db.transaction(async (tx) => {
+ await tx
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+
+ await tx
+ .insert(listCollaborators)
+ .values({
+ listId: this.invitation.listId,
+ userId: this.invitation.userId,
+ role: this.invitation.role,
+ addedBy: this.invitation.invitedBy,
+ })
+ .onConflictDoNothing();
+ });
+ }
+
+ /**
+ * Decline the invitation
+ */
+ async decline(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be declined",
+ });
+ }
+
+ await this.ctx.db
+ .update(listInvitations)
+ .set({
+ status: "declined",
+ })
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * Revoke the invitation (owner only)
+ */
+ async revoke(): Promise<void> {
+ this.ensureIsListOwner();
+
+ await this.ctx.db
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * @returns the invitation ID
+ */
+ static async inviteByEmail(
+ ctx: AuthedContext,
+ params: {
+ email: string;
+ role: Role;
+ listId: string;
+ listName: string;
+ listType: "manual" | "smart";
+ listOwnerId: string;
+ inviterUserId: string;
+ inviterName: string | null;
+ },
+ ): Promise<string> {
+ const {
+ email,
+ role,
+ listId,
+ listName,
+ listType,
+ listOwnerId,
+ inviterUserId,
+ inviterName,
+ } = params;
+
+ const user = await ctx.db.query.users.findFirst({
+ where: (users, { eq }) => eq(users.email, email),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No user found with that email address",
+ });
+ }
+
+ if (user.id === listOwnerId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot add the list owner as a collaborator",
+ });
+ }
+
+ if (listType !== "manual") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only manual lists can have collaborators",
+ });
+ }
+
+ const existingCollaborator = await ctx.db.query.listCollaborators.findFirst(
+ {
+ where: and(
+ eq(listCollaborators.listId, listId),
+ eq(listCollaborators.userId, user.id),
+ ),
+ },
+ );
+
+ if (existingCollaborator) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User is already a collaborator on this list",
+ });
+ }
+
+ const existingInvitation = await ctx.db.query.listInvitations.findFirst({
+ where: and(
+ eq(listInvitations.listId, listId),
+ eq(listInvitations.userId, user.id),
+ ),
+ });
+
+ if (existingInvitation) {
+ if (existingInvitation.status === "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User already has a pending invitation for this list",
+ });
+ } else if (existingInvitation.status === "declined") {
+ await ctx.db
+ .update(listInvitations)
+ .set({
+ status: "pending",
+ role,
+ invitedAt: new Date(),
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .where(eq(listInvitations.id, existingInvitation.id));
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return existingInvitation.id;
+ }
+ }
+
+ const res = await ctx.db
+ .insert(listInvitations)
+ .values({
+ listId,
+ userId: user.id,
+ role,
+ status: "pending",
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .returning();
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return res[0].id;
+ }
+
+ static async pendingForUser(ctx: AuthedContext) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: and(
+ eq(listInvitations.userId, ctx.user.id),
+ eq(listInvitations.status, "pending"),
+ ),
+ with: {
+ list: {
+ columns: {
+ id: true,
+ name: true,
+ icon: true,
+ description: true,
+ rssToken: false,
+ },
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return invitations.map((inv) => ({
+ id: inv.id,
+ listId: inv.listId,
+ role: inv.role,
+ invitedAt: inv.invitedAt,
+ list: {
+ id: inv.list.id,
+ name: inv.list.name,
+ icon: inv.list.icon,
+ description: inv.list.description,
+ owner: inv.list.user
+ ? {
+ id: inv.list.user.id,
+ name: inv.list.user.name,
+ email: inv.list.user.email,
+ }
+ : null,
+ },
+ }));
+ }
+
+ static async invitationsForList(
+ ctx: AuthedContext,
+ params: { listId: string },
+ ) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: eq(listInvitations.listId, params.listId),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ return invitations.map((invitation) => ({
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ addedAt: invitation.invitedAt,
+ user: {
+ id: invitation.user.id,
+ // Don't show the actual user's name for any invitation (pending or declined)
+ // This protects user privacy until they accept
+ name: "Pending User",
+ email: invitation.user.email || "",
+ },
+ }));
+ }
+
+ static async sendInvitationEmail(params: {
+ email: string;
+ inviterName: string | null;
+ listName: string;
+ listId: string;
+ }) {
+ try {
+ const { sendListInvitationEmail } = await import("../email");
+ await sendListInvitationEmail(
+ params.email,
+ params.inviterName || "A user",
+ params.listName,
+ params.listId,
+ );
+ } catch (error) {
+ // Log the error but don't fail the invitation
+ console.error("Failed to send list invitation email:", error);
+ }
+ }
+}