diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-23 00:54:38 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-23 00:54:38 +0000 |
| commit | 5f0934acc0f7dde119be9f0a42a42742ec128377 (patch) | |
| tree | f13bd90961eab0c694eed101db0eea96e0fc4725 /packages/trpc/models | |
| parent | daee8e7a4f764f188e1773a9def1542513bf66e1 (diff) | |
| download | karakeep-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')
| -rw-r--r-- | packages/trpc/models/listInvitations.ts | 398 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 114 |
2 files changed, 448 insertions, 64 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); + } + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 2250819f..a0d9ca23 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -26,6 +26,7 @@ import { AuthedContext, Context } from ".."; import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "./bookmarks"; +import { ListInvitation } from "./listInvitations"; interface ListCollaboratorEntry { membershipId: string; @@ -536,7 +537,7 @@ export abstract class List { throw new TRPCError({ code: "NOT_FOUND" }); } invariant(result[0].userId === this.ctx.user.id); - // Fetch current collaborators count to update hasCollaborators + // Fetch current collaborators to update hasCollaborators const collaboratorsCount = await this.ctx.db.query.listCollaborators.findMany({ where: eq(listCollaborators.listId, this.list.id), @@ -596,61 +597,24 @@ export abstract class List { /** * Add a collaborator to this list by email. + * Creates a pending invitation that must be accepted by the user. + * Returns the invitation ID. */ async addCollaboratorByEmail( email: string, role: "viewer" | "editor", - ): Promise<void> { + ): Promise<string> { this.ensureCanManage(); - // Look up the user by email - const user = await this.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", - }); - } - - // Check that the user is not adding themselves - if (user.id === this.list.userId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Cannot add the list owner as a collaborator", - }); - } - - // Check that the collaborator is not already added - const existing = await this.ctx.db.query.listCollaborators.findFirst({ - where: and( - eq(listCollaborators.listId, this.list.id), - eq(listCollaborators.userId, user.id), - ), - }); - - if (existing) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "User is already a collaborator on this list", - }); - } - - // Only manual lists can be collaborative - if (this.list.type !== "manual") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only manual lists can have collaborators", - }); - } - - await this.ctx.db.insert(listCollaborators).values({ - listId: this.list.id, - userId: user.id, + return await ListInvitation.inviteByEmail(this.ctx, { + email, role, - addedBy: this.ctx.user.id, + listId: this.list.id, + listName: this.list.name, + listType: this.list.type, + listOwnerId: this.list.userId, + inviterUserId: this.ctx.user.id, + inviterName: this.ctx.user.name ?? null, }); } @@ -738,23 +702,34 @@ export abstract class List { } /** - * Get all collaborators for this list. + * Get all collaborators for this list, including pending invitations. + * For privacy, pending invitations show masked user info unless the invitation has been accepted. */ async getCollaborators() { this.ensureCanView(); - const collaborators = await this.ctx.db.query.listCollaborators.findMany({ - where: eq(listCollaborators.listId, this.list.id), - with: { - user: { - columns: { - id: true, - name: true, - email: true, + const isOwner = this.list.userId === this.ctx.user.id; + + const [collaborators, invitations] = await Promise.all([ + this.ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.listId, this.list.id), + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, }, }, - }, - }); + }), + // Only show invitations for the owner + isOwner + ? ListInvitation.invitationsForList(this.ctx, { + listId: this.list.id, + }) + : [], + ]); // Get the owner information const owner = await this.ctx.db.query.users.findFirst({ @@ -766,14 +741,24 @@ export abstract class List { }, }); - return { - collaborators: collaborators.map((c) => ({ + const collaboratorEntries = collaborators.map((c) => { + return { id: c.id, userId: c.userId, role: c.role, + status: "accepted" as const, addedAt: c.addedAt, - user: c.user, - })), + invitedAt: c.addedAt, + user: { + id: c.user.id, + name: c.user.name, + email: c.user.email, + }, + }; + }); + + return { + collaborators: [...collaboratorEntries, ...invitations], owner: owner ? { id: owner.id, @@ -786,6 +771,7 @@ export abstract class List { /** * Get all lists shared with the user (as a collaborator). + * Only includes lists where the invitation has been accepted. */ static async getSharedWithUser( ctx: AuthedContext, |
