aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models
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
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')
-rw-r--r--packages/trpc/models/listInvitations.ts398
-rw-r--r--packages/trpc/models/lists.ts114
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,