mirror of
https://github.com/karakeep-app/karakeep.git
synced 2026-10-16 01:49:03 +02:00
* feat: Add owner icon to bookmarks in shared lists Display a small icon showing the bookmark owner's name and email on hover when viewing bookmarks from other users in shared lists. The icon appears in the top-right corner of bookmark cards across all layout types (grid, list, compact). Changes: - Add user field to ZBookmark type to include owner name and email - Update bookmark queries to fetch user information via join - Create BookmarkOwnerIcon component with tooltip showing owner details - Integrate owner indicator into BookmarkLayoutAdaptingCard for all layouts - Only show icon for bookmarks not owned by current user * use icons in more places * remove tooltip providers * fix non list context --------- Co-authored-by: Claude <noreply@anthropic.com>
400 lines
9.4 KiB
TypeScript
400 lines
9.4 KiB
TypeScript
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 || "",
|
|
image: null,
|
|
},
|
|
}));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|