mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 10:16:32 +02:00
266 lines
6.6 KiB
TypeScript
266 lines
6.6 KiB
TypeScript
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
import { prisma } from "@rallly/database";
|
|
import { absoluteUrl } from "@rallly/utils/absolute-url";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { waitUntil } from "@vercel/functions";
|
|
import { z } from "zod";
|
|
|
|
import { env } from "@/env";
|
|
import { getS3Client } from "@/utils/s3";
|
|
import { createToken } from "@/utils/session";
|
|
import { getSubscriptionStatus } from "@/utils/subscription";
|
|
|
|
import {
|
|
createRateLimitMiddleware,
|
|
privateProcedure,
|
|
publicProcedure,
|
|
router,
|
|
} from "../trpc";
|
|
|
|
const mimeToExtension = {
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
} as const;
|
|
|
|
export const user = router({
|
|
getByEmail: publicProcedure
|
|
.input(z.object({ email: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return await prisma.user.findUnique({
|
|
where: {
|
|
email: input.email,
|
|
},
|
|
select: {
|
|
name: true,
|
|
email: true,
|
|
image: true,
|
|
},
|
|
});
|
|
}),
|
|
delete: privateProcedure
|
|
.use(createRateLimitMiddleware(5, "1 h"))
|
|
.mutation(async ({ ctx }) => {
|
|
if (ctx.user.isGuest) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Guest users cannot be deleted",
|
|
});
|
|
}
|
|
|
|
await prisma.user.delete({
|
|
where: {
|
|
id: ctx.user.id,
|
|
},
|
|
});
|
|
}),
|
|
subscription: publicProcedure.query(
|
|
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
|
|
if (!ctx.user || ctx.user.isGuest) {
|
|
// guest user can't have an active subscription
|
|
return {
|
|
active: false,
|
|
};
|
|
}
|
|
|
|
return await getSubscriptionStatus(ctx.user.id);
|
|
},
|
|
),
|
|
changeName: privateProcedure
|
|
.use(createRateLimitMiddleware(20, "1 h"))
|
|
.input(
|
|
z.object({
|
|
name: z.string().min(1).max(100),
|
|
}),
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
await prisma.user.update({
|
|
where: {
|
|
id: ctx.user.id,
|
|
},
|
|
data: {
|
|
name: input.name,
|
|
},
|
|
});
|
|
}),
|
|
updatePreferences: privateProcedure
|
|
.use(createRateLimitMiddleware(30, "1 h"))
|
|
.input(
|
|
z.object({
|
|
locale: z.string().optional(),
|
|
timeZone: z.string().optional(),
|
|
weekStart: z.number().min(0).max(6).optional(),
|
|
timeFormat: z.enum(["hours12", "hours24"]).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (ctx.user.isGuest) {
|
|
throw new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: "Guest users cannot update preferences",
|
|
});
|
|
}
|
|
|
|
await prisma.user.update({
|
|
where: {
|
|
id: ctx.user.id,
|
|
},
|
|
data: input,
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
requestEmailChange: privateProcedure
|
|
.use(createRateLimitMiddleware(10, "1 h"))
|
|
.input(z.object({ email: z.string().email() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const currentUser = await prisma.user.findUnique({
|
|
where: { id: ctx.user.id },
|
|
select: { email: true },
|
|
});
|
|
|
|
if (!currentUser) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// check if the email is already in use
|
|
const existingUser = await prisma.user.count({
|
|
where: { email: input.email },
|
|
});
|
|
|
|
if (existingUser) {
|
|
return {
|
|
success: false as const,
|
|
reason: "emailAlreadyInUse" as const,
|
|
};
|
|
}
|
|
|
|
// create a verification token
|
|
const token = await createToken(
|
|
{
|
|
fromEmail: currentUser.email,
|
|
toEmail: input.email,
|
|
},
|
|
{
|
|
ttl: 60 * 10,
|
|
},
|
|
);
|
|
|
|
ctx.user.getEmailClient().sendTemplate("ChangeEmailRequest", {
|
|
to: input.email,
|
|
props: {
|
|
verificationUrl: absoluteUrl(
|
|
`/api/user/verify-email-change?token=${token}`,
|
|
),
|
|
fromEmail: currentUser.email,
|
|
toEmail: input.email,
|
|
},
|
|
});
|
|
|
|
return { success: true as const };
|
|
}),
|
|
getAvatarUploadUrl: privateProcedure
|
|
.use(createRateLimitMiddleware(20, "1 h"))
|
|
.input(
|
|
z.object({
|
|
fileType: z.enum(["image/jpeg", "image/png"]),
|
|
fileSize: z.number(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const s3Client = getS3Client();
|
|
|
|
if (!s3Client) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "S3 storage has not been configured",
|
|
});
|
|
}
|
|
|
|
const userId = ctx.user.id;
|
|
const key = `avatars/${userId}-${Date.now()}.${mimeToExtension[input.fileType]}`;
|
|
|
|
if (input.fileSize > 2 * 1024 * 1024) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "File size too large",
|
|
});
|
|
}
|
|
|
|
const command = new PutObjectCommand({
|
|
Bucket: env.S3_BUCKET_NAME,
|
|
Key: key,
|
|
ContentType: input.fileType,
|
|
ContentLength: input.fileSize,
|
|
});
|
|
|
|
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
|
|
|
return {
|
|
success: true,
|
|
url,
|
|
fields: {
|
|
key,
|
|
},
|
|
} as const;
|
|
}),
|
|
updateAvatar: privateProcedure
|
|
.input(z.object({ imageKey: z.string().max(255) }))
|
|
.use(createRateLimitMiddleware(10, "1 h"))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user.id;
|
|
const oldImageKey = ctx.user.image;
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { image: input.imageKey },
|
|
});
|
|
|
|
const s3Client = getS3Client();
|
|
|
|
if (oldImageKey && s3Client) {
|
|
waitUntil(
|
|
s3Client?.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: env.S3_BUCKET_NAME,
|
|
Key: oldImageKey,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
removeAvatar: privateProcedure.mutation(async ({ ctx }) => {
|
|
const userId = ctx.user.id;
|
|
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { image: null },
|
|
});
|
|
|
|
// Delete the avatar from storage if it's an internal avatar
|
|
const isInternalAvatar =
|
|
ctx.user.image && !ctx.user.image.startsWith("https://");
|
|
|
|
if (isInternalAvatar) {
|
|
const s3Client = getS3Client();
|
|
|
|
if (s3Client) {
|
|
waitUntil(
|
|
s3Client.send(
|
|
new DeleteObjectCommand({
|
|
Bucket: env.S3_BUCKET_NAME,
|
|
Key: ctx.user.image,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
}),
|
|
});
|