aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/users.ts
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2025-08-02 21:41:59 -0700
committerMohamedBassem <me@mbassem.com>2025-08-02 21:41:59 -0700
commit99653566f73187631d30cb52a66a982c455c1f9a (patch)
treead7d276d88677deea6848821132116e191ad6dff /packages/trpc/routers/users.ts
parent2493ccf08e4a4e96c6be8f3e5ee80f7db7284dfe (diff)
downloadkarakeep-99653566f73187631d30cb52a66a982c455c1f9a.tar.zst
refactor: Move webhook, users and tags into models
Diffstat (limited to 'packages/trpc/routers/users.ts')
-rw-r--r--packages/trpc/routers/users.ts739
1 files changed, 31 insertions, 708 deletions
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index 6aa12454..5ce9c67e 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -1,24 +1,6 @@
-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,
@@ -29,160 +11,14 @@ import {
zWhoAmIResponseSchema,
} from "@karakeep/shared/types/users";
-import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
-import { sendPasswordResetEmail, sendVerificationEmail } from "../email";
import {
adminProcedure,
authedProcedure,
- Context,
createRateLimitMiddleware,
publicProcedure,
router,
} from "../index";
-
-async function genEmailVerificationToken(db: Context["db"], email: string) {
- const token = randomBytes(10).toString("hex");
- const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
-
- // Store verification token
- await db.insert(verificationTokens).values({
- identifier: email,
- token,
- expires,
- });
-
- return token;
-}
-
-async function 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()) {
- // Clean up expired token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
- return false;
- }
-
- // Clean up used token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
-
- return true;
-}
-
-export async function createUserRaw(
- 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({
- id: users.id,
- name: users.name,
- email: users.email,
- role: users.role,
- emailVerified: users.emailVerified,
- });
-
- // Insert user settings for the new user
- 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",
- });
- }
- });
-}
-
-export async function createUser(
- input: z.infer<typeof zSignUpSchema>,
- ctx: Context,
- role?: "user" | "admin",
-) {
- const salt = generatePasswordSalt();
- let user = await createUserRaw(ctx.db, {
- name: input.name,
- email: input.email,
- password: await hashPassword(input.password, salt),
- salt,
- role,
- });
- // Send verification email if required
- if (serverConfig.auth.emailVerificationRequired) {
- const token = await genEmailVerificationToken(ctx.db, input.email);
- try {
- await sendVerificationEmail(input.email, input.name, token);
- } catch (error) {
- console.error("Failed to send verification email:", error);
- // Don't fail user creation if email sending fails
- }
- }
- return user;
-}
+import { User } from "../models/users";
export const usersAppRouter = router({
create: publicProcedure
@@ -215,7 +51,13 @@ export const usersAppRouter = router({
message: errorMessage,
});
}
- return createUser(input, ctx);
+ const user = await User.create(ctx, input);
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ };
}),
list: adminProcedure
.output(
@@ -234,23 +76,9 @@ export const usersAppRouter = router({
}),
)
.query(async ({ ctx }) => {
- 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,
- })
- .from(users);
-
+ const users = await User.getAll(ctx);
return {
- users: dbUsers.map(({ password, ...user }) => ({
- ...user,
- localUser: password !== null,
- })),
+ users: users.map((u) => u.asPublicUser()),
};
}),
changePassword: authedProcedure
@@ -261,26 +89,8 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- invariant(ctx.user.email, "A user always has an email specified");
- let user;
- try {
- user = await validatePassword(
- ctx.user.email,
- input.currentPassword,
- ctx.db,
- );
- } catch {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- invariant(user.id, ctx.user.id);
- const newSalt = generatePasswordSalt();
- await ctx.db
- .update(users)
- .set({
- password: await hashPassword(input.newPassword, newSalt),
- salt: newSalt,
- })
- .where(eq(users.id, ctx.user.id));
+ const user = await User.fromCtx(ctx);
+ await user.changePassword(input.currentPassword, input.newPassword);
}),
delete: adminProcedure
.input(
@@ -289,11 +99,7 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- const res = await ctx.db.delete(users).where(eq(users.id, input.userId));
- if (res.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
- await deleteUserAssets({ userId: input.userId });
+ await User.deleteAsAdmin(ctx, input.userId);
}),
deleteAccount: authedProcedure
.input(
@@ -302,367 +108,32 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- invariant(ctx.user.email, "A user always has an email specified");
-
- // Check if user has a password (local account)
- const user = await ctx.db.query.users.findFirst({
- where: eq(users.id, ctx.user.id),
- });
-
- if (!user) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
-
- // If user has a password, verify it before allowing account deletion
- if (user.password) {
- if (!input.password) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Password is required for local accounts",
- });
- }
-
- try {
- await validatePassword(ctx.user.email, input.password, ctx.db);
- } catch {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "Invalid password",
- });
- }
- }
-
- // Delete the user account
- const res = await ctx.db.delete(users).where(eq(users.id, ctx.user.id));
- if (res.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
-
- // Delete user assets
- await deleteUserAssets({ userId: ctx.user.id });
+ const user = await User.fromCtx(ctx);
+ await user.deleteAccount(input.password);
}),
whoami: authedProcedure
.output(zWhoAmIResponseSchema)
.query(async ({ ctx }) => {
- if (!ctx.user.email) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- const userDb = await ctx.db.query.users.findFirst({
- where: and(eq(users.id, ctx.user.id), eq(users.email, ctx.user.email)),
- });
- if (!userDb) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- return {
- id: ctx.user.id,
- name: ctx.user.name,
- email: ctx.user.email,
- localUser: userDb.password !== null,
- };
+ const user = await User.fromCtx(ctx);
+ return user.asWhoAmI();
}),
stats: authedProcedure
.output(zUserStatsResponseSchema)
.query(async ({ ctx }) => {
- // Get user's timezone
- const userSet = await ctx.db.query.userSettings.findFirst({
- where: eq(userSettings.userId, ctx.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
- ctx.db
- .select({ numBookmarks: count() })
- .from(bookmarks)
- .where(eq(bookmarks.userId, ctx.user.id)),
- ctx.db
- .select({ numFavorites: count() })
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.favourited, true),
- ),
- ),
- ctx.db
- .select({ numArchived: count() })
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.archived, true),
- ),
- ),
- ctx.db
- .select({ numTags: count() })
- .from(bookmarkTags)
- .where(eq(bookmarkTags.userId, ctx.user.id)),
- ctx.db
- .select({ numLists: count() })
- .from(bookmarkLists)
- .where(eq(bookmarkLists.userId, ctx.user.id)),
- ctx.db
- .select({ numHighlights: count() })
- .from(highlights)
- .where(eq(highlights.userId, ctx.user.id)),
-
- // Bookmarks by type
- ctx.db
- .select({
- type: bookmarks.type,
- count: count(),
- })
- .from(bookmarks)
- .where(eq(bookmarks.userId, ctx.user.id))
- .groupBy(bookmarks.type),
-
- // Top domains
- 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, ctx.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
- ctx.db
- .select({
- totalAssetSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`,
- })
- .from(assets)
- .where(eq(assets.userId, ctx.user.id)),
-
- // Assets by type
- ctx.db
- .select({
- type: assets.assetType,
- count: count(),
- totalSize: sql<number>`COALESCE(SUM(${assets.size}), 0)`,
- })
- .from(assets)
- .where(eq(assets.userId, ctx.user.id))
- .groupBy(assets.assetType),
-
- // Activity stats
- ctx.db
- .select({ thisWeek: count() })
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- gte(bookmarks.createdAt, weekAgo),
- ),
- ),
- ctx.db
- .select({ thisMonth: count() })
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- gte(bookmarks.createdAt, monthAgo),
- ),
- ),
- ctx.db
- .select({ thisYear: count() })
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- gte(bookmarks.createdAt, yearAgo),
- ),
- ),
-
- // Get all bookmark timestamps for timezone conversion
- ctx.db
- .select({
- createdAt: bookmarks.createdAt,
- })
- .from(bookmarks)
- .where(eq(bookmarks.userId, ctx.user.id)),
-
- // Tag usage
- ctx.db
- .select({
- name: bookmarkTags.name,
- count: count(),
- })
- .from(bookmarkTags)
- .innerJoin(
- tagsOnBookmarks,
- eq(tagsOnBookmarks.tagId, bookmarkTags.id),
- )
- .where(eq(bookmarkTags.userId, ctx.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) {
- // Convert timestamp to user timezone
- 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,
- };
+ const user = await User.fromCtx(ctx);
+ return await user.getStats();
}),
settings: authedProcedure
.output(zUserSettingsSchema)
.query(async ({ ctx }) => {
- const settings = await ctx.db.query.userSettings.findFirst({
- where: eq(userSettings.userId, ctx.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",
- };
+ const user = await User.fromCtx(ctx);
+ return await user.getSettings();
}),
updateSettings: authedProcedure
.input(zUpdateUserSettingsSchema)
.mutation(async ({ input, ctx }) => {
- if (Object.keys(input).length === 0) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "No settings provided",
- });
- }
- await ctx.db
- .update(userSettings)
- .set({
- bookmarkClickAction: input.bookmarkClickAction,
- archiveDisplayBehaviour: input.archiveDisplayBehaviour,
- timezone: input.timezone,
- })
- .where(eq(userSettings.userId, ctx.user.id));
+ const user = await User.fromCtx(ctx);
+ await user.updateSettings(input);
}),
verifyEmail: publicProcedure
.use(
@@ -671,7 +142,7 @@ export const usersAppRouter = router({
windowMs: 5 * 60 * 1000,
maxRequests: 10,
}),
- ) // 10 requests per 5 minutes
+ )
.input(
z.object({
email: z.string().email(),
@@ -679,27 +150,7 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- const isValid = await verifyEmailToken(ctx.db, input.email, input.token);
- if (!isValid) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Invalid or expired verification token",
- });
- }
-
- // Update user's emailVerified status
- const result = await ctx.db
- .update(users)
- .set({ emailVerified: new Date() })
- .where(eq(users.email, input.email));
-
- if (result.changes === 0) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "User not found",
- });
- }
-
+ await User.verifyEmail(ctx, input.email, input.token);
return { success: true };
}),
resendVerificationEmail: publicProcedure
@@ -709,52 +160,15 @@ export const usersAppRouter = router({
windowMs: 5 * 60 * 1000,
maxRequests: 3,
}),
- ) // 3 requests per 5 minutes
+ )
.input(
z.object({
email: z.string().email(),
}),
)
.mutation(async ({ input, ctx }) => {
- 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, input.email),
- });
-
- if (!user) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "User not found",
- });
- }
-
- if (user.emailVerified) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Email is already verified",
- });
- }
-
- const token = await genEmailVerificationToken(ctx.db, input.email);
- try {
- await sendVerificationEmail(input.email, user.name, token);
- return { success: true };
- } catch (error) {
- console.error("Failed to send verification email:", error);
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "Failed to send verification email",
- });
- }
+ await User.resendVerificationEmail(ctx, input.email);
+ return { success: true };
}),
forgotPassword: publicProcedure
.use(
@@ -770,47 +184,8 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- 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, input.email),
- });
-
- if (!user) {
- // Don't reveal if user exists or not for security
- return { success: true };
- }
-
- // Only send reset email for users with passwords (local accounts)
- if (!user.password) {
- return { success: true };
- }
-
- try {
- const token = randomBytes(32).toString("hex");
- const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
-
- // Store password reset token
- await ctx.db.insert(passwordResetTokens).values({
- userId: user.id,
- token,
- expires,
- });
-
- await sendPasswordResetEmail(input.email, user.name, token);
- return { success: true };
- } 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",
- });
- }
+ await User.forgotPassword(ctx, input.email);
+ return { success: true };
}),
resetPassword: publicProcedure
.use(
@@ -822,59 +197,7 @@ export const usersAppRouter = router({
)
.input(zResetPasswordSchema)
.mutation(async ({ input, ctx }) => {
- const token = input.token;
- const resetToken = await ctx.db.query.passwordResetTokens.findFirst({
- where: eq(passwordResetTokens.token, 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()) {
- // Clean up expired token
- await ctx.db
- .delete(passwordResetTokens)
- .where(eq(passwordResetTokens.token, 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",
- });
- }
-
- // Generate new password hash
- const newSalt = generatePasswordSalt();
- const hashedPassword = await hashPassword(input.newPassword, newSalt);
-
- // Update user password
- 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, token));
+ await User.resetPassword(ctx, input);
return { success: true };
}),
});