Skip to content

Commit fd3b22b

Browse files
authored
Feat/issue55 profile photo (#394)
## Description This PR adds a new edit profile photo feature. Prior to this, RecNet displays a user's Google account profile photo and does not allow changes. This feature allows any RecNet user to upload a profile photo, which is stored in an AWS S3 bucket and make updates to the user profile information in the database. ## Related Issue * See [PR#392](#392) for previous comments and requested changes. Reopening PR for the same issue to merge to `dev` before `master`. ## Frontend Changes * Modified `/apps/recnet/src/components/setting/profile/ProfileEditForm.tsx` adding new form element and function: * Added profile photo `<input>` element with a photo preview image to accept and display the selected photo. * Added `handleUploadS3` function to get a secure uploadUrl from the backend, put objects to the S3 bucket, and update the form data. * Modified the `onSubmit` function to call the `handleUploadS3` function when a user saves the form. * Updated the `ProfileEditSchema`, `useForm` hook to include the photoUrl field. * Added a `generateUploadUrlMutation` hook to send a request to the backend to get a secure S3 upload URL. * Added new state hooks `[isUploading, setIsUploading]`, `[selectedFile, setSelectedFile]`, `[photoPreviewUrl, setPhotoPreviewUrl]` , `[fileError, setFileError]` to manage the selected photo and upload status. * Created a new router `generateS3UploadUrl` in the `/apps/recnet/src/server/routers/user.ts` * Modified the router `updateUser` in `/apps/recnet/src/server/routers/user.ts` to fetch the original photoUrl and delete the corresponding S3 file if a new profile photo is uploaded. ## Backend Changes * Added a new folder `photo-storage` under `/apps/recnet-api/src/modules` * Created a `photo-storage.controler.ts` to handle requests to the `photo-storage` endpoint. * Created a `photo-storage.service.ts`. Included a `generateS3UploadUrl` method and a `deleteS3Object` method. * Created a `photo-storage.module.ts` to package and export the s3 class. * Modified `common.config.ts` under `/apps/recnet-api/src/config` to register AWS S3 configuration. * Updated `env.schema.ts` under `/apps/recnet-api/src/config` to include the s3 environment variable data types. * Updated `app.module.ts` under `/apps/recnet-api/src` to include and import the `PhotoStorageModule`. ## Other Changes * Added `photo-storage.ts` under `/libs/recnet-api-model/src/lib/api` to document the new APIs related to `photo-storage`. * Updated `package.json` to include `aws-sdk/client-s3` and `aws-sdk/s3-request-presigner` . * Install aws-sdk: `@aws-sdk/client-s3 @aws-sdk/s3-request-presigner`. Make sure to use aws-sdk v3. ## How To Test 1. Log in to your account. 2. Navigate to the Profile page. 3. Click on the "Settings" button and open the first tab "Edit profile". 4. Click on "Choose file" to choose a file and attach to the form. 5. Click on the "Save" button and wait for the upload to complete. 6. Check your profile photo and it should be updated to the new photo you uploaded. ## Screenshots (if appropriate): <img width="863" alt="1_profile-photo-before" src="https://github.com/user-attachments/assets/608bfba6-883f-4fbd-a50f-329434d920f3" /> <img width="783" alt="2_edit-photo" src="https://github.com/user-attachments/assets/38ed449d-d572-4259-b7f6-fe42a4a44664" /> <img width="799" alt="3_photo-attached" src="https://github.com/user-attachments/assets/94b94372-5dfa-4c7d-9dba-5586850799f0" /> <img width="739" alt="4_uploading" src="https://github.com/user-attachments/assets/8c3bc13c-e06b-40d9-b254-5648e78bfd8f" /> <img width="851" alt="5_photo-updated" src="https://github.com/user-attachments/assets/a8267cfa-dd82-45ba-a6d6-11908eed4dcd" /> ## TODO - Add AWS S3 configurations in the `.env` file under `/apps/recnet-api` for the dev and prod environment for deployment
2 parents 6da9454 + fde0153 commit fd3b22b

File tree

15 files changed

+1652
-141
lines changed

15 files changed

+1652
-141
lines changed

apps/recnet-api/.env.sample

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ export SMTP_PASS="ask for password"
2323
export SLACK_TOKEN="ask for token" # to be deprecated
2424
export SLACK_CLIENT_ID="ask for client id"
2525
export SLACK_CLIENT_SECRET="ask for client secret"
26-
export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key"
26+
export SLACK_TOKEN_ENCRYPTION_KEY="ask for token encryption key"
27+
28+
# AWS S3
29+
export AWS_BUCKET_NAME="ask for AWS bucket name"
30+
export AWS_ACCESS_KEY_ID="ask for AWS access key id"
31+
export AWS_SECRET_ACCESS_KEY="ask for AWS secret access key"
32+
export AWS_BUCKET_REGION="ask for AWS bucket region"

apps/recnet-api/.env.test

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@ SMTP_USER=test_user
55
SMTP_PASS=test_password
66
SLACK_CLIENT_ID=test_client_id
77
SLACK_CLIENT_SECRET=test_client_secret
8-
SLACK_TOKEN_ENCRYPTION_KEY=test_token_encryption_key
8+
SLACK_TOKEN_ENCRYPTION_KEY=test_token_encryption_key
9+
10+
# AWS S3
11+
export AWS_BUCKET_NAME=test_aws_bucket_name
12+
export AWS_ACCESS_KEY_ID=test_aws_access_key_id
13+
export AWS_SECRET_ACCESS_KEY=test_aws_secret_access_key
14+
export AWS_BUCKET_REGION=test_aws_bucket_region

apps/recnet-api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ArticleModule } from "./modules/article/article.module";
99
import { EmailModule } from "./modules/email/email.module";
1010
import { HealthModule } from "./modules/health/health.module";
1111
import { InviteCodeModule } from "./modules/invite-code/invite-code.module";
12+
import { PhotoStorageModule } from "./modules/photo-storage/photo-storage.module";
1213
import { RecModule } from "./modules/rec/rec.module";
1314
import { StatModule } from "./modules/stat/stat.module";
1415
import { SubscriptionModule } from "./modules/subscription/subscription.module";
@@ -32,6 +33,7 @@ import { LoggerMiddleware } from "./utils/middlewares/logger.middleware";
3233
EmailModule,
3334
AnnouncementModule,
3435
SubscriptionModule,
36+
PhotoStorageModule,
3537
],
3638
controllers: [],
3739
providers: [],

apps/recnet-api/src/config/common.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,10 @@ export const SlackConfig = registerAs("slack", () => ({
3838
clientSecret: parsedEnv.SLACK_CLIENT_SECRET,
3939
tokenEncryptionKey: parsedEnv.SLACK_TOKEN_ENCRYPTION_KEY,
4040
}));
41+
42+
export const S3Config = registerAs("s3", () => ({
43+
bucketName: parsedEnv.AWS_BUCKET_NAME,
44+
accessKeyId: parsedEnv.AWS_ACCESS_KEY_ID,
45+
secretAccessKey: parsedEnv.AWS_SECRET_ACCESS_KEY,
46+
s3Region: parsedEnv.AWS_BUCKET_REGION,
47+
}));

apps/recnet-api/src/config/env.schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export const EnvSchema = z.object({
3030
SLACK_TOKEN_ENCRYPTION_KEY: z
3131
.string()
3232
.transform((val) => Buffer.from(val, "base64")),
33+
// AWS S3 config
34+
AWS_BUCKET_NAME: z.string(),
35+
AWS_ACCESS_KEY_ID: z.string(),
36+
AWS_SECRET_ACCESS_KEY: z.string(),
37+
AWS_BUCKET_REGION: z.string(),
3338
});
3439

3540
export const parseEnv = (env: Record<string, string | undefined>) => {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Delete, Post, Query } from "@nestjs/common";
2+
import { ApiOperation } from "@nestjs/swagger";
3+
4+
import { PhotoStorageService } from "./photo-storage.service";
5+
6+
@Controller("photo-storage")
7+
export class PhotoStorageController {
8+
constructor(private readonly photoStorageService: PhotoStorageService) {}
9+
@ApiOperation({
10+
summary: "Generate S3 Upload URL",
11+
description:
12+
"Generate a secure signed Url to upload profile photo to S3 bucket",
13+
})
14+
@Post("upload-url")
15+
generateUploadUrl(): Promise<{ url: string }> {
16+
return this.photoStorageService.generateS3UploadUrl();
17+
}
18+
19+
@ApiOperation({
20+
summary: "Delete S3 Object",
21+
description: "Delete S3 Object (profile photo)",
22+
})
23+
@Delete("photo")
24+
async deleteS3Object(@Query("fileUrl") fileUrl: string): Promise<void> {
25+
return this.photoStorageService.deleteS3Object(fileUrl);
26+
}
27+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from "@nestjs/common";
2+
3+
import { PhotoStorageController } from "./photo-storage.controller";
4+
import { PhotoStorageService } from "./photo-storage.service";
5+
6+
@Module({
7+
controllers: [PhotoStorageController],
8+
providers: [PhotoStorageService],
9+
})
10+
export class PhotoStorageModule {}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { S3Client } from "@aws-sdk/client-s3";
2+
import { PutObjectCommand } from "@aws-sdk/client-s3";
3+
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
4+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5+
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
6+
import { ConfigType } from "@nestjs/config";
7+
import { v4 as uuidv4 } from "uuid";
8+
9+
import { S3Config } from "@recnet-api/config/common.config";
10+
import { RecnetError } from "@recnet-api/utils/error/recnet.error";
11+
import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const";
12+
13+
@Injectable()
14+
export class PhotoStorageService {
15+
private readonly s3: S3Client;
16+
17+
constructor(
18+
@Inject(S3Config.KEY)
19+
private readonly s3Config: ConfigType<typeof S3Config>
20+
) {
21+
this.s3 = new S3Client({
22+
region: this.s3Config.s3Region,
23+
credentials: {
24+
accessKeyId: this.s3Config.accessKeyId,
25+
secretAccessKey: this.s3Config.secretAccessKey,
26+
},
27+
});
28+
}
29+
30+
async generateS3UploadUrl(): Promise<{ url: string }> {
31+
// build time stamp as part of the image name, format: YYYY-MM-DD-HH-MM-SS
32+
// e.g. the current date and time is February 21, 2025, 10:30:45 AM,
33+
// the timestamp would be: 2025-02-21-10-30-45
34+
const timestamp = new Date()
35+
.toLocaleString("en-US", {
36+
year: "numeric",
37+
month: "2-digit",
38+
day: "2-digit",
39+
hour: "2-digit",
40+
minute: "2-digit",
41+
second: "2-digit",
42+
hour12: false,
43+
})
44+
.replace(/[/,: ]/g, "-");
45+
46+
const imageName = `${timestamp}-${uuidv4()}`;
47+
48+
const command = new PutObjectCommand({
49+
Bucket: this.s3Config.bucketName,
50+
Key: imageName,
51+
});
52+
53+
try {
54+
const uploadURL = await getSignedUrl(this.s3, command, { expiresIn: 60 });
55+
return { url: uploadURL };
56+
} catch (error: unknown) {
57+
throw new RecnetError(
58+
ErrorCode.AWS_S3_GET_SIGNED_URL_ERROR,
59+
HttpStatus.INTERNAL_SERVER_ERROR
60+
);
61+
}
62+
}
63+
64+
async deleteS3Object(fileUrl: string): Promise<void> {
65+
// Extract the key (filename) from the URL
66+
const urlParts = fileUrl.split("/");
67+
const key = urlParts[urlParts.length - 1];
68+
69+
const command = new DeleteObjectCommand({
70+
Bucket: this.s3Config.bucketName,
71+
Key: key,
72+
});
73+
74+
try {
75+
await this.s3.send(command);
76+
} catch (error: unknown) {
77+
throw new RecnetError(
78+
ErrorCode.AWS_S3_DELETE_OBJECT_ERROR,
79+
HttpStatus.INTERNAL_SERVER_ERROR
80+
);
81+
}
82+
}
83+
}

apps/recnet-api/src/utils/error/recnet.error.const.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const ErrorCode = {
2323
EMAIL_SEND_ERROR: 3000,
2424
FETCH_DIGITAL_LIBRARY_ERROR: 3001,
2525
SLACK_ERROR: 3002,
26+
AWS_S3_GET_SIGNED_URL_ERROR: 3003,
27+
AWS_S3_DELETE_OBJECT_ERROR: 3004,
2628
};
2729

2830
export const errorMessages = {
@@ -49,4 +51,6 @@ export const errorMessages = {
4951
[ErrorCode.FETCH_DIGITAL_LIBRARY_ERROR]: "Fetch digital library error",
5052
[ErrorCode.SLACK_ERROR]: "Slack error",
5153
[ErrorCode.SLACK_ALREADY_INSTALLED]: "Slack already installed",
54+
[ErrorCode.AWS_S3_GET_SIGNED_URL_ERROR]: "Failed to get AWS S3 signed URL",
55+
[ErrorCode.AWS_S3_DELETE_OBJECT_ERROR]: "AWS S3 delete object error",
5256
};

apps/recnet/src/components/setting/profile/ProfileEditForm.tsx

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
TextArea,
1111
} from "@radix-ui/themes";
1212
import { TRPCClientError } from "@trpc/client";
13+
import Image from "next/image";
1314
import { useRouter, usePathname } from "next/navigation";
15+
import React from "react";
1416
import { useForm, useFormState } from "react-hook-form";
1517
import { toast } from "sonner";
1618
import * as z from "zod";
@@ -61,11 +63,14 @@ const ProfileEditSchema = z.object({
6163
.max(200, "Bio must contain at most 200 character(s)")
6264
.nullable(),
6365
url: z.string().url().nullable(),
66+
photoUrl: z.string().url(),
6467
googleScholarLink: z.string().url().nullable(),
6568
semanticScholarLink: z.string().url().nullable(),
6669
openReviewUserName: z.string().nullable(),
6770
});
6871

72+
const MAX_FILE_SIZE = 3 * 1024 * 1024; // 3MB in bytes
73+
6974
export function ProfileEditForm() {
7075
const utils = trpc.useUtils();
7176
const router = useRouter();
@@ -83,6 +88,7 @@ export function ProfileEditForm() {
8388
affiliation: user?.affiliation ?? null,
8489
bio: user?.bio ?? null,
8590
url: user?.url ?? null,
91+
photoUrl: user?.photoUrl ?? null,
8692
googleScholarLink: user?.googleScholarLink ?? null,
8793
semanticScholarLink: user?.semanticScholarLink ?? null,
8894
openReviewUserName: user?.openReviewUserName ?? null,
@@ -92,6 +98,44 @@ export function ProfileEditForm() {
9298
const { isDirty } = useFormState({ control: control });
9399

94100
const updateProfileMutation = trpc.updateUser.useMutation();
101+
const generateUploadUrlMutation = trpc.generateUploadUrl.useMutation();
102+
const [isUploading, setIsUploading] = React.useState(false);
103+
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
104+
const [photoPreviewUrl, setPhotoPreviewUrl] = React.useState<string | null>(
105+
null
106+
);
107+
const [fileError, setFileError] = React.useState<string | null>(null);
108+
109+
const handleUploadS3 = async (formData: any) => {
110+
if (!selectedFile) return;
111+
try {
112+
setIsUploading(true);
113+
const uploadUrl = await generateUploadUrlMutation.mutateAsync();
114+
if (!uploadUrl?.url) {
115+
throw new Error("Error getting S3 upload URL");
116+
}
117+
const response = await fetch(uploadUrl.url, {
118+
method: "PUT",
119+
body: selectedFile,
120+
headers: {
121+
"Content-Type": selectedFile.type,
122+
},
123+
});
124+
if (!response.ok) {
125+
throw new Error("Upload failed");
126+
}
127+
// The URL where the file will be accessible
128+
const fileUrl = uploadUrl.url.split("?")[0];
129+
// update form data directly because the form data is already passed to the handleSubmit function
130+
formData.photoUrl = fileUrl;
131+
return formData;
132+
} catch (error) {
133+
console.error("Error uploading file:", error);
134+
toast.error("Error uploading file. Please try again.");
135+
} finally {
136+
setIsUploading(false);
137+
}
138+
};
95139

96140
return (
97141
<form
@@ -104,8 +148,12 @@ export function ProfileEditForm() {
104148
console.error("Invalid form data.");
105149
return;
106150
}
151+
// Handle the file upload if there's a selected file
152+
if (selectedFile) {
153+
res.data = await handleUploadS3(res.data);
154+
}
107155
// if no changes, close dialog
108-
if (!isDirty) {
156+
if (!isDirty && !selectedFile) {
109157
setOpen(false);
110158
return;
111159
}
@@ -183,6 +231,64 @@ export function ProfileEditForm() {
183231
</Text>
184232
) : null}
185233
</label>
234+
<label>
235+
<Text as="div" size="2" mb="1" weight="medium">
236+
Profile Photo
237+
</Text>
238+
<input
239+
type="file"
240+
accept="image/*"
241+
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
242+
setFileError(null);
243+
if (!e.target.files || e.target.files.length === 0) {
244+
setSelectedFile(null);
245+
setPhotoPreviewUrl(null);
246+
return;
247+
}
248+
const file = e.target.files[0];
249+
if (file.size > MAX_FILE_SIZE) {
250+
setFileError("File size must be less than 3MB");
251+
setSelectedFile(null);
252+
setPhotoPreviewUrl(null);
253+
return;
254+
}
255+
256+
setSelectedFile(file);
257+
// Cleanup previous preview URL if it exists
258+
if (photoPreviewUrl) {
259+
URL.revokeObjectURL(photoPreviewUrl);
260+
}
261+
// Create preview URL for the selected image
262+
const objectUrl = URL.createObjectURL(file);
263+
setPhotoPreviewUrl(objectUrl);
264+
}}
265+
/>
266+
{fileError && (
267+
<Text size="1" color="red">
268+
{fileError}
269+
</Text>
270+
)}
271+
{formState.errors?.photoUrl ? (
272+
<Text size="1" color="red">
273+
{formState.errors.photoUrl.message}
274+
</Text>
275+
) : null}
276+
{photoPreviewUrl && (
277+
<Image
278+
src={photoPreviewUrl}
279+
alt="Profile photo preview"
280+
width={100}
281+
height={100}
282+
style={{
283+
objectFit: "cover",
284+
borderRadius: "50%",
285+
marginTop: "12px",
286+
width: "100px",
287+
height: "100px",
288+
}}
289+
/>
290+
)}
291+
</label>
186292
<label>
187293
<Text as="div" size="2" mb="1" weight="medium">
188294
Affiliation
@@ -313,9 +419,9 @@ export function ProfileEditForm() {
313419
"bg-gray-5": !formState.isValid,
314420
})}
315421
type="submit"
316-
disabled={!formState.isValid}
422+
disabled={!formState.isValid || isUploading}
317423
>
318-
Save
424+
{isUploading ? "Uploading photo..." : "Save"}
319425
</Button>
320426
</Flex>
321427
</form>

0 commit comments

Comments
 (0)