Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#56 integrated SMCloudStore with Azure provider; cleaned Item form in frontend #58

Merged
merged 5 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 41 additions & 47 deletions client-app/src/Components/NewItemForm.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't see any validation that you're only accepting images, indication what sort of images types might be accepted, or limits on file size. these aren't urgent concerns, but you'll probably want them at least in the client, and probably in the server, at some point. maybe just drop a new issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense, Copied!!

Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import React, { useState, ChangeEvent, FormEvent, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import '../css/DonorForm.css';

interface FormData {
itemType: string;
currentStatus: string;
donorEmail: string;
donorId: number | null;
program: string;
programId: number | null;
imageUpload: string[];
dateDonated: string;
imageFiles: File[];
}

interface FormErrors {
Expand All @@ -24,14 +23,13 @@ interface Option {
}

const NewItemForm: React.FC = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<FormData>({
itemType: '',
currentStatus: 'Received',
donorEmail: '',
donorId: null,
program: '',
programId: null,
imageUpload: [],
imageFiles: [],
dateDonated: '',
});

Expand All @@ -54,7 +52,7 @@ const NewItemForm: React.FC = () => {
`${process.env.REACT_APP_BACKEND_API_BASE_URL}donor`,
);
const emailOptions = response.data.map((donor: any) => ({
value: donor.firstName,
value: donor.id,
label: donor.email,
id: donor.id,
}));
Expand All @@ -70,7 +68,7 @@ const NewItemForm: React.FC = () => {
`${process.env.REACT_APP_BACKEND_API_BASE_URL}program`,
);
const programOptions = response.data.map((program: any) => ({
value: program.name,
value: program.id,
label: program.name,
id: program.id,
}));
Expand All @@ -97,25 +95,31 @@ const NewItemForm: React.FC = () => {
const files = e.target.files;
if (files) {
const fileArray = Array.from(files);
const base64Images = await Promise.all(
fileArray.map(file => convertToBase64(file)),
);
setFormData(prevState => ({
...prevState,
imageUpload: [...prevState.imageUpload, ...base64Images],
imageFiles: [...prevState.imageFiles, ...fileArray]
}));

// Creating previews for display
const filePreviews = await Promise.all(fileArray.map(file => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = error => reject(error);
});
}));
setPreviews([...previews, ...base64Images]);

setPreviews(prev => [...prev, ...filePreviews]);
}
};

