aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models
diff options
context:
space:
mode:
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,