Skip to content

Commit

Permalink
feat: add image upload (#34)
Browse files Browse the repository at this point in the history
* feat: add image upload

* add image size limit

* fix linting issue

* fix campService test

* fix test

* fix linting error

* refactor: change property name body to data
  • Loading branch information
SinghHarman286 authored Apr 21, 2022
1 parent 4030263 commit 328e9ae
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 41 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
.vscode
**/*.cache
**/*.egg-info
**/uploads/
55 changes: 36 additions & 19 deletions backend/typescript/middlewares/validators/campValidators.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import fs from "fs";
import { Request, Response, NextFunction } from "express";
import { validateFormQuestion } from "./formQuestionValidators";
import {
getApiValidationError,
getImageTypeValidationError,
getImageSizeValidationError,
validateArray,
validatePrimitive,
validateDate,
validateImageType,
validateTime,
validateImageSize,
} from "./util";
import { getErrorMessage } from "../../utilities/errorUtils";

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable-next-line import/prefer-default-export */
Expand All @@ -15,39 +21,42 @@ export const createCampDtoValidator = async (
res: Response,
next: NextFunction,
) => {
if (!validatePrimitive(req.body.name, "string")) {
let body;
try {
body = JSON.parse(req.body.data);
} catch (e: unknown) {
return res.status(400).send(getErrorMessage(e));
}
if (!validatePrimitive(body.name, "string")) {
return res.status(400).send(getApiValidationError("name", "string"));
}
if (
req.body.description &&
!validatePrimitive(req.body.description, "string")
) {
if (body.description && !validatePrimitive(body.description, "string")) {
return res.status(400).send(getApiValidationError("description", "string"));
}
if (!validatePrimitive(req.body.location, "string")) {
if (!validatePrimitive(body.location, "string")) {
return res.status(400).send(getApiValidationError("location", "string"));
}
if (!validatePrimitive(req.body.ageLower, "integer")) {
if (!validatePrimitive(body.ageLower, "integer")) {
return res.status(400).send(getApiValidationError("ageLower", "integer"));
}
if (!validatePrimitive(req.body.ageUpper, "integer")) {
if (!validatePrimitive(body.ageUpper, "integer")) {
return res.status(400).send(getApiValidationError("ageUpper", "integer"));
}
if (req.body.ageUpper < req.body.ageLower) {
if (body.ageUpper < body.ageLower) {
return res.status(400).send("ageUpper must be larger than ageLower");
}
if (!validatePrimitive(req.body.capacity, "integer")) {
if (!validatePrimitive(body.capacity, "integer")) {
return res.status(400).send(getApiValidationError("capacity", "integer"));
}
if (!validatePrimitive(req.body.fee, "integer")) {
if (!validatePrimitive(body.fee, "integer")) {
return res.status(400).send(getApiValidationError("fee", "integer"));
}

if (
req.body.formQuestions &&
Array.isArray(req.body.formQuestions) &&
body.formQuestions &&
Array.isArray(body.formQuestions) &&
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
!req.body.formQuestions.every((formQuestion: { [key: string]: any }) => {
!body.formQuestions.every((formQuestion: { [key: string]: any }) => {
return validateFormQuestion(formQuestion);
})
) {
Expand All @@ -56,9 +65,9 @@ export const createCampDtoValidator = async (
.send(getApiValidationError("formQuestion", "string", true));
}

if (req.body.campSessions) {
for (let i = 0; i < req.body.campSessions.length; i += 1) {
const campSession = req.body.campSessions[i];
if (body.campSessions) {
for (let i = 0; i < body.campSessions.length; i += 1) {
const campSession = body.campSessions[i];
if (campSession.dates && !validateArray(campSession.dates, "string")) {
return res
.status(400)
Expand Down Expand Up @@ -92,11 +101,19 @@ export const createCampDtoValidator = async (
}
}
}
if (req.body.campers) {
if (body.campers) {
return res.status(400).send("campers should be empty");
}
if (req.body.waitlist) {
if (body.waitlist) {
return res.status(400).send("waitlist should be empty");
}
if (req.file && !validateImageType(req.file.mimetype)) {
fs.unlinkSync(req.file.path);
return res.status(400).send(getImageTypeValidationError(req.file.mimetype));
}
if (req.file && !validateImageSize(req.file.size)) {
fs.unlinkSync(req.file.path);
return res.status(400).send(getImageSizeValidationError());
}
return next();
};
24 changes: 24 additions & 0 deletions backend/typescript/middlewares/validators/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const allowableContentTypes = new Set([
"image/gif",
]);

const allowableImageContentTypes = new Set(["image/png", "image/jpeg"]);

const allowableImageSize = 5;

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export const validatePrimitive = (value: any, type: Type): boolean => {
Expand Down Expand Up @@ -49,6 +53,10 @@ export const validateFileType = (mimetype: string): boolean => {
return allowableContentTypes.has(mimetype);
};

export const validateImageType = (mimetype: string): boolean => {
return allowableImageContentTypes.has(mimetype);
};

export const getApiValidationError = (
fieldName: string,
type: Type,
Expand All @@ -62,6 +70,13 @@ export const getFileTypeValidationError = (mimetype: string): string => {
return `The file type ${mimetype} is not one of ${allowableContentTypesString}`;
};

export const getImageTypeValidationError = (mimetype: string): string => {
const allowableContentTypesString = [...allowableImageContentTypes].join(
", ",
);
return `The file type ${mimetype} is not one of ${allowableContentTypesString}`;
};

export const validateDate = (value: string): boolean => {
return !!Date.parse(value);
};
Expand Down Expand Up @@ -97,6 +112,15 @@ export const validateMap = (
return true;
};

export const validateImageSize = (imageSize: number): boolean => {
const imageSizeInMb = imageSize / 100000;
return imageSizeInMb <= allowableImageSize;
};

export const getImageSizeValidationError = (): string => {
return `Image size must be less than ${allowableImageSize} MB.`;
};

export const checkDuplicatesInArray = (value: Array<any>): boolean => {
return new Set(value).size !== value.length;
};
5 changes: 5 additions & 0 deletions backend/typescript/models/camp.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Camp extends Document {
fee: number;
formQuestions: (Schema.Types.ObjectId | FormQuestion)[];
campSessions: (Schema.Types.ObjectId | CampSession)[];
fileName?: string;
}

const CampSchema: Schema = new Schema({
Expand Down Expand Up @@ -60,6 +61,10 @@ const CampSchema: Schema = new Schema({
},
],
},
fileName: {
type: String,
required: false,
},
});

export default model<Camp>("Camp", CampSchema);
60 changes: 40 additions & 20 deletions backend/typescript/rest/campRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import fs from "fs";
import multer from "multer";
import { Router } from "express";
import FileStorageService from "../services/implementations/fileStorageService";
import IFileStorageService from "../services/interfaces/fileStorageService";
import ICampService from "../services/interfaces/campService";
import CampService from "../services/implementations/campService";
import { getErrorMessage } from "../utilities/errorUtils";
import { createCampDtoValidator } from "../middlewares/validators/campValidators";

const upload = multer({ dest: "uploads/" });

const campRouter: Router = Router();

const campService: ICampService = new CampService();
const defaultBucket = process.env.FIREBASE_STORAGE_DEFAULT_BUCKET || "";
const fileStorageService: IFileStorageService = new FileStorageService(
defaultBucket,
);

const campService: ICampService = new CampService(fileStorageService);
/* Get all camps */
campRouter.get("/", async (req, res) => {
try {
Expand All @@ -19,25 +29,35 @@ campRouter.get("/", async (req, res) => {
});

/* Create a camp */
campRouter.post("/", createCampDtoValidator, async (req, res) => {
try {
const newCamp = await campService.createCamp({
ageLower: req.body.ageLower,
ageUpper: req.body.ageUpper,
name: req.body.name,
description: req.body.description,
location: req.body.location,
capacity: req.body.capacity,
fee: req.body.fee,
formQuestions: req.body.formQuestions,
campSessions: req.body.campSessions,
});

res.status(201).json(newCamp);
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
});
campRouter.post(
"/",
upload.single("file"),
createCampDtoValidator,
async (req, res) => {
try {
const body = JSON.parse(req.body.data);
const newCamp = await campService.createCamp({
ageLower: body.ageLower,
ageUpper: body.ageUpper,
name: body.name,
description: body.description,
location: body.location,
capacity: body.capacity,
fee: body.fee,
formQuestions: body.formQuestions,
campSessions: body.campSessions,
filePath: req.file?.path,
fileContentType: req.file?.mimetype,
});
if (req.file?.path) {
fs.unlinkSync(req.file.path);
}
res.status(201).json(newCamp);
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
},
);

/* Returns a CSV string containing all campers within a specific camp */
campRouter.get("/csv/:id", async (req, res) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import CampService from "../campService";
import { CreateCampDTO, QuestionType } from "../../../types";
import MgCampSession from "../../../models/campSession.model";
import MgFormQuestion from "../../../models/formQuestion.model";
import FileStorageService from "../fileStorageService";
import IFileStorageService from "../../interfaces/fileStorageService";

const defaultBucket = process.env.FIREBASE_STORAGE_DEFAULT_BUCKET || "";
const fileStorageService: IFileStorageService = new FileStorageService(
defaultBucket,
);

const testCamps: CreateCampDTO[] = [
{
Expand Down Expand Up @@ -76,7 +83,7 @@ describe("mongo campService", (): void => {
});

beforeEach(async () => {
campService = new CampService();
campService = new CampService(fileStorageService);
});

afterEach(async () => {
Expand Down
20 changes: 19 additions & 1 deletion backend/typescript/services/implementations/campService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { v4 as uuidv4 } from "uuid";
import ICampService from "../interfaces/campService";
import IFileStorageService from "../interfaces/fileStorageService";
import {
CreateCampDTO,
CampDTO,
CamperCSVInfoDTO,
GetCampDTO,
} from "../../types";
import ICampService from "../interfaces/campService";
import { getErrorMessage } from "../../utilities/errorUtils";
import { generateCSV } from "../../utilities/CSVUtils";
import logger from "../../utilities/logger";
Expand All @@ -16,6 +18,12 @@ import MgCamper, { Camper } from "../../models/camper.model";
const Logger = logger(__filename);

class CampService implements ICampService {
storageService: IFileStorageService;

constructor(storageService: IFileStorageService) {
this.storageService = storageService;
}

/* eslint-disable class-methods-use-this */
async getCamps(): Promise<GetCampDTO[]> {
try {
Expand Down Expand Up @@ -153,6 +161,14 @@ class CampService implements ICampService {
let newCamp: Camp;

try {
const fileName = camp.filePath ? uuidv4() : "";
if (camp.filePath) {
await this.storageService.createFile(
fileName,
camp.filePath,
camp.fileContentType,
);
}
newCamp = new MgCamp({
name: camp.name,
ageLower: camp.ageLower,
Expand All @@ -162,6 +178,7 @@ class CampService implements ICampService {
location: camp.location,
fee: camp.fee,
formQuestions: [],
...(camp.filePath && { fileName }),
});
/* eslint no-underscore-dangle: 0 */
await Promise.all(
Expand Down Expand Up @@ -239,6 +256,7 @@ class CampService implements ICampService {
formQuestions: newCamp.formQuestions.map((formQuestion) =>
formQuestion.toString(),
),
fileName: newCamp.fileName,
};
}

Expand Down
3 changes: 3 additions & 0 deletions backend/typescript/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type CampDTO = {
fee: number;
formQuestions: string[];
campSessions: string[];
fileName?: string;
};

export type GetCampDTO = Omit<CampDTO, "campSessions" | "formQuestions"> & {
Expand All @@ -118,6 +119,8 @@ export type CreateCampDTO = Omit<
> & {
formQuestions: Omit<FormQuestionDTO, "id">[];
campSessions: Omit<CampSessionDTO, "id" | "camp" | "campers" | "waitlist">[];
filePath?: string;
fileContentType?: string;
};

export type CreateCamperDTO = Omit<CamperDTO, "id">;
Expand Down

0 comments on commit 328e9ae

Please sign in to comment.