rcgit

/ karakeep

plain blame

import { randomBytes } from "crypto";
import { TRPCError } from "@trpc/server";
import { and, eq, gt } from "drizzle-orm";
import { z } from "zod";

import { invites, users } from "@karakeep/db/schema";

import { generatePasswordSalt, hashPassword } from "../auth";
import { sendInviteEmail } from "../email";
import {
  adminProcedure,
  createRateLimitMiddleware,
  publicProcedure,
  router,
} from "../index";
import { createUserRaw } from "./users";

export const invitesAppRouter = router({
  create: adminProcedure
    .input(
      z.object({
        email: z.string().email(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const existingUser = await ctx.db.query.users.findFirst({
        where: eq(users.email, input.email),
      });

      if (existingUser) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "User with this email already exists",
        });
      }

      const existingInvite = await ctx.db.query.invites.findFirst({
        where: and(
          eq(invites.email, input.email),
          gt(invites.expiresAt, new Date()),
        ),
      });

      if (existingInvite) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "An active invite for this email already exists",
        });
      }

      const token = randomBytes(32).toString("hex");
      const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

      const [invite] = await ctx.db
        .insert(invites)
        .values({
          email: input.email,
          token,
          expiresAt,
          invitedBy: ctx.user.id,
        })
        .returning();

      // Send invite email
      try {
        await sendInviteEmail(
          input.email,
          token,
          ctx.user.name || "A Karakeep admin",
        );
      } catch (error) {
        console.error("Failed to send invite email:", error);
        // Don't fail the invite creation if email sending fails
      }

      return {
        id: invite.id,
        email: invite.email,
        expiresAt: invite.expiresAt,
      };
    }),

  list: adminProcedure
    .output(
      z.object({
        invites: z.array(
          z.object({
            id: z.string(),
            email: z.string(),
            createdAt: z.date(),
            expiresAt: z.date(),
            invitedBy: z.object({
              id: z.string(),
              name: z.string(),
              email: z.string(),
            }),
          }),
        ),
      }),
    )
    .query(async ({ ctx }) => {
      const dbInvites = await ctx.db.query.invites.findMany({
        with: {
          invitedBy: {
            columns: {
              id: true,
              name: true,
              email: true,
            },
          },
        },
        orderBy: (invites, { desc }) => [desc(invites.createdAt)],
      });

      return {
        invites: dbInvites,
      };
    }),

  get: publicProcedure
    .use(
      createRateLimitMiddleware({
        name: "invites.get",
        windowMs: 60 * 1000,
        maxRequests: 10,
      }),
    )
    .input(
      z.object({
        token: z.string(),
      }),
    )
    .output(
      z.object({
        email: z.string(),
        expired: z.boolean(),
      }),
    )
    .query(async ({ input, ctx }) => {
      const invite = await ctx.db.query.invites.findFirst({
        where: eq(invites.token, input.token),
      });

      if (!invite) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Invite not found or has been used",
        });
      }

      const now = new Date();
      const expired = invite.expiresAt < now;

      if (expired) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "Invite has expired",
        });
      }

      return {
        email: invite.email,
        expired: false,
      };
    }),

  accept: publicProcedure
    .use(
      createRateLimitMiddleware({
        name: "invites.accept",
        windowMs: 60 * 1000,
        maxRequests: 10,
      }),
    )
    .input(
      z.object({
        token: z.string(),
        name: z.string().min(1),
        password: z.string().min(8),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const invite = await ctx.db.query.invites.findFirst({
        where: eq(invites.token, input.token),
      });

      if (!invite) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Invite not found or has been used",
        });
      }

      const now = new Date();
      if (invite.expiresAt < now) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "This invite has expired",
        });
      }

      const existingUser = await ctx.db.query.users.findFirst({
        where: eq(users.email, invite.email),
      });

      if (existingUser) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "User with this email already exists",
        });
      }

      const salt = generatePasswordSalt();
      const user = await createUserRaw(ctx.db, {
        name: input.name,
        email: invite.email,
        password: await hashPassword(input.password, salt),
        salt,
        role: "user",
        emailVerified: new Date(), // Auto-verify invited users
      });

      // Delete the invite after successful user creation
      await ctx.db.delete(invites).where(eq(invites.id, invite.id));

      return {
        id: user.id,
        name: user.name,
        email: user.email,
      };
    }),

  revoke: adminProcedure
    .input(
      z.object({
        inviteId: z.string(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const invite = await ctx.db.query.invites.findFirst({
        where: eq(invites.id, input.inviteId),
      });

      if (!invite) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Invite not found",
        });
      }

      // Delete the invite to revoke it
      await ctx.db.delete(invites).where(eq(invites.id, input.inviteId));

      return { success: true };
    }),

  resend: adminProcedure
    .input(
      z.object({
        inviteId: z.string(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const invite = await ctx.db.query.invites.findFirst({
        where: eq(invites.id, input.inviteId),
      });

      if (!invite) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: "Invite not found",
        });
      }

      const newToken = randomBytes(32).toString("hex");
      const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

      await ctx.db
        .update(invites)
        .set({
          token: newToken,
          expiresAt: newExpiresAt,
        })
        .where(eq(invites.id, input.inviteId));

      // Send invite email with new token
      try {
        await sendInviteEmail(
          invite.email,
          newToken,
          ctx.user.name || "A Karakeep admin",
        );
      } catch (error) {
        console.error("Failed to send invite email:", error);
        // Don't fail the resend if email sending fails
      }

      return {
        id: invite.id,
        email: invite.email,
        expiresAt: newExpiresAt,
      };
    }),
});