const removeImage = (index: number) => {
const updatedImages = formData.imageUpload.filter(
(_, i) => i !== index,
);
const updatedFiles = formData.imageFiles.filter((_, i) => i !== index);
const updatedPreviews = previews.filter((_, i) => i !== index);
setFormData(prevState => ({
...prevState,
imageUpload: updatedImages,
imageFiles: updatedFiles
}));
setPreviews(updatedPreviews);
};
Expand All @@ -136,7 +140,6 @@ const NewItemForm: React.FC = () => {
);
setFormData(prevState => ({
...prevState,
donorEmail: value,
donorId: selectedDonor?.id || null,
}));
} else if (name === 'program') {
Expand All @@ -145,7 +148,6 @@ const NewItemForm: React.FC = () => {
);
setFormData(prevState => ({
...prevState,
program: value,
programId: selectedProgram?.id || null,
}));
} else {
Expand All @@ -164,7 +166,6 @@ const NewItemForm: React.FC = () => {
'itemType',
'currentStatus',
'donorEmail',
'program',
'dateDonated',
];

Expand All @@ -190,41 +191,36 @@ const NewItemForm: React.FC = () => {
try {
const formDataToSubmit = new FormData();
formDataToSubmit.append('itemType', formData.itemType);
formDataToSubmit.append(
'currentStatus',
formData.currentStatus,
);
formDataToSubmit.append(
'donorId',
formData.donorId !== null ? String(formData.donorId) : '',
);
formDataToSubmit.append(
'programId',
String(Number(formData.programId)),
);
formDataToSubmit.append('currentStatus', formData.currentStatus);
formDataToSubmit.append('donorId', formData.donorId ? formData.donorId.toString() : '');
formDataToSubmit.append('programId', formData.programId ? formData.programId.toString() : '');
formDataToSubmit.append('dateDonated', formData.dateDonated);

// Append image files directly as part of the FormData
formData.imageFiles.forEach(file => {
formDataToSubmit.append('imageFiles', file);
});


const response = await axios.post(
`${process.env.REACT_APP_BACKEND_API_BASE_URL}donatedItem`,
formDataToSubmit,
{
headers: {
'Content-Type': 'application/json',
'Content-Type': 'multipart/form-data',
},
},
);

if (response.status === 201) {
setSuccessMessage('Item added successfully!');
handleRefresh();
navigate('/donations');
} else {
setErrorMessage('Item not added');
}
} catch (error: unknown) {
const message =
(error as any).response?.data?.message ||
'Error adding item';
setErrorMessage(message);
} catch (error: any) {
setErrorMessage(error.response?.data?.message || 'Error adding item');
}
} else {
setErrorMessage('Form has validation errors');
Expand All @@ -235,11 +231,9 @@ const NewItemForm: React.FC = () => {
setFormData({
itemType: '',
currentStatus: 'Received',
donorEmail: '',
donorId: null,
program: '',
programId: null,
imageUpload: [],
imageFiles: [],
dateDonated: '',
});
setPreviews([]);
Expand All @@ -260,7 +254,7 @@ const NewItemForm: React.FC = () => {
{label}
{required && <span className="text-red-500">&nbsp;*</span>}
</label>
{name === 'imageUpload' ? (
{name === 'imageFiles' ? (
<div>
<input
type="file"
Expand Down Expand Up @@ -349,29 +343,29 @@ const NewItemForm: React.FC = () => {
{renderFormField('Current Status', 'currentStatus')}
{renderFormField(
'Donor Email',
'donorEmail',
'donorId',
'text',
true,
donorEmailOptions,
)}
{renderFormField(
'Program',
'program',
'programId',
'text',
true,
false,
programOptions,
)}
{renderFormField('Date Donated', 'dateDonated', 'date')}
{renderFormField(
'Images (Max 5)',
'imageUpload',
'imageFiles',
'file',
false,
)}

<div className="form-field full-width button-container">
<button type="submit" className="submit-button">
Add Item
Submit
</button>
<button
type="button"
Expand Down
2 changes: 2 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Create a `.env` file and define the necessary environment variables:
```plaintext
DATABASE_URL="postgresql://username:password@localhost:5432/dbname"
PORT=5000
AZURE_STORAGE_ACCOUNT_NAME="mdmaproject"
AZURE_STORAGE_ACCESS_KEY="<enter-azure-storage-access-key>">
```

Replace `username`, `password`, and `dbname` with your PostgreSQL username, password, and the name of the database you created.
Expand Down
5 changes: 4 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.19.1",
"@smcloudstore/azure-storage": "^0.2.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"joi": "^17.13.3",
"morgan": "^1.10.0",
"portfinder": "^1.0.32"
"portfinder": "^1.0.32",
"smcloudstore": "^0.2.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "DonatedItemStatus" ADD COLUMN "imageUrls" TEXT[];
1 change: 1 addition & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ model DonatedItemStatus {
donatedItemId Int
donatedItem DonatedItem @relation(fields: [donatedItemId], references: [id], onDelete: Cascade)
@@index([donatedItemId])
imageUrls String[]
}

model Program {
Expand Down
11 changes: 11 additions & 0 deletions server/src/configs/SMCloudStoreConfig.ts
Copy link
Collaborator

@kungfuchicken kungfuchicken Oct 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

starting with Azure makes sense based on what we discussed. do you have an issue created to add an option for all the supported platforms? it seems like it would be a straightforward update to this file, the donated item service, and package.json.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writing a new connection for other cloud storage is easy but I have to create accounts for other cloud providers to get the connection keys, storage account etc which is time consuming. I will create an issue for that. What is the purpose of the multiple cloud providers? Is it for fail safe or to be able to switch to other providers in future?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to switch to other providers in the future, especially if the solution is deployed by other users. solving for BWorks is our first use case, but in talking w/ Patrick my understanding is that his vision has always been something that anyone who does similar things could re-use the tool.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense. I will work on that.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require('dotenv').config();
const SMCloudStore = require('smcloudstore')

// connection options for Azure Blob Storage
const connection = {
storageAccount: process.env.AZURE_STORAGE_ACCOUNT_NAME,
storageAccessKey: process.env.AZURE_STORAGE_ACCESS_KEY
}

// Return an instance of the AzureStorageProvider class
export const storage = SMCloudStore.Create('azure-storage', connection)
27 changes: 20 additions & 7 deletions server/src/routes/donatedItemRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import prisma from '../prismaClient'; // Import Prisma client
import { donatedItemValidator } from '../validators/donatedItemValidator'; // Import the validator
import { validateDonor } from '../services/donorService';
import { validateProgram } from '../services/programService';
import { date } from 'joi';
import { uploadToAzure } from '../services/donatedItemService';

const router = Router();
const upload = multer({ storage: multer.memoryStorage() });

// POST /donatedItem - Create a new DonatedItem
router.post('/', donatedItemValidator, async (req: Request, res: Response) => {
router.post('/', [upload.array('imageFiles'), donatedItemValidator], async (req: Request, res: Response) => {
try {

const imageFiles = req.files as Express.Multer.File[];
const donorId = parseInt(req.body.donorId);
const programId = parseInt(req.body.programId);
const { dateDonated, ...rest } = req.body;

try {
await validateDonor(req.body.donorId);
await validateProgram(req.body.programId);
await validateDonor(donorId);
await validateProgram(programId);
} catch (error) {
if (error instanceof Error) {
console.log('error', error)
return res.status(400).json({ error: error.message });
}
}
Expand All @@ -27,16 +32,24 @@ router.post('/', donatedItemValidator, async (req: Request, res: Response) => {
const newItem = await prisma.donatedItem.create({
data: {
...rest, //spread the rest of the fields
donorId,
programId,
dateDonated: dateDonatedDateTime,
// dateDonated: new Date(dateDonated),
// dateDonated: new Date(dateDonated).setUTCHours(0,0,0,0), // Set time to 00:00:00 UTC
},
});

// upload images to Azure and get their filenames
const imageUrls = await Promise.all(imageFiles.map((file, index) => {
const formattedDate = new Date().toISOString();
return uploadToAzure(file, `item-${formattedDate}-${newItem.id}.jpg`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming .jpg is fine for now, but long-term it might bite you. maybe you want a function to handle detecting file types and generating your uploaded image file name. you could create an issue to do that. it might make a good-first-issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it makes sense. I blindly used .jpg but it could be .png image also. I will try to fix it or create a good first issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed this one by creating a function to find the file extension based on incoming file MIME type.

}));

const newStatus = await prisma.donatedItemStatus.create({
data: {
statusType: 'Received',
dateModified: dateDonatedDateTime, // Use the same date as dateDonated
donatedItemId: newItem.id,
imageUrls: imageUrls
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ export const donatedItemSchema = Joi.object({
currentStatus: Joi.string()
.valid('Received', 'Pending', 'Processed', 'Delivered')
.required(),
donorId: Joi.number().integer().required(),
programId: Joi.number().integer().required(),
donorId: Joi.alternatives().try(
Joi.string().pattern(/^\d+$/)
).required().messages({
'string.pattern.base': 'donorId must be a numeric string'
}),
programId: Joi.alternatives().try(
Joi.string().pattern(/^\d+$/)
).messages({
'string.pattern.base': 'programId must be a numeric string'
}),
dateDonated: Joi.date().required(), // Validates as a proper date
});

Expand Down
10 changes: 10 additions & 0 deletions server/src/services/donatedItemService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import multer from 'multer';
import { storage } from "../configs/SMCloudStoreConfig";

export async function uploadToAzure(file: Express.Multer.File, filename: string): Promise<string> {
const containerName = 'mdma-dev';
await storage.putObject(containerName, filename, file.buffer, {
'Content-Type': file.mimetype
});
return `${containerName}/${filename}`;
}
2 changes: 1 addition & 1 deletion server/src/validators/donatedItemStatusValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { donatedItemStatusSchema } from '../schemas/donatedItems';
import { donatedItemStatusSchema } from '../schemas/donatedItemSchema';

export const donatedItemStatusValidator = (
req: Request,
Expand Down
2 changes: 1 addition & 1 deletion server/src/validators/donatedItemValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { donatedItemSchema } from '../schemas/donatedItems';
import { donatedItemSchema } from '../schemas/donatedItemSchema';

export const donatedItemValidator = (
req: Request,
Expand Down
Loading