aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/models')
-rw-r--r--packages/trpc/models/tags.ts362
-rw-r--r--packages/trpc/models/users.ts768
-rw-r--r--packages/trpc/models/webhooks.ts123
3 files changed, 1253 insertions, 0 deletions
diff --git a/packages/trpc/models/tags.ts b/packages/trpc/models/tags.ts
new file mode 100644
index 00000000..79cd855b
--- /dev/null
+++ b/packages/trpc/models/tags.ts
@@ -0,0 +1,362 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq, inArray, notExists } from "drizzle-orm";
+import { z } from "zod";
+
+import type { ZAttachedByEnum } from "@karakeep/shared/types/tags";
+import { SqliteError } from "@karakeep/db";
+import { bookmarkTags, tagsOnBookmarks } from "@karakeep/db/schema";
+import { triggerSearchReindex } from "@karakeep/shared/queues";
+import {
+ zCreateTagRequestSchema,
+ zGetTagResponseSchema,
+ zTagBasicSchema,
+ zUpdateTagRequestSchema,
+} from "@karakeep/shared/types/tags";
+
+import { AuthedContext } from "..";
+import { PrivacyAware } from "./privacy";
+
+export class Tag implements PrivacyAware {
+ constructor(
+ protected ctx: AuthedContext,
+ public tag: typeof bookmarkTags.$inferSelect,
+ ) {}
+
+ static async fromId(ctx: AuthedContext, id: string): Promise<Tag> {
+ const tag = await ctx.db.query.bookmarkTags.findFirst({
+ where: eq(bookmarkTags.id, id),
+ });
+
+ if (!tag) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Tag not found",
+ });
+ }
+
+ // If it exists but belongs to another user, throw forbidden error
+ if (tag.userId !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+
+ return new Tag(ctx, tag);
+ }
+
+ static async create(
+ ctx: AuthedContext,
+ input: z.infer<typeof zCreateTagRequestSchema>,
+ ): Promise<Tag> {
+ try {
+ const [result] = await ctx.db
+ .insert(bookmarkTags)
+ .values({
+ name: input.name,
+ userId: ctx.user.id,
+ })
+ .returning();
+
+ return new Tag(ctx, result);
+ } catch (e) {
+ if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Tag name already exists for this user.",
+ });
+ }
+ throw e;
+ }
+ }
+
+ static async getAll(ctx: AuthedContext): Promise<Tag[]> {
+ const tags = await ctx.db.query.bookmarkTags.findMany({
+ where: eq(bookmarkTags.userId, ctx.user.id),
+ });
+
+ return tags.map((t) => new Tag(ctx, t));
+ }
+
+ static async getAllWithStats(ctx: AuthedContext) {
+ const tags = await ctx.db.query.bookmarkTags.findMany({
+ where: eq(bookmarkTags.userId, ctx.user.id),
+ with: {
+ tagsOnBookmarks: {
+ columns: {
+ attachedBy: true,
+ },
+ },
+ },
+ });
+
+ return tags.map(({ tagsOnBookmarks, ...rest }) => ({
+ ...rest,
+ numBookmarks: tagsOnBookmarks.length,
+ numBookmarksByAttachedType: tagsOnBookmarks.reduce<
+ Record<ZAttachedByEnum, number>
+ >(
+ (acc, curr) => {
+ if (curr.attachedBy) {
+ acc[curr.attachedBy]++;
+ }
+ return acc;
+ },
+ { ai: 0, human: 0 },
+ ),
+ }));
+ }
+
+ static async deleteUnused(ctx: AuthedContext): Promise<number> {
+ const res = await ctx.db
+ .delete(bookmarkTags)
+ .where(
+ and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ notExists(
+ ctx.db
+ .select({ id: tagsOnBookmarks.tagId })
+ .from(tagsOnBookmarks)
+ .where(eq(tagsOnBookmarks.tagId, bookmarkTags.id)),
+ ),
+ ),
+ );
+ return res.changes;
+ }
+
+ static async merge(
+ ctx: AuthedContext,
+ input: {
+ intoTagId: string;
+ fromTagIds: string[];
+ },
+ ): Promise<{
+ mergedIntoTagId: string;
+ deletedTags: string[];
+ }> {
+ const requestedTags = new Set([input.intoTagId, ...input.fromTagIds]);
+ if (requestedTags.size === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No tags provided",
+ });
+ }
+ if (input.fromTagIds.includes(input.intoTagId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot merge tag into itself",
+ });
+ }
+
+ const affectedTags = await ctx.db.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(bookmarkTags.id, [...requestedTags]),
+ ),
+ columns: {
+ id: true,
+ userId: true,
+ },
+ });
+
+ if (affectedTags.some((t) => t.userId !== ctx.user.id)) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ if (affectedTags.length !== requestedTags.size) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "One or more tags not found",
+ });
+ }
+
+ const { deletedTags, affectedBookmarks } = await ctx.db.transaction(
+ async (trx) => {
+ const unlinked = await trx
+ .delete(tagsOnBookmarks)
+ .where(and(inArray(tagsOnBookmarks.tagId, input.fromTagIds)))
+ .returning();
+
+ if (unlinked.length > 0) {
+ await trx
+ .insert(tagsOnBookmarks)
+ .values(
+ unlinked.map((u) => ({
+ ...u,
+ tagId: input.intoTagId,
+ })),
+ )
+ .onConflictDoNothing();
+ }
+
+ const deletedTags = await trx
+ .delete(bookmarkTags)
+ .where(
+ and(
+ inArray(bookmarkTags.id, input.fromTagIds),
+ eq(bookmarkTags.userId, ctx.user.id),
+ ),
+ )
+ .returning({ id: bookmarkTags.id });
+
+ return {
+ deletedTags,
+ affectedBookmarks: unlinked.map((u) => u.bookmarkId),
+ };
+ },
+ );
+
+ try {
+ await Promise.all(
+ affectedBookmarks.map((id) => triggerSearchReindex(id)),
+ );
+ } catch (e) {
+ console.error("Failed to reindex affected bookmarks", e);
+ }
+
+ return {
+ deletedTags: deletedTags.map((t) => t.id),
+ mergedIntoTagId: input.intoTagId,
+ };
+ }
+
+ ensureCanAccess(ctx: AuthedContext): void {
+ if (this.tag.userId !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
+ async delete(): Promise<void> {
+ const affectedBookmarks = await this.ctx.db
+ .select({
+ bookmarkId: tagsOnBookmarks.bookmarkId,
+ })
+ .from(tagsOnBookmarks)
+ .where(eq(tagsOnBookmarks.tagId, this.tag.id));
+
+ const res = await this.ctx.db
+ .delete(bookmarkTags)
+ .where(
+ and(
+ eq(bookmarkTags.id, this.tag.id),
+ eq(bookmarkTags.userId, this.ctx.user.id),
+ ),
+ );
+
+ if (res.changes === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await Promise.all(
+ affectedBookmarks.map(({ bookmarkId }) =>
+ triggerSearchReindex(bookmarkId),
+ ),
+ );
+ }
+
+ async update(input: z.infer<typeof zUpdateTagRequestSchema>): Promise<void> {
+ try {
+ const result = await this.ctx.db
+ .update(bookmarkTags)
+ .set({
+ name: input.name,
+ })
+ .where(
+ and(
+ eq(bookmarkTags.id, this.tag.id),
+ eq(bookmarkTags.userId, this.ctx.user.id),
+ ),
+ )
+ .returning();
+
+ if (result.length === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ this.tag = result[0];
+
+ try {
+ const affectedBookmarks =
+ await this.ctx.db.query.tagsOnBookmarks.findMany({
+ where: eq(tagsOnBookmarks.tagId, this.tag.id),
+ columns: {
+ bookmarkId: true,
+ },
+ });
+ await Promise.all(
+ affectedBookmarks
+ .map((b) => b.bookmarkId)
+ .map((id) => triggerSearchReindex(id)),
+ );
+ } catch (e) {
+ console.error("Failed to reindex affected bookmarks", e);
+ }
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code === "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Tag name already exists. You might want to consider a merge instead.",
+ });
+ }
+ }
+ throw e;
+ }
+ }
+
+ async getStats(): Promise<z.infer<typeof zGetTagResponseSchema>> {
+ const res = await this.ctx.db
+ .select({
+ id: bookmarkTags.id,
+ name: bookmarkTags.name,
+ attachedBy: tagsOnBookmarks.attachedBy,
+ })
+ .from(bookmarkTags)
+ .leftJoin(tagsOnBookmarks, eq(bookmarkTags.id, tagsOnBookmarks.tagId))
+ .where(
+ and(
+ eq(bookmarkTags.id, this.tag.id),
+ eq(bookmarkTags.userId, this.ctx.user.id),
+ ),
+ );
+
+ if (res.length === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ const numBookmarksByAttachedType = res.reduce<
+ Record<ZAttachedByEnum, number>
+ >(
+ (acc, curr) => {
+ if (curr.attachedBy) {
+ acc[curr.attachedBy]++;
+ }
+ return acc;
+ },
+ { ai: 0, human: 0 },
+ );
+
+ return {
+ id: res[0].id,
+ name: res[0].name,
+ numBookmarks: Object.values(numBookmarksByAttachedType).reduce(
+ (s, a) => s + a,
+ 0,
+ ),
+ numBookmarksByAttachedType,
+ };
+ }
+
+ asBasicTag(): z.infer<typeof zTagBasicSchema> {
+ return {
+ id: this.tag.id,
+ name: this.tag.name,
+ };
+ }
+}
diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts
new file mode 100644
index 00000000..e6d443a7
--- /dev/null
+++ b/packages/trpc/models/users.ts
@@ -0,0 +1,768 @@
+import { randomBytes } from "crypto";
+import { TRPCError } from "@trpc/server";
+import { and, count, desc, eq, gte, sql } from "drizzle-orm";
+import invariant from "tiny-invariant";
+import { z } from "zod";
+
+import { SqliteError } from "@karakeep/db";
+import {
+ assets,
+ bookmarkLinks,
+ bookmarkLists,
+ bookmarks,
+ bookmarkTags,
+ highlights,
+ passwordResetTokens,
+ tagsOnBookmarks,
+ users,
+ userSettings,
+ verificationTokens,
+} from "@karakeep/db/schema";
+import { deleteUserAssets } from "@karakeep/shared/assetdb";
+import serverConfig from "@karakeep/shared/config";
+import {
+ zResetPasswordSchema,
+ zSignUpSchema,
+ zUpdateUserSettingsSchema,
+ zUserSettingsSchema,
+ zUserStatsResponseSchema,
+ zWhoAmIResponseSchema,
+} from "@karakeep/shared/types/users";
+
+import { AuthedContext, Context } from "..";
+import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
+import { sendPasswordResetEmail, sendVerificationEmail } from "../email";
+import { PrivacyAware } from "./privacy";
+
+export class User implements PrivacyAware {
+ constructor(
+ protected ctx: AuthedContext,
+ public user: typeof users.$inferSelect,
+ ) {}
+
+ static async fromId_DANGEROUS(ctx: AuthedContext, id: string): Promise<User> {
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.id, id),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ return new User(ctx, user);
+ }
+
+ static async fromCtx(ctx: AuthedContext): Promise<User> {
+ return this.fromId_DANGEROUS(ctx, ctx.user.id);
+ }
+
+ static async create(
+ ctx: Context,
+ input: z.infer<typeof zSignUpSchema>,
+ role?: "user" | "admin",
+ ) {
+ const salt = generatePasswordSalt();
+ const user = await User.createRaw(ctx.db, {
+ name: input.name,
+ email: input.email,
+ password: await hashPassword(input.password, salt),
+ salt,
+ role,
+ });
+
+ if (serverConfig.auth.emailVerificationRequired) {
+ const token = await User.genEmailVerificationToken(ctx.db, input.email);
+ try {
+ await sendVerificationEmail(input.email, input.name, token);
+ } catch (error) {
+ console.error("Failed to send verification email:", error);
+ }
+ }
+
+ return user;
+ }
+
+ static async createRaw(
+ db: Context["db"],
+ input: {
+ name: string;
+ email: string;
+ password?: string;
+ salt?: string;
+ role?: "user" | "admin";
+ emailVerified?: Date | null;
+ },
+ ) {
+ return await db.transaction(async (trx) => {
+ let userRole = input.role;
+ if (!userRole) {
+ const [{ count: userCount }] = await trx
+ .select({ count: count() })
+ .from(users);
+ userRole = userCount === 0 ? "admin" : "user";
+ }
+
+ try {
+ const [result] = await trx
+ .insert(users)
+ .values({
+ name: input.name,
+ email: input.email,
+ password: input.password,
+ salt: input.salt,
+ role: userRole,
+ emailVerified: input.emailVerified,
+ bookmarkQuota: serverConfig.quotas.free.bookmarkLimit,
+ storageQuota: serverConfig.quotas.free.assetSizeBytes,
+ })
+ .returning();
+
+ await trx.insert(userSettings).values({
+ userId: result.id,
+ });
+
+ return result;
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code === "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email is already taken",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ });
+ }
+
+ static async getAll(ctx: AuthedContext): Promise<User[]> {
+ const dbUsers = await ctx.db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ password: users.password,
+ bookmarkQuota: users.bookmarkQuota,
+ storageQuota: users.storageQuota,
+ emailVerified: users.emailVerified,
+ image: users.image,
+ salt: users.salt,
+ browserCrawlingEnabled: users.browserCrawlingEnabled,
+ })
+ .from(users);
+
+ return dbUsers.map((u) => new User(ctx, u));
+ }
+
+ static async genEmailVerificationToken(
+ db: Context["db"],
+ email: string,
+ ): Promise<string> {
+ const token = randomBytes(10).toString("hex");
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
+
+ await db.insert(verificationTokens).values({
+ identifier: email,
+ token,
+ expires,
+ });
+
+ return token;
+ }
+
+ static async verifyEmailToken(
+ db: Context["db"],
+ email: string,
+ token: string,
+ ): Promise<boolean> {
+ const verificationToken = await db.query.verificationTokens.findFirst({
+ where: (vt, { and, eq }) =>
+ and(eq(vt.identifier, email), eq(vt.token, token)),
+ });
+
+ if (!verificationToken) {
+ return false;
+ }
+
+ if (verificationToken.expires < new Date()) {
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+ return false;
+ }
+
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+
+ return true;
+ }
+
+ static async verifyEmail(
+ ctx: Context,
+ email: string,
+ token: string,
+ ): Promise<void> {
+ const isValid = await User.verifyEmailToken(ctx.db, email, token);
+ if (!isValid) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired verification token",
+ });
+ }
+
+ const result = await ctx.db
+ .update(users)
+ .set({ emailVerified: new Date() })
+ .where(eq(users.email, email));
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+ }
+
+ static async resendVerificationEmail(
+ ctx: Context,
+ email: string,
+ ): Promise<void> {
+ if (
+ !serverConfig.auth.emailVerificationRequired ||
+ !serverConfig.email.smtp
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email verification is not enabled",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+
+ if (!user) {
+ return; // Don't reveal if user exists or not for security
+ }
+
+ if (user.emailVerified) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email is already verified",
+ });
+ }
+
+ const token = await User.genEmailVerificationToken(ctx.db, email);
+ try {
+ await sendVerificationEmail(email, user.name, token);
+ } catch (error) {
+ console.error("Failed to send verification email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send verification email",
+ });
+ }
+ }
+
+ static async forgotPassword(ctx: Context, email: string): Promise<void> {
+ if (!serverConfig.email.smtp) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email service is not configured",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+
+ if (!user || !user.password) {
+ return; // Don't reveal if user exists or not for security
+ }
+
+ try {
+ const token = randomBytes(32).toString("hex");
+ const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+
+ await ctx.db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ await sendPasswordResetEmail(email, user.name, token);
+ } catch (error) {
+ console.error("Failed to send password reset email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send password reset email",
+ });
+ }
+ }
+
+ static async resetPassword(
+ ctx: Context,
+ input: z.infer<typeof zResetPasswordSchema>,
+ ): Promise<void> {
+ const resetToken = await ctx.db.query.passwordResetTokens.findFirst({
+ where: eq(passwordResetTokens.token, input.token),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ });
+
+ if (!resetToken) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (resetToken.expires < new Date()) {
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, input.token));
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (!resetToken.user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ const newSalt = generatePasswordSalt();
+ const hashedPassword = await hashPassword(input.newPassword, newSalt);
+
+ await ctx.db
+ .update(users)
+ .set({
+ password: hashedPassword,
+ salt: newSalt,
+ })
+ .where(eq(users.id, resetToken.user.id));
+
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, input.token));
+ }
+
+ ensureCanAccess(ctx: AuthedContext): void {
+ if (this.user.id !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
+ private static async deleteInternal(db: Context["db"], userId: string) {
+ const res = await db.delete(users).where(eq(users.id, userId));
+
+ if (res.changes === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ await deleteUserAssets({ userId: userId });
+ }
+
+ static async deleteAsAdmin(
+ adminCtx: AuthedContext,
+ userId: string,
+ ): Promise<void> {
+ invariant(adminCtx.user.role === "admin", "Only admins can delete users");
+ await this.deleteInternal(adminCtx.db, userId);
+ }
+
+ async deleteAccount(password?: string): Promise<void> {
+ invariant(this.ctx.user.email, "A user always has an email specified");
+
+ if (this.user.password) {
+ if (!password) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Password is required for local accounts",
+ });
+ }
+
+ try {
+ await validatePassword(this.ctx.user.email, password, this.ctx.db);
+ } catch {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Invalid password",
+ });
+ }
+ }
+
+ await User.deleteInternal(this.ctx.db, this.user.id);
+ }
+
+ async changePassword(
+ currentPassword: string,
+ newPassword: string,
+ ): Promise<void> {
+ invariant(this.ctx.user.email, "A user always has an email specified");
+
+ try {
+ const user = await validatePassword(
+ this.ctx.user.email,
+ currentPassword,
+ this.ctx.db,
+ );
+ invariant(user.id === this.ctx.user.id);
+ } catch {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ const newSalt = generatePasswordSalt();
+ await this.ctx.db
+ .update(users)
+ .set({
+ password: await hashPassword(newPassword, newSalt),
+ salt: newSalt,
+ })
+ .where(eq(users.id, this.user.id));
+ }
+
+ async getSettings(): Promise<z.infer<typeof zUserSettingsSchema>> {
+ const settings = await this.ctx.db.query.userSettings.findFirst({
+ where: eq(userSettings.userId, this.user.id),
+ });
+
+ if (!settings) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User settings not found",
+ });
+ }
+
+ return {
+ bookmarkClickAction: settings.bookmarkClickAction,
+ archiveDisplayBehaviour: settings.archiveDisplayBehaviour,
+ timezone: settings.timezone || "UTC",
+ };
+ }
+
+ async updateSettings(
+ input: z.infer<typeof zUpdateUserSettingsSchema>,
+ ): Promise<void> {
+ if (Object.keys(input).length === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "No settings provided",
+ });
+ }
+
+ await this.ctx.db
+ .update(userSettings)
+ .set({
+ bookmarkClickAction: input.bookmarkClickAction,
+ archiveDisplayBehaviour: input.archiveDisplayBehaviour,
+ timezone: input.timezone,
+ })
+ .where(eq(userSettings.userId, this.user.id));
+ }
+
+ async getStats(): Promise<z.infer<typeof zUserStatsResponseSchema>> {
+ const userSet = await this.ctx.db.query.userSettings.findFirst({
+ where: eq(userSettings.userId, this.user.id),
+ });
+ const userTimezone = userSet?.timezone || "UTC";
+ const now = new Date();
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+ const yearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
+
+ const [
+ [{ numBookmarks }],
+ [{ numFavorites }],
+ [{ numArchived }],
+ [{ numTags }],
+ [{ numLists }],
+ [{ numHighlights }],
+ bookmarksByType,
+ topDomains,
+ [{ totalAssetSize }],
+ assetsByType,
+ [{ thisWeek }],
+ [{ thisMonth }],
+ [{ thisYear }],
+ bookmarkTimestamps,
+ tagUsage,
+ ] = await Promise.all([
+ // Basic counts
+ this.ctx.db
+ .select({ numBookmarks: count() })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, this.user.id)),
+ this.ctx.db
+ .select({ numFavorites: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.user.id),
+ eq(bookmarks.favourited, true),
+ ),
+ ),
+ this.ctx.db
+ .select({ numArchived: count() })
+ .from(bookmarks)
+ .where(
+ and(eq(bookmarks.userId, this.user.id), eq(bookmarks.archived, true)),
+ ),
+ this.ctx.db
+ .select({ numTags: count() })
+ .from(bookmarkTags)
+ .where(eq(bookmarkTags.userId, this.user.id)),
+ this.ctx.db
+ .select({ numLists: count() })
+ .from(bookmarkLists)
+ .where(eq(bookmarkLists.userId, this.user.id)),
+ this.ctx.db
+ .select({ numHighlights: count() })
+ .from(highlights)
+ .where(eq(highlights.userId, this.user.id)),
+
+ // Bookmarks by type
+ this.ctx.db
+ .select({
+ type: bookmarks.type,
+ count: count(),
+ })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, this.user.id))
+ .groupBy(bookmarks.type),
+
+ // Top domains
+ this.ctx.db
+ .select({
+ domain: sql<string>`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 9)
+ END
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 8)
+ END
+ ELSE
+ CASE
+ WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
+ ELSE
+ ${bookmarkLinks.url}
+ END
+ END`,
+ count: count(),
+ })
+ .from(bookmarkLinks)
+ .innerJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
+ .where(eq(bookmarks.userId, this.user.id))
+ .groupBy(
+ sql`CASE
+ WHEN ${bookmarkLinks.url} LIKE 'https://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 9, INSTR(SUBSTR(${bookmarkLinks.url}, 9), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 9)
+ END
+ WHEN ${bookmarkLinks.url} LIKE 'http://%' THEN
+ CASE
+ WHEN INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 8, INSTR(SUBSTR(${bookmarkLinks.url}, 8), '/') - 1)
+ ELSE
+ SUBSTR(${bookmarkLinks.url}, 8)
+ END
+ ELSE
+ CASE
+ WHEN INSTR(${bookmarkLinks.url}, '/') > 0 THEN
+ SUBSTR(${bookmarkLinks.url}, 1, INSTR(${bookmarkLinks.url}, '/') - 1)
+ ELSE
+ ${bookmarkLinks.url}
+ END
+ END`,
+ )
+ .orderBy(desc(count()))
+ .limit(10),
+
+ // Total asset size
+ this.ctx.db
+ .select({
+ totalAssetSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`,
+ })
+ .from(assets)
+ .where(eq(assets.userId, this.user.id)),
+
+ // Assets by type
+ this.ctx.db
+ .select({
+ type: assets.assetType,
+ count: count(),
+ totalSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`,
+ })
+ .from(assets)
+ .where(eq(assets.userId, this.user.id))
+ .groupBy(assets.assetType),
+
+ // Activity stats
+ this.ctx.db
+ .select({ thisWeek: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.user.id),
+ gte(bookmarks.createdAt, weekAgo),
+ ),
+ ),
+ this.ctx.db
+ .select({ thisMonth: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.user.id),
+ gte(bookmarks.createdAt, monthAgo),
+ ),
+ ),
+ this.ctx.db
+ .select({ thisYear: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.user.id),
+ gte(bookmarks.createdAt, yearAgo),
+ ),
+ ),
+
+ // Get all bookmark timestamps for timezone conversion
+ this.ctx.db
+ .select({
+ createdAt: bookmarks.createdAt,
+ })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, this.user.id)),
+
+ // Tag usage
+ this.ctx.db
+ .select({
+ name: bookmarkTags.name,
+ count: count(),
+ })
+ .from(bookmarkTags)
+ .innerJoin(tagsOnBookmarks, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
+ .where(eq(bookmarkTags.userId, this.user.id))
+ .groupBy(bookmarkTags.name)
+ .orderBy(desc(count()))
+ .limit(10),
+ ]);
+
+ // Process bookmarks by type
+ const bookmarkTypeMap = { link: 0, text: 0, asset: 0 };
+ bookmarksByType.forEach((item) => {
+ if (item.type in bookmarkTypeMap) {
+ bookmarkTypeMap[item.type as keyof typeof bookmarkTypeMap] = item.count;
+ }
+ });
+
+ // Process timestamps with user timezone
+ const hourCounts = Array.from({ length: 24 }, () => 0);
+ const dayCounts = Array.from({ length: 7 }, () => 0);
+
+ bookmarkTimestamps.forEach(({ createdAt }) => {
+ if (createdAt) {
+ const date = new Date(createdAt);
+ const userDate = new Date(
+ date.toLocaleString("en-US", { timeZone: userTimezone }),
+ );
+
+ const hour = userDate.getHours();
+ const day = userDate.getDay();
+
+ hourCounts[hour]++;
+ dayCounts[day]++;
+ }
+ });
+
+ const hourlyActivity = Array.from({ length: 24 }, (_, i) => ({
+ hour: i,
+ count: hourCounts[i],
+ }));
+
+ const dailyActivity = Array.from({ length: 7 }, (_, i) => ({
+ day: i,
+ count: dayCounts[i],
+ }));
+
+ return {
+ numBookmarks,
+ numFavorites,
+ numArchived,
+ numTags,
+ numLists,
+ numHighlights,
+ bookmarksByType: bookmarkTypeMap,
+ topDomains: topDomains.filter((d) => d.domain && d.domain.length > 0),
+ totalAssetSize: totalAssetSize || 0,
+ assetsByType,
+ bookmarkingActivity: {
+ thisWeek: thisWeek || 0,
+ thisMonth: thisMonth || 0,
+ thisYear: thisYear || 0,
+ byHour: hourlyActivity,
+ byDayOfWeek: dailyActivity,
+ },
+ tagUsage,
+ };
+ }
+
+ asWhoAmI(): z.infer<typeof zWhoAmIResponseSchema> {
+ return {
+ id: this.user.id,
+ name: this.user.name,
+ email: this.user.email,
+ localUser: this.user.password !== null,
+ };
+ }
+
+ asPublicUser() {
+ const { password, salt: _salt, ...rest } = this.user;
+ return {
+ ...rest,
+ localUser: password !== null,
+ };
+ }
+}
diff --git a/packages/trpc/models/webhooks.ts b/packages/trpc/models/webhooks.ts
new file mode 100644
index 00000000..3a8c7bab
--- /dev/null
+++ b/packages/trpc/models/webhooks.ts
@@ -0,0 +1,123 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+import { z } from "zod";
+
+import { webhooksTable } from "@karakeep/db/schema";
+import {
+ zNewWebhookSchema,
+ zUpdateWebhookSchema,
+ zWebhookSchema,
+} from "@karakeep/shared/types/webhooks";
+
+import { AuthedContext } from "..";
+import { PrivacyAware } from "./privacy";
+
+export class Webhook implements PrivacyAware {
+ constructor(
+ protected ctx: AuthedContext,
+ public webhook: typeof webhooksTable.$inferSelect,
+ ) {}
+
+ static async fromId(ctx: AuthedContext, id: string): Promise<Webhook> {
+ const webhook = await ctx.db.query.webhooksTable.findFirst({
+ where: eq(webhooksTable.id, id),
+ });
+
+ if (!webhook) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Webhook not found",
+ });
+ }
+
+ // If it exists but belongs to another user, throw forbidden error
+ if (webhook.userId !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+
+ return new Webhook(ctx, webhook);
+ }
+
+ static async create(
+ ctx: AuthedContext,
+ input: z.infer<typeof zNewWebhookSchema>,
+ ): Promise<Webhook> {
+ const [result] = await ctx.db
+ .insert(webhooksTable)
+ .values({
+ url: input.url,
+ events: input.events,
+ token: input.token ?? null,
+ userId: ctx.user.id,
+ })
+ .returning();
+
+ return new Webhook(ctx, result);
+ }
+
+ static async getAll(ctx: AuthedContext): Promise<Webhook[]> {
+ const webhooks = await ctx.db.query.webhooksTable.findMany({
+ where: eq(webhooksTable.userId, ctx.user.id),
+ });
+
+ return webhooks.map((w) => new Webhook(ctx, w));
+ }
+
+ ensureCanAccess(ctx: AuthedContext): void {
+ if (this.webhook.userId !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
+ async delete(): Promise<void> {
+ const res = await this.ctx.db
+ .delete(webhooksTable)
+ .where(
+ and(
+ eq(webhooksTable.id, this.webhook.id),
+ eq(webhooksTable.userId, this.ctx.user.id),
+ ),
+ );
+
+ if (res.changes === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ }
+
+ async update(input: z.infer<typeof zUpdateWebhookSchema>): Promise<void> {
+ const result = await this.ctx.db
+ .update(webhooksTable)
+ .set({
+ url: input.url,
+ events: input.events,
+ token: input.token,
+ })
+ .where(
+ and(
+ eq(webhooksTable.id, this.webhook.id),
+ eq(webhooksTable.userId, this.ctx.user.id),
+ ),
+ )
+ .returning();
+
+ if (result.length === 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ this.webhook = result[0];
+ }
+
+ asPublicWebhook(): z.infer<typeof zWebhookSchema> {
+ const { token, ...rest } = this.webhook;
+ return {
+ ...rest,
+ hasToken: token !== null,
+ };
+ }
+}