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 { 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 { 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 { 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 { 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 { 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); } } }