diff options
Diffstat (limited to 'packages/trpc/models/listInvitations.ts')
| -rw-r--r-- | packages/trpc/models/listInvitations.ts | 398 |
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); + } + } +} |
