mirror of
https://github.com/documenso/documenso.git
synced 2026-07-23 16:33:57 +10:00
feat: avatar images
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "^0.33.1",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
@@ -58,4 +59,4 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetAvatarImageOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
|
||||
const avatarImage = await prisma.avatarImage.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!avatarImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(avatarImage.bytes, 'base64');
|
||||
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
|
||||
};
|
||||
};
|
||||
@@ -25,6 +25,7 @@ type PublicDirectLinkTemplate = Template & {
|
||||
type BaseResponse = {
|
||||
url: string;
|
||||
name: string;
|
||||
avatarImageId?: string | null;
|
||||
badge?: {
|
||||
type: 'Premium' | 'EarlySupporter';
|
||||
since: Date;
|
||||
@@ -149,6 +150,7 @@ export const getPublicProfileByUrl = async ({
|
||||
badge,
|
||||
profile: user.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: user.avatarImageId,
|
||||
name: user.name || '',
|
||||
templates: user.Template.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
@@ -166,6 +168,7 @@ export const getPublicProfileByUrl = async ({
|
||||
},
|
||||
profile: team.profile,
|
||||
url: profileUrl,
|
||||
avatarImageId: team.avatarImageId,
|
||||
name: team.name || '',
|
||||
templates: team.templates.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export type SetAvatarImageOptions = {
|
||||
userId: number;
|
||||
teamId?: number | null;
|
||||
bytes?: string | null;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const setAvatarImage = async ({
|
||||
userId,
|
||||
teamId,
|
||||
bytes,
|
||||
requestMetadata,
|
||||
}: SetAvatarImageOptions) => {
|
||||
let oldAvatarImageId: string | null = null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
avatarImage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
oldAvatarImageId = user.avatarImageId;
|
||||
|
||||
if (teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
}
|
||||
|
||||
oldAvatarImageId = team.avatarImageId;
|
||||
}
|
||||
|
||||
if (oldAvatarImageId) {
|
||||
await prisma.avatarImage.delete({
|
||||
where: {
|
||||
id: oldAvatarImageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let newAvatarImageId: string | null = null;
|
||||
|
||||
if (bytes) {
|
||||
const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
|
||||
const avatarImage = await prisma.avatarImage.create({
|
||||
data: {
|
||||
bytes: optimisedBytes.toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
newAvatarImageId = avatarImage.id;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
avatarImageId: newAvatarImageId,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Audit Logs
|
||||
}
|
||||
|
||||
return newAvatarImageId;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "avatarImageId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatarImageId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AvatarImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bytes" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AvatarImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_avatarImageId_fkey" FOREIGN KEY ("avatarImageId") REFERENCES "AvatarImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -24,19 +24,21 @@ enum Role {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
avatarImageId String?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
@@ -58,6 +60,7 @@ model User {
|
||||
Webhooks Webhook[]
|
||||
siteSettings SiteSettings[]
|
||||
passkeys Passkey[]
|
||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -474,17 +477,20 @@ enum TeamMemberInviteStatus {
|
||||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String @unique
|
||||
createdAt DateTime @default(now())
|
||||
customerId String? @unique
|
||||
ownerUserId Int
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String @unique
|
||||
createdAt DateTime @default(now())
|
||||
avatarImageId String?
|
||||
customerId String? @unique
|
||||
ownerUserId Int
|
||||
|
||||
members TeamMember[]
|
||||
invites TeamMemberInvite[]
|
||||
teamEmail TeamEmail?
|
||||
emailVerification TeamEmailVerification?
|
||||
transferVerification TeamTransferVerification?
|
||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||
|
||||
profile TeamProfile?
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
@@ -676,3 +682,11 @@ model BackgroundJobTask {
|
||||
jobId String
|
||||
backgroundJob BackgroundJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AvatarImage {
|
||||
id String @id @default(cuid())
|
||||
bytes String
|
||||
|
||||
team Team[]
|
||||
user User[]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ZForgotPasswordFormSchema,
|
||||
ZResetPasswordFormSchema,
|
||||
ZRetrieveUserByIdQuerySchema,
|
||||
ZSetProfileImageMutationSchema,
|
||||
ZUpdatePasswordMutationSchema,
|
||||
ZUpdateProfileMutationSchema,
|
||||
ZUpdatePublicProfileMutationSchema,
|
||||
@@ -246,4 +248,32 @@ export const profileRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
setProfileImage: authenticatedProcedure
|
||||
.input(ZSetProfileImageMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { bytes, teamId } = input;
|
||||
|
||||
return await setAvatarImage({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
bytes,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
let message = 'We were unable to update your profile image. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,15 +9,21 @@ export const ZFindUserSecurityAuditLogsSchema = z.object({
|
||||
perPage: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
|
||||
|
||||
export const ZRetrieveUserByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
|
||||
export const ZUpdateProfileMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
|
||||
export const ZUpdatePublicProfileMutationSchema = z.object({
|
||||
bio: z
|
||||
.string()
|
||||
@@ -37,28 +43,37 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TUpdatePublicProfileMutationSchema = z.infer<typeof ZUpdatePublicProfileMutationSchema>;
|
||||
|
||||
export const ZUpdatePasswordMutationSchema = z.object({
|
||||
currentPassword: ZCurrentPasswordSchema,
|
||||
password: ZPasswordSchema,
|
||||
});
|
||||
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
export const ZResetPasswordFormSchema = z.object({
|
||||
password: ZPasswordSchema,
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
|
||||
export const ZConfirmEmailMutationSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
export type TConfirmEmailMutationSchema = z.infer<typeof ZConfirmEmailMutationSchema>;
|
||||
|
||||
export const ZSetProfileImageMutationSchema = z.object({
|
||||
bytes: z.string().nullish(),
|
||||
teamId: z.number().min(1).nullish(),
|
||||
});
|
||||
|
||||
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
|
||||
|
||||
@@ -50,6 +50,7 @@ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
type AvatarWithTextProps = {
|
||||
avatarClass?: string;
|
||||
avatarSrc?: string | null;
|
||||
avatarFallback: string;
|
||||
className?: string;
|
||||
primaryText: React.ReactNode;
|
||||
@@ -61,6 +62,7 @@ type AvatarWithTextProps = {
|
||||
|
||||
const AvatarWithText = ({
|
||||
avatarClass,
|
||||
avatarSrc,
|
||||
avatarFallback,
|
||||
className,
|
||||
primaryText,
|
||||
@@ -72,6 +74,7 @@ const AvatarWithText = ({
|
||||
<Avatar
|
||||
className={cn('dark:border-border h-10 w-10 border-2 border-solid border-white', avatarClass)}
|
||||
>
|
||||
{avatarSrc && <AvatarImage src={avatarSrc} />}
|
||||
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user