diff --git a/src/backend/index.ts b/src/backend/index.ts index babf50b843..680ead6886 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,6 +17,7 @@ import workPackageTemplatesRouter from './src/routes/work-package-templates.rout import carsRouter from './src/routes/cars.routes'; import organizationRouter from './src/routes/organizations.routes'; import recruitmentRouter from './src/routes/recruitment.routes'; +import onboardingRouter from './src/routes/onboarding.routes'; const app = express(); @@ -68,6 +69,7 @@ app.use('/templates', workPackageTemplatesRouter); app.use('/cars', carsRouter); app.use('/organizations', organizationRouter); app.use('/recruitment', recruitmentRouter); +app.use('/onboarding', onboardingRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/onboarding.controller.ts b/src/backend/src/controllers/onboarding.controller.ts new file mode 100644 index 0000000000..2bd8660a35 --- /dev/null +++ b/src/backend/src/controllers/onboarding.controller.ts @@ -0,0 +1,21 @@ +import { NextFunction, Request, Response } from 'express'; +import OnboardingServices from '../services/onboarding.services'; + +export default class OnboardingController { + static async downloadImage(req: Request, res: Response, next: NextFunction) { + try { + const { fileId } = req.params; + + const imageData = await OnboardingServices.downloadImage(fileId); + + // Set the appropriate headers for the HTTP response + res.setHeader('content-type', String(imageData.type)); + res.setHeader('content-length', imageData.buffer.length); + + // Send the Buffer as the response body + res.send(imageData.buffer); + } catch (error: unknown) { + return next(error); + } + } +} diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index af14eb1dd8..c418d4d779 100644 --- a/src/backend/src/controllers/organizations.controllers.ts +++ b/src/backend/src/controllers/organizations.controllers.ts @@ -78,6 +78,7 @@ export default class OrganizationsController { if (!req.file) { throw new HttpException(400, 'Invalid or undefined image data'); } + const updatedOrg = await OrganizationsService.setLogoImage(req.file, req.currentUser, req.organization); res.status(200).json(updatedOrg); diff --git a/src/backend/src/routes/onboarding.routes.ts b/src/backend/src/routes/onboarding.routes.ts new file mode 100644 index 0000000000..fabce68796 --- /dev/null +++ b/src/backend/src/routes/onboarding.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import OnboardingController from '../controllers/onboarding.controller'; + +const onboardingRouter = express.Router(); + +onboardingRouter.get('/image/:fileId', OnboardingController.downloadImage); + +export default onboardingRouter; diff --git a/src/backend/src/services/onboarding.services.ts b/src/backend/src/services/onboarding.services.ts new file mode 100644 index 0000000000..823451c580 --- /dev/null +++ b/src/backend/src/services/onboarding.services.ts @@ -0,0 +1,12 @@ +import { NotFoundException } from '../utils/errors.utils'; +import { downloadImageFile } from '../utils/google-integration.utils'; + +export default class OnboardingServices { + static async downloadImage(fileId: string) { + const fileData = await downloadImageFile(fileId); + console.log('FILE DATA RECEIVED'); + + if (!fileData) throw new NotFoundException('Image File', fileId); + return fileData; + } +} diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index c4ce91668b..5feed48fc4 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -201,6 +201,10 @@ export default class OrganizationsService { const logoImageData = await uploadFile(logoImage); + if (!logoImageData?.name) { + throw new HttpException(500, 'Image Name not found'); + } + const updatedOrg = await prisma.organization.update({ where: { organizationId: organization.organizationId }, data: { @@ -216,7 +220,7 @@ export default class OrganizationsService { * @param organizationId the id of the organization * @returns the id of the image */ - static async getLogoImage(organizationId: string): Promise { + static async getLogoImage(organizationId: string): Promise { const organization = await prisma.organization.findUnique({ where: { organizationId } }); @@ -225,10 +229,6 @@ export default class OrganizationsService { throw new NotFoundException('Organization', organizationId); } - if (!organization.logoImageId) { - throw new HttpException(404, `Organization ${organizationId} does not have a logo image`); - } - return organization.logoImageId; } diff --git a/src/backend/tests/unmocked/organization.test.ts b/src/backend/tests/unmocked/organization.test.ts index 2c0affdff9..a66814357b 100644 --- a/src/backend/tests/unmocked/organization.test.ts +++ b/src/backend/tests/unmocked/organization.test.ts @@ -55,7 +55,7 @@ describe('Organization Tests', () => { it('Succeeds and updates all the images', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); (uploadFile as Mock).mockImplementation((file) => { - return Promise.resolve({ id: `uploaded-${file.originalname}` }); + return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); }); await OrganizationsService.setImages(file1, file2, testBatman, organization); @@ -240,7 +240,7 @@ describe('Organization Tests', () => { it('Succeeds and updates the logo', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); (uploadFile as Mock).mockImplementation((file) => { - return Promise.resolve({ id: `uploaded-${file.originalname}` }); + return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); }); await OrganizationsService.setLogoImage(file1, testBatman, organization); @@ -273,12 +273,6 @@ describe('Organization Tests', () => { ); }); - it('Fails if the organization does not have a logo image', async () => { - await expect(async () => await OrganizationsService.getLogoImage(orgId)).rejects.toThrow( - new HttpException(404, `Organization ${orgId} does not have a logo image`) - ); - }); - it('Succeeds and gets the image', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); await OrganizationsService.setLogoImage( diff --git a/src/frontend/src/apis/onboarding.api.ts b/src/frontend/src/apis/onboarding.api.ts new file mode 100644 index 0000000000..7912e80516 --- /dev/null +++ b/src/frontend/src/apis/onboarding.api.ts @@ -0,0 +1,16 @@ +import axios from 'axios'; +import { apiUrls } from '../utils/urls'; + +/** + * API Call to download a google image + * @param fileId file id to be downloaded + * @returns an image blob + */ +export const downloadGoogleImage = async (fileId: string): Promise => { + const response = await axios.get(apiUrls.imageById(fileId), { + responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer + }); + const imageBuffer = new Uint8Array(response.data); + const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] }); + return imageBlob; +}; diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index e5903575c9..7c7f37af1f 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -24,8 +24,35 @@ export const setOrganizationDescription = async (description: string) => { }); }; +export const getOrganizationLogo = async () => { + return axios.get(apiUrls.organizationsLogoImage(), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +export const setOrganizationLogo = async (file: File) => { + const formData = new FormData(); + formData.append('logo', file); + return axios.post(apiUrls.organizationsSetLogoImage(), formData); +}; + export const setOrganizationFeaturedProjects = async (featuredProjectIds: string[]) => { return axios.post(apiUrls.organizationsSetFeaturedProjects(), { projectIds: featuredProjectIds }); }; + +/** + * Downloads a given fileId from google drive into a blob + * + * @param fileId the google id of the file to download + * @returns the downloaded file as a Blob + */ +export const downloadGoogleImage = async (fileId: string): Promise => { + const response = await axios.get(apiUrls.imageById(fileId), { + responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer + }); + const imageBuffer = new Uint8Array(response.data); + const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] }); + return imageBlob; +}; diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index 24da9a5653..573ece5bdb 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -6,8 +6,11 @@ import { getFeaturedProjects, getCurrentOrganization, setOrganizationDescription, + getOrganizationLogo, + setOrganizationLogo, setOrganizationFeaturedProjects } from '../apis/organizations.api'; +import { downloadGoogleImage } from '../apis/organizations.api'; interface OrganizationProvider { organizationId: string; @@ -84,3 +87,22 @@ export const useSetFeaturedProjects = () => { } ); }; + +export const useSetOrganizationLogo = () => { + const queryClient = useQueryClient(); + return useMutation(['reimbursement-requsts', 'edit'], async (file: File) => { + const { data } = await setOrganizationLogo(file); + queryClient.invalidateQueries(['organizations']); + return data; + }); +}; + +export const useOrganizationLogo = () => { + return useQuery(['organizations', 'logo'], async () => { + const { data: fileId } = await getOrganizationLogo(); + if (!fileId) { + return; + } + return await downloadGoogleImage(fileId); + }); +}; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx index 06e44ac673..e00599f669 100644 --- a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditDescription.tsx @@ -36,7 +36,7 @@ const EditDescription: React.FC = () => { return ( { return ( { + const { + data: organization, + isLoading: organizationIsLoading, + isError: organizationIsError, + error: organizationError + } = useCurrentOrganization(); + const { data: imageData, isLoading: imageDataIsLoading, isError: imageIsError, error: imageError } = useOrganizationLogo(); + const { mutateAsync, isLoading } = useSetOrganizationLogo(); + const toast = useToast(); + const [isEditMode, setIsEditMode] = useState(false); + const theme = useTheme(); + + if (isLoading || !mutateAsync || organizationIsLoading || !organization || imageDataIsLoading) return ; + if (organizationIsError) return ; + if (imageIsError) return ; + + const handleClose = () => { + setIsEditMode(false); + }; + + const onSubmit = async (logoInput: EditLogoInput) => { + try { + if (!logoInput.logoImage) { + toast.error('No logo image submitted.'); + handleClose(); + return; + } + await mutateAsync(logoInput.logoImage); + toast.success('Logo updated successfully!'); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + handleClose(); + }; + + return ( + + + {organization.name} Logo + + {isEditMode ? ( + + ) : ( + <> + + + + setIsEditMode(true)}> + Update + + + + + )} + + ); +}; + +export default EditLogo; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx new file mode 100644 index 0000000000..72be9d618e --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogoForm.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Box, Button, FormControl, Stack, Typography } from '@mui/material'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { Controller, useForm } from 'react-hook-form'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import ImageIcon from '@mui/icons-material/Image'; + +export interface EditLogoInput { + logoImage?: File; +} + +interface EditLogoFormProps { + onSubmit: (logoImage: EditLogoInput) => Promise; + onHide: () => void; + orgLogo?: File; +} + +const EditLogoForm: React.FC = ({ onSubmit, orgLogo, onHide }) => { + const { handleSubmit, control, reset } = useForm({ + defaultValues: { + logoImage: orgLogo + } + }); + + const onHideWrapper = () => { + onHide(); + reset(); + }; + + return ( + + ); +}; + +export default EditLogoForm; diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx index 7e47085d0f..419bb2548c 100644 --- a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx @@ -1,13 +1,21 @@ -import { Stack } from '@mui/material'; +import { Stack, Grid } from '@mui/material'; import EditDescription from './EditDescription'; import EditFeaturedProjects from './EditFeaturedProjects'; +import EditLogo from './EditLogo'; const GuestViewConfig: React.FC = () => { return ( - - - - + + + + + + + + + + + ); }; diff --git a/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx b/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx new file mode 100644 index 0000000000..0ebeb68db5 --- /dev/null +++ b/src/frontend/src/pages/HomePage/components/LogoDisplay.tsx @@ -0,0 +1,35 @@ +import { Box, useTheme, Card } from '@mui/material'; +import React from 'react'; + +interface LogoDisplayProps { + imageUrl?: string; +} + +const LogoDisplay: React.FC = ({ imageUrl }) => { + const theme = useTheme(); + return ( + + {imageUrl && ( + + )} + + ); +}; + +export default LogoDisplay; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index d50675ed69..f034f3f221 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -179,6 +179,8 @@ const organizationsUsefulLinks = () => `${organizations()}/useful-links`; const organizationsSetUsefulLinks = () => `${organizationsUsefulLinks()}/set`; const organizationsSetDescription = () => `${organizations()}/description/set`; const organizationsFeaturedProjects = () => `${organizations()}/featured-projects`; +const organizationsLogoImage = () => `${organizations()}/logo`; +const organizationsSetLogoImage = () => `${organizations()}/logo/update`; const organizationsSetFeaturedProjects = () => `${organizationsFeaturedProjects()}/set`; /******************* Car Endpoints ********************/ @@ -196,6 +198,10 @@ const faqCreate = () => `${recruitment()}/faq/create`; const faqEdit = (id: string) => `${recruitment()}/faq/${id}/edit`; const faqDelete = (id: string) => `${recruitment()}/faq/${id}/delete`; +/************** Onboarding Endpoints ***************/ +const onboarding = () => `${API_URL}/onboarding`; +const imageById = (imageId: string) => `${onboarding()}/image/${imageId}`; + /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -341,6 +347,8 @@ export const apiUrls = { organizationsSetUsefulLinks, organizationsFeaturedProjects, organizationsSetDescription, + organizationsLogoImage, + organizationsSetLogoImage, organizationsSetFeaturedProjects, cars, @@ -354,6 +362,7 @@ export const apiUrls = { faqCreate, faqEdit, faqDelete, + imageById, version };