diff --git a/backend/api_gateway/src/main/kotlin/com/linkedout/backend/config/WebFluxConfiguration.kt b/backend/api_gateway/src/main/kotlin/com/linkedout/backend/config/WebFluxConfiguration.kt new file mode 100644 index 0000000..f475f5a --- /dev/null +++ b/backend/api_gateway/src/main/kotlin/com/linkedout/backend/config/WebFluxConfiguration.kt @@ -0,0 +1,12 @@ +package com.linkedout.backend.config + +import org.springframework.context.annotation.Configuration +import org.springframework.http.codec.ServerCodecConfigurer +import org.springframework.web.reactive.config.WebFluxConfigurer + +@Configuration +internal open class WebFluxConfiguration : WebFluxConfigurer { + override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) { + configurer.defaultCodecs().maxInMemorySize(8 * 1024 * 1024) + } +} diff --git a/backend/api_gateway/src/main/resources/application.yml b/backend/api_gateway/src/main/resources/application.yml index 84bcc01..b759443 100644 --- a/backend/api_gateway/src/main/resources/application.yml +++ b/backend/api_gateway/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + codec: + max-in-memory-size: 8MB security: oauth2: resourceserver: diff --git a/front/app.json b/front/app.json index b15bf4c..fa11007 100644 --- a/front/app.json +++ b/front/app.json @@ -28,6 +28,11 @@ "experiments": { "tsconfigPaths": true }, - "plugins": ["expo-localization", "expo-font", "expo-secure-store"] + "plugins": [ + "expo-localization", + "expo-font", + "expo-secure-store", + "expo-document-picker" + ] } } diff --git a/front/assets/translations/en_US.json b/front/assets/translations/en_US.json index 4fe53b8..ac28a45 100644 --- a/front/assets/translations/en_US.json +++ b/front/assets/translations/en_US.json @@ -69,7 +69,17 @@ "phoneNumber": "Phone number", "email": "Email", "references": "References", - "downloadResume": "Download resume", + "uploadPicture": "Upload profile picture", + "uploadResume": "Upload resume", + "shareResume": "Share resume", + "resumeNotFound": { + "title": "Resume not found", + "message": "You have not uploaded a resume yet." + }, + "resumeDownloadError": { + "title": "Download error", + "message": "An error occurred while downloading the resume." + }, "information": "Information", "firstName": "First name", "lastName": "Last name", diff --git a/front/assets/translations/fr_FR.json b/front/assets/translations/fr_FR.json index 34ea490..241536d 100644 --- a/front/assets/translations/fr_FR.json +++ b/front/assets/translations/fr_FR.json @@ -68,7 +68,17 @@ "phoneNumber": "Numéro de téléphone", "email": "Email", "references": "Références", - "downloadResume": "Télécharger le CV", + "uploadPicture": "Modifier la photo de profil", + "uploadResume": "Modifier le CV", + "shareResume": "Partager le CV", + "resumeNotFound": { + "title": "Aucun CV trouvé", + "message": "Vous n'avez pas encore ajouté de CV à votre profil." + }, + "resumeDownloadError": { + "title": "Erreur de téléchargement", + "message": "Une erreur est survenue lors du téléchargement de votre CV." + }, "information": "Informations", "firstName": "Prénom", "lastName": "Nom", diff --git a/front/package-lock.json b/front/package-lock.json index 596764b..568368d 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -20,9 +20,13 @@ "expo": "~50.0.6", "expo-auth-session": "~5.4.0", "expo-crypto": "~12.8.0", + "expo-document-picker": "~11.10.1", + "expo-file-system": "~16.0.6", "expo-font": "~11.10.2", + "expo-image-picker": "~14.7.1", "expo-localization": "~14.8.3", "expo-secure-store": "~12.8.1", + "expo-sharing": "~11.10.0", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "expo-system-ui": "~2.9.3", @@ -9508,6 +9512,14 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "11.10.1", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.10.1.tgz", + "integrity": "sha512-A1MiLfyXQ+KxanRO5lYxYQy3ryV+25JHe5Ai/BLV+FJU0QXByUF+Y/dn35WVPx5gpdZXC8UJ4ejg5SKSoeconw==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "16.0.6", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.6.tgz", @@ -9527,6 +9539,25 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.6.0.tgz", + "integrity": "sha512-RHQTDak7/KyhWUxikn2yNzXL7i2cs16cMp6gEAgkHOjVhoCJQoOJ0Ljrt4cKQ3IowxgCuOrAgSUzGkqs7omj8Q==", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "14.7.1", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-14.7.1.tgz", + "integrity": "sha512-ILQVOJgI3aEzrDmCFGDPtpAepYkn8mot8G7vfQ51BfFdQbzL6N3Wm1fS/ofdWlAZJl/qT2DwaIh5xYmf3SyGZA==", + "dependencies": { + "expo-image-loader": "~4.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "12.8.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-12.8.2.tgz", @@ -9684,6 +9715,14 @@ "expo": "*" } }, + "node_modules/expo-sharing": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-11.10.0.tgz", + "integrity": "sha512-/64RyyKlZ25WfnMXa87HbPXhIIqWwNbIku/RaIYAq4SE0XTRC+KTH3v0XFkfDa+SCG/jKsAr1pJ3vQvsNo1sCQ==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.26.4", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.26.4.tgz", diff --git a/front/package.json b/front/package.json index 93d1319..4efd79f 100644 --- a/front/package.json +++ b/front/package.json @@ -27,7 +27,9 @@ "expo-auth-session": "~5.4.0", "expo-crypto": "~12.8.0", "expo-font": "~11.10.2", + "expo-image-picker": "~14.7.1", "expo-localization": "~14.8.3", + "expo-secure-store": "~12.8.1", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "expo-system-ui": "~2.9.3", @@ -44,7 +46,9 @@ "react-native-tab-view": "^3.5.2", "react-redux": "^9.1.0", "typescript": "^5.3.3", - "expo-secure-store": "~12.8.1" + "expo-document-picker": "~11.10.1", + "expo-sharing": "~11.10.0", + "expo-file-system": "~16.0.6" }, "devDependencies": { "@babel/core": "^7.23.9", diff --git a/front/src/components/profile/ProfileShareResumeButton.tsx b/front/src/components/profile/ProfileShareResumeButton.tsx new file mode 100644 index 0000000..4bf4508 --- /dev/null +++ b/front/src/components/profile/ProfileShareResumeButton.tsx @@ -0,0 +1,64 @@ +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import { useCallback } from 'react'; +import { Alert } from 'react-native'; +import { Button } from 'react-native-paper'; + +import { useAppSelector } from '@/store/hooks'; +import i18n from '@/utils/i18n'; + +/** + * Button to share the resume of the user. + * @constructor + */ +export const ProfileShareResumeButton = () => { + // Store hooks + const auth = useAppSelector((state) => state.auth); + + // Callbacks + const handleDownloadResume = useCallback(async () => { + if (auth.state !== 'authenticated') { + return; + } + + const downloadResult = await FileSystem.downloadAsync( + `${process.env.EXPO_PUBLIC_API_URL}/profile/cv`, + FileSystem.cacheDirectory + 'cv.pdf', + { + headers: { + Authorization: `Bearer ${auth.token}`, + }, + }, + ); + + if (downloadResult.status !== 200) { + if (downloadResult.status === 404) { + Alert.alert( + i18n.t('profile.info.resumeNotFound.title'), + i18n.t('profile.info.resumeNotFound.message'), + ); + } else { + Alert.alert( + i18n.t('profile.info.resumeDownloadError.title'), + i18n.t('profile.info.resumeDownloadError.message'), + ); + } + + return; + } + + await Sharing.shareAsync(downloadResult.uri); + }, [auth]); + + return ( + + ); +}; + +export default ProfileShareResumeButton; diff --git a/front/src/components/profile/ProfileUpdateInfosForm.tsx b/front/src/components/profile/ProfileUpdateInfosForm.tsx index f18d482..8c3762c 100644 --- a/front/src/components/profile/ProfileUpdateInfosForm.tsx +++ b/front/src/components/profile/ProfileUpdateInfosForm.tsx @@ -2,6 +2,8 @@ import { FC, useCallback } from 'react'; import { Image, StyleSheet, View, ViewStyle } from 'react-native'; import { Text, TextInput } from 'react-native-paper'; +import ProfileUploadPictureButton from '@/components/profile/ProfileUploadPictureButton'; +import ProfileUploadResumeButton from '@/components/profile/ProfileUploadResumeButton'; import i18n from '@/utils/i18n'; /** @@ -27,6 +29,10 @@ const styles = StyleSheet.create({ textInput: { marginVertical: 8, }, + uploadButtonContainer: { + gap: 8, + marginVertical: 8, + }, }); /** @@ -127,6 +133,11 @@ const ProfileUpdateInfosForm: FC = ({ onChangeText={(value) => handleInputChange('shortBiography', value)} /> + + + + + {i18n.t('profile.info.contact')} diff --git a/front/src/components/profile/ProfileUploadPictureButton.tsx b/front/src/components/profile/ProfileUploadPictureButton.tsx new file mode 100644 index 0000000..06cd27b --- /dev/null +++ b/front/src/components/profile/ProfileUploadPictureButton.tsx @@ -0,0 +1,43 @@ +import * as ImagePicker from 'expo-image-picker'; +import { useCallback } from 'react'; +import { Button } from 'react-native-paper'; + +import { useUploadProfilePictureMutation } from '@/store/api/profileApiSlice'; +import i18n from '@/utils/i18n'; + +/** + * Button to upload the profile picture of the user. + * @constructor + */ +export const ProfileUploadPictureButton = () => { + // API calls + const [uploadProfilePicture] = useUploadProfilePictureMutation(); + + // Callbacks + const handleUploadPicture = useCallback(async () => { + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: true, + aspect: [1, 1], + exif: false, + quality: 0.4, + }); + + if (result.canceled === true || result.assets.length === 0) { + return; + } + + uploadProfilePicture(result.assets[0].uri); + }, [uploadProfilePicture]); + + return ( + + ); +}; + +export default ProfileUploadPictureButton; diff --git a/front/src/components/profile/ProfileUploadResumeButton.tsx b/front/src/components/profile/ProfileUploadResumeButton.tsx new file mode 100644 index 0000000..645c9f1 --- /dev/null +++ b/front/src/components/profile/ProfileUploadResumeButton.tsx @@ -0,0 +1,40 @@ +import * as DocumentPicker from 'expo-document-picker'; +import { useCallback } from 'react'; +import { Button } from 'react-native-paper'; + +import { useUploadResumeMutation } from '@/store/api/profileApiSlice'; +import i18n from '@/utils/i18n'; + +/** + * Button to upload the resume of the user. + * @constructor + */ +export const ProfileUploadResumeButton = () => { + // API calls + const [uploadResume] = useUploadResumeMutation(); + + // Callbacks + const handleUploadResume = useCallback(async () => { + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/pdf', + }); + + if (result.canceled === true || result.assets.length === 0) { + return; + } + + uploadResume(result.assets[0].uri); + }, [uploadResume]); + + return ( + + ); +}; + +export default ProfileUploadResumeButton; diff --git a/front/src/components/profile/header/ProfileHeader.tsx b/front/src/components/profile/header/ProfileHeader.tsx index 7f1632d..c05bc60 100644 --- a/front/src/components/profile/header/ProfileHeader.tsx +++ b/front/src/components/profile/header/ProfileHeader.tsx @@ -1,10 +1,9 @@ import { FC } from 'react'; import { StyleSheet, View } from 'react-native'; -import { Button } from 'react-native-paper'; +import ProfileShareResumeButton from '@/components/profile/ProfileShareResumeButton'; import ProfileHeaderDescription from '@/components/profile/header/ProfileHeaderDescription'; import ProfileHeaderName from '@/components/profile/header/ProfileHeaderName'; -import i18n from '@/utils/i18n'; /** * The styles for the ProfileHeader component. @@ -68,10 +67,7 @@ const ProfileHeader: FC = ({ /> - - + ); }; diff --git a/front/src/global.d.ts b/front/src/global.d.ts new file mode 100644 index 0000000..dbaf068 --- /dev/null +++ b/front/src/global.d.ts @@ -0,0 +1,10 @@ +interface FormDataValue { + uri: string; + name: string; + type: string; +} + +interface FormData { + append(name: string, value: FormDataValue, fileName?: string): void; + set(name: string, value: FormDataValue, fileName?: string): void; +} diff --git a/front/src/pages/profile/ProfilePage.tsx b/front/src/pages/profile/ProfilePage.tsx index 1489f0d..b27d816 100644 --- a/front/src/pages/profile/ProfilePage.tsx +++ b/front/src/pages/profile/ProfilePage.tsx @@ -5,7 +5,10 @@ import { Appbar } from 'react-native-paper'; import ProfileContents from '@/components/profile/ProfileContents'; import { useGetAvailabilitiesQuery } from '@/store/api/availabilityApiSlice'; -import { useGetProfileQuery } from '@/store/api/profileApiSlice'; +import { + useGetProfilePictureQuery, + useGetProfileQuery, +} from '@/store/api/profileApiSlice'; import { ProfileStackParamList } from './ProfileNav'; @@ -24,6 +27,7 @@ type ProfilePageProps = NativeStackScreenProps< const ProfilePage: FC = ({ navigation }) => { // API calls const { data: profile, refetch: refetchProfile } = useGetProfileQuery(); + const { data: profilePicture } = useGetProfilePictureQuery(); const { data: availabilities, refetch: refetchAvailabilities } = useGetAvailabilitiesQuery(); @@ -71,7 +75,7 @@ const ProfilePage: FC = ({ navigation }) => { return ( = ({ navigation }) => { // API calls const { data: profile } = useGetProfileQuery(); + const { data: profilePicture } = useGetProfilePictureQuery(); const { data: availabilities } = useGetAvailabilitiesQuery(); const [patchProfile] = usePatchProfileMutation(); const [deleteAvailability] = useDeleteAvailabilityMutation(); @@ -143,7 +145,7 @@ const ProfileUpdatePage: FC = ({ navigation }) => { contentContainerStyle={styles.contentContainer} > diff --git a/front/src/store/api/apiSlice.ts b/front/src/store/api/apiSlice.ts index ad577ed..295ef37 100644 --- a/front/src/store/api/apiSlice.ts +++ b/front/src/store/api/apiSlice.ts @@ -29,6 +29,8 @@ export const apiSlice = createApi({ 'Experiences', 'JobCategories', 'Profile', + 'ProfilePicture', + 'ProfileResume', 'References', 'Job', 'JobOffer', diff --git a/front/src/store/api/profileApiSlice.ts b/front/src/store/api/profileApiSlice.ts index 6e6a666..91edc02 100644 --- a/front/src/store/api/profileApiSlice.ts +++ b/front/src/store/api/profileApiSlice.ts @@ -28,6 +28,57 @@ export const extendedApiSlice = apiSlice.injectEndpoints({ }), invalidatesTags: ['Profile'], }), + getProfilePicture: builder.query({ + query: () => ({ + url: 'profile/photo', + responseHandler: async (response) => { + const blob = await response.blob(); + + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + }, + }), + providesTags: ['ProfilePicture'], + }), + uploadProfilePicture: builder.mutation({ + query: (uri) => { + const formData = new FormData(); + formData.append('file', { + uri, + name: 'profile-picture.png', + type: 'image/png', + }); + + return { + url: 'profile/photo', + method: 'POST', + body: formData, + formData: true, + }; + }, + invalidatesTags: ['ProfilePicture'], + }), + uploadResume: builder.mutation({ + query: (uri) => { + const formData = new FormData(); + formData.append('file', { + uri, + name: 'cv.pdf', + type: 'application/pdf', + }); + + return { + url: 'profile/cv', + method: 'POST', + body: formData, + formData: true, + }; + }, + invalidatesTags: ['ProfileResume'], + }), }), }); @@ -35,4 +86,7 @@ export const { useGetProfileQuery, usePatchProfileMutation, usePutProfileMutation, + useGetProfilePictureQuery, + useUploadProfilePictureMutation, + useUploadResumeMutation, } = extendedApiSlice; diff --git a/kube/prod/api_gateway/configure-ingress.patch.yml b/kube/prod/api_gateway/configure-ingress.patch.yml index 09c5de2..338c674 100644 --- a/kube/prod/api_gateway/configure-ingress.patch.yml +++ b/kube/prod/api_gateway/configure-ingress.patch.yml @@ -1,9 +1,6 @@ - op: add path: /metadata/annotations/cert-manager.io~1cluster-issuer value: letsencrypt-prod -- op: add - path: /spec/ingressClassName - value: nginx - op: replace path: /spec/rules/0/host value: linkedout.cluster-2020-5.dopolytech.fr