diff --git a/api/.env.docker.example b/api/.env.docker.example index cc35f55ff..15a1ce243 100644 --- a/api/.env.docker.example +++ b/api/.env.docker.example @@ -24,6 +24,8 @@ BC_CDN_CARS=/var/www/cdn/bookcars/cars BC_CDN_TEMP_CARS=/var/www/cdn/bookcars/temp/cars BC_CDN_LOCATIONS=/var/www/cdn/bookcars/locations BC_CDN_TEMP_LOCATIONS=/var/www/cdn/bookcars/temp/locations +BC_CDN_CONTRACTS=/var/www/cdn/bookcars/contracts +BC_CDN_TEMP_CONTRACTS=/var/www/cdn/bookcars/temp/contracts BC_DEFAULT_LANGUAGE=en BC_BACKEND_HOST=http://localhost:3001/ BC_FRONTEND_HOST=http://localhost/ diff --git a/api/.env.example b/api/.env.example index 0030fa868..cd016e30d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -24,6 +24,8 @@ BC_CDN_CARS=/var/www/cdn/bookcars/cars BC_CDN_TEMP_CARS=/var/www/cdn/bookcars/temp/cars BC_CDN_LOCATIONS=/var/www/cdn/bookcars/locations BC_CDN_TEMP_LOCATIONS=/var/www/cdn/bookcars/temp/locations +BC_CDN_CONTRACTS=/var/www/cdn/bookcars/contracts +BC_CDN_TEMP_CONTRACTS=/var/www/cdn/bookcars/temp/contracts BC_DEFAULT_LANGUAGE=en BC_BACKEND_HOST=http://localhost:3001/ BC_FRONTEND_HOST=http://localhost:3002/ diff --git a/api/src/app.ts b/api/src/app.ts index 0ea5f882a..6929a7595 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -61,5 +61,7 @@ await helper.mkdir(env.CDN_CARS) await helper.mkdir(env.CDN_TEMP_CARS) await helper.mkdir(env.CDN_LOCATIONS) await helper.mkdir(env.CDN_TEMP_LOCATIONS) +await helper.mkdir(env.CDN_CONTRACTS) +await helper.mkdir(env.CDN_TEMP_CONTRACTS) export default app diff --git a/api/src/config/env.config.ts b/api/src/config/env.config.ts index f4342369b..4b9b0b45b 100644 --- a/api/src/config/env.config.ts +++ b/api/src/config/env.config.ts @@ -245,6 +245,20 @@ export const CDN_LOCATIONS = __env__('BC_CDN_LOCATIONS', true) */ export const CDN_TEMP_LOCATIONS = __env__('BC_CDN_TEMP_LOCATIONS', true) +/** + * Contracts' cdn folder path. + * + * @type {string} + */ +export const CDN_CONTRACTS = __env__('BC_CDN_CONTRACTS', true) + +/** + * Contracts' temp cdn folder path. + * + * @type {string} + */ +export const CDN_TEMP_CONTRACTS = __env__('BC_CDN_TEMP_CONTRACTS', true) + /** * Backend host. * @@ -358,6 +372,7 @@ export interface User extends Document { blacklisted?: boolean payLater?: boolean customerId?: string + contracts?: bookcarsTypes.Contract[] expireAt?: Date } diff --git a/api/src/config/supplierRoutes.config.ts b/api/src/config/supplierRoutes.config.ts index 123bfcfb5..704631db9 100644 --- a/api/src/config/supplierRoutes.config.ts +++ b/api/src/config/supplierRoutes.config.ts @@ -7,6 +7,10 @@ const routes = { getAllSuppliers: '/api/all-suppliers', getFrontendSuppliers: '/api/frontend-suppliers', getBackendSuppliers: '/api/backend-suppliers', + createContract: '/api/create-contract/:language', + updateContract: '/api/update-contract/:id/:language', + deleteContract: '/api/delete-contract/:id/:language', + deleteTempContract: '/api/delete-temp-contract/:file', } export default routes diff --git a/api/src/controllers/bookingController.ts b/api/src/controllers/bookingController.ts index c51c99602..437383de2 100644 --- a/api/src/controllers/bookingController.ts +++ b/api/src/controllers/bookingController.ts @@ -3,6 +3,7 @@ import escapeStringRegexp from 'escape-string-regexp' import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk' import { Request, Response } from 'express' import nodemailer from 'nodemailer' +import path from 'node:path' import * as bookcarsTypes from ':bookcars-types' import i18n from '../lang/i18n' import Booking from '../models/Booking' @@ -106,7 +107,7 @@ export const notify = async (driver: env.User, bookingId: string, user: env.User * @param {boolean} payLater * @returns {unknown} */ -export const confirm = async (user: env.User, booking: env.Booking, payLater: boolean) => { +export const confirm = async (user: env.User, supplier: env.User, booking: env.Booking, payLater: boolean = false) => { const { language } = user const locale = language === 'fr' ? 'fr-FR' : 'en-US' const options: Intl.DateTimeFormatOptions = { @@ -138,6 +139,14 @@ export const confirm = async (user: env.User, booking: env.Booking, payLater: bo } const dropOffLocationName = dropOffLocation.values.filter((value) => value.language === language)[0].value + let contractFile: string | null = null + if (supplier.contracts) { + contractFile = supplier.contracts.find((c) => c.language === user.language)?.file || null + if (!contractFile) { + contractFile = supplier.contracts.find((c) => c.language === 'en')?.file || null + } + } + const mailOptions: nodemailer.SendMailOptions = { from: env.SMTP_FROM, to: user.email, @@ -158,6 +167,13 @@ export const confirm = async (user: env.User, booking: env.Booking, payLater: bo

`, } + if (contractFile) { + const file = path.join(env.CDN_CONTRACTS, contractFile) + if (await helper.exists(file)) { + mailOptions.attachments = [{ path: file }] + } + } + await mailHelper.sendMail(mailOptions) return true @@ -179,7 +195,7 @@ export const checkout = async (req: Request, res: Response) => { const { driver } = body if (!body.booking) { - throw new Error('Booking missing') + throw new Error('Booking not found') } if (driver) { @@ -214,17 +230,14 @@ export const checkout = async (req: Request, res: Response) => { } if (!user) { - logger.info('Driver not found', body) - return res.sendStatus(204) + throw new Error('Driver not found') } if (!body.payLater) { const { paymentIntentId, sessionId } = body if (!paymentIntentId && !sessionId) { - const message = 'Payment intent and session missing' - logger.error(message, body) - return res.status(400).send(message) + throw new Error('paymentIntentId and sessionId not found') } body.booking.customerId = body.customerId @@ -288,43 +301,38 @@ export const checkout = async (req: Request, res: Response) => { if (booking.status === bookcarsTypes.BookingStatus.Paid && body.paymentIntentId && body.customerId) { const car = await Car.findById(booking.car) if (!car) { - logger.info(`Car ${booking.car} not found`) - return res.sendStatus(204) + throw new Error(`Car ${booking.car} not found`) } car.trips += 1 await car.save() + } - if (!await confirm(user, booking, false)) { - return res.sendStatus(400) + if (body.payLater || (booking.status === bookcarsTypes.BookingStatus.Paid && body.paymentIntentId && body.customerId)) { + const supplier = await User.findById(booking.supplier) + if (!supplier) { + throw new Error(`Supplier ${booking.supplier} not found`) } - } - if (body.payLater) { - // Send confirmation email - if (!await confirm(user, booking, body.payLater)) { + // Send confirmation email to customer + if (!await confirm(user, supplier, booking, body.payLater)) { return res.sendStatus(400) } // Notify supplier - const supplier = await User.findById(booking.supplier) - if (!supplier) { - logger.info(`Supplier ${booking.supplier} not found`) - return res.sendStatus(204) - } i18n.locale = supplier.language let message = body.payLater ? i18n.t('BOOKING_PAY_LATER_NOTIFICATION') : i18n.t('BOOKING_PAID_NOTIFICATION') - await notify(user, booking._id.toString(), supplier, message) + await notify(user, booking.id, supplier, message) // Notify admin const admin = !!env.ADMIN_EMAIL && await User.findOne({ email: env.ADMIN_EMAIL, type: bookcarsTypes.UserType.Admin }) if (admin) { i18n.locale = admin.language message = body.payLater ? i18n.t('BOOKING_PAY_LATER_NOTIFICATION') : i18n.t('BOOKING_PAID_NOTIFICATION') - await notify(user, booking._id.toString(), admin, message) + await notify(user, booking.id, admin, message) } } - return res.status(200).send({ bookingId: booking._id }) + return res.status(200).send({ bookingId: booking.id }) } catch (err) { logger.error(`[booking.checkout] ${i18n.t('ERROR')}`, err) return res.status(400).send(i18n.t('ERROR') + err) diff --git a/api/src/controllers/locationController.ts b/api/src/controllers/locationController.ts index bd1688406..4464895f0 100644 --- a/api/src/controllers/locationController.ts +++ b/api/src/controllers/locationController.ts @@ -707,12 +707,10 @@ export const deleteTempImage = async (req: Request, res: Response) => { try { const imageFile = path.join(env.CDN_TEMP_LOCATIONS, image) - if (!await helper.exists(imageFile)) { - throw new Error(`[location.deleteTempImage] temp image ${imageFile} not found`) + if (await helper.exists(imageFile)) { + await fs.unlink(imageFile) } - await fs.unlink(imageFile) - res.sendStatus(200) } catch (err) { logger.error(`[location.deleteTempImage] ${i18n.t('DB_ERROR')} ${image}`, err) diff --git a/api/src/controllers/stripeController.ts b/api/src/controllers/stripeController.ts index f9613c395..51910dac6 100644 --- a/api/src/controllers/stripeController.ts +++ b/api/src/controllers/stripeController.ts @@ -127,6 +127,11 @@ export const checkCheckoutSession = async (req: Request, res: Response) => { // (Set BookingStatus to Paid and remove expireAt TTL index) // if (session.payment_status === 'paid') { + const supplier = await User.findById(booking.supplier) + if (!supplier) { + throw new Error(`Supplier ${booking.supplier} not found`) + } + booking.expireAt = undefined booking.status = bookcarsTypes.BookingStatus.Paid await booking.save() @@ -135,32 +140,25 @@ export const checkCheckoutSession = async (req: Request, res: Response) => { // await Car.updateOne({ _id: booking.car }, { available: false }) const car = await Car.findById(booking.car) if (!car) { - logger.info(`Car ${booking.car} not found`) - return res.sendStatus(204) + throw new Error(`Car ${booking.car} not found`) } car.trips += 1 await car.save() - // Send confirmation email + // Send confirmation email to customer const user = await User.findById(booking.driver) if (!user) { - logger.info(`Driver ${booking.driver} not found`) - return res.sendStatus(204) + throw new Error(`Driver ${booking.driver} not found`) } user.expireAt = undefined await user.save() - if (!await bookingController.confirm(user, booking, false)) { + if (!await bookingController.confirm(user, supplier, booking, false)) { return res.sendStatus(400) } // Notify supplier - const supplier = await User.findById(booking.supplier) - if (!supplier) { - logger.info(`Supplier ${booking.supplier} not found`) - return res.sendStatus(204) - } i18n.locale = supplier.language let message = i18n.t('BOOKING_PAID_NOTIFICATION') await bookingController.notify(user, booking.id, supplier, message) diff --git a/api/src/controllers/supplierController.ts b/api/src/controllers/supplierController.ts index d446c21d9..7c3dd69b8 100644 --- a/api/src/controllers/supplierController.ts +++ b/api/src/controllers/supplierController.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import escapeStringRegexp from 'escape-string-regexp' import { Request, Response } from 'express' import mongoose from 'mongoose' +import { nanoid } from 'nanoid' import * as bookcarsTypes from ':bookcars-types' import i18n from '../lang/i18n' import * as env from '../config/env.config' @@ -115,23 +116,34 @@ export const deleteSupplier = async (req: Request, res: Response) => { if (await helper.exists(avatar)) { await fs.unlink(avatar) } + } - await NotificationCounter.deleteMany({ user: id }) - await Notification.deleteMany({ user: id }) - const additionalDrivers = (await Booking.find({ supplier: id, _additionalDriver: { $ne: null } }, { _id: 0, _additionalDriver: 1 })).map((b) => b._additionalDriver) - await AdditionalDriver.deleteMany({ _id: { $in: additionalDrivers } }) - await Booking.deleteMany({ supplier: id }) - const cars = await Car.find({ supplier: id }) - await Car.deleteMany({ supplier: id }) - for (const car of cars) { - if (car.image) { - const image = path.join(env.CDN_CARS, car.image) - if (await helper.exists(image)) { - await fs.unlink(image) + if (supplier.contracts) { + for (const contract of supplier.contracts) { + if (contract.file) { + const file = path.join(env.CDN_CONTRACTS, contract.file) + if (await helper.exists(file)) { + await fs.unlink(file) } } } } + + await NotificationCounter.deleteMany({ user: id }) + await Notification.deleteMany({ user: id }) + const additionalDrivers = (await Booking.find({ supplier: id, _additionalDriver: { $ne: null } }, { _id: 0, _additionalDriver: 1 })).map((b) => b._additionalDriver) + await AdditionalDriver.deleteMany({ _id: { $in: additionalDrivers } }) + await Booking.deleteMany({ supplier: id }) + const cars = await Car.find({ supplier: id }) + await Car.deleteMany({ supplier: id }) + for (const car of cars) { + if (car.image) { + const image = path.join(env.CDN_CARS, car.image) + if (await helper.exists(image)) { + await fs.unlink(image) + } + } + } } else { return res.sendStatus(204) } @@ -530,3 +542,155 @@ export const getBackendSuppliers = async (req: Request, res: Response) => { return res.status(400).send(i18n.t('DB_ERROR') + err) } } + +/** + * Upload a contract to temp folder. + * + * @export + * @async + * @param {Request} req + * @param {Response} res + * @returns {unknown} + */ +export const createContract = async (req: Request, res: Response) => { + const { language } = req.params + + try { + if (!req.file) { + throw new Error('req.file not found') + } + if (language.length !== 2) { + throw new Error('Language not valid') + } + + const filename = `${nanoid()}_${language}${path.extname(req.file.originalname)}` + const filepath = path.join(env.CDN_TEMP_CONTRACTS, filename) + + await fs.writeFile(filepath, req.file.buffer) + return res.json(filename) + } catch (err) { + logger.error(`[supplier.createContract] ${i18n.t('DB_ERROR')}`, err) + return res.status(400).send(i18n.t('ERROR') + err) + } +} + +/** + * Update a contract. + * + * @export + * @async + * @param {Request} req + * @param {Response} res + * @returns {unknown} + */ +export const updateContract = async (req: Request, res: Response) => { + const { id, language } = req.params + const { file } = req + + try { + if (!file) { + throw new Error('req.file not found') + } + if (!helper.isValidObjectId(id)) { + throw new Error('Supplier Id not valid') + } + if (language.length !== 2) { + throw new Error('Language not valid') + } + + const supplier = await User.findOne({ _id: id, type: bookcarsTypes.UserType.Supplier }) + + if (supplier) { + const contract = supplier.contracts?.find((c) => c.language === language) + if (contract?.file) { + const contractFile = path.join(env.CDN_CONTRACTS, contract.file) + if (await helper.exists(contractFile)) { + await fs.unlink(contractFile) + } + } + + const filename = `${supplier._id}_${language}${path.extname(file.originalname)}` + const filepath = path.join(env.CDN_CONTRACTS, filename) + + await fs.writeFile(filepath, file.buffer) + if (!contract) { + supplier.contracts?.push({ language, file: filename }) + } else { + contract.file = filename + } + await supplier.save() + return res.json(filename) + } + + return res.sendStatus(204) + } catch (err) { + logger.error(`[supplier.updateContract] ${i18n.t('DB_ERROR')} ${id}`, err) + return res.status(400).send(i18n.t('DB_ERROR') + err) + } +} + +/** + * Delete a contract. + * + * @export + * @async + * @param {Request} req + * @param {Response} res + * @returns {unknown} + */ +export const deleteContract = async (req: Request, res: Response) => { + const { id, language } = req.params + + try { + if (!helper.isValidObjectId(id)) { + throw new Error('Supplier Id not valid') + } + if (language.length !== 2) { + throw new Error('Language not valid') + } + const supplier = await User.findOne({ _id: id, type: bookcarsTypes.UserType.Supplier }) + + if (supplier) { + const contract = supplier.contracts?.find((c) => c.language === language) + if (contract?.file) { + const contractFile = path.join(env.CDN_CONTRACTS, contract.file) + if (await helper.exists(contractFile)) { + await fs.unlink(contractFile) + } + contract.file = null + } + + await supplier.save() + return res.sendStatus(200) + } + return res.sendStatus(204) + } catch (err) { + logger.error(`[supplier.deleteContract] ${i18n.t('DB_ERROR')} ${id}`, err) + return res.status(400).send(i18n.t('DB_ERROR') + err) + } +} + +/** + * Delete a temp contract. + * + * @export + * @async + * @param {Request} req + * @param {Response} res + * @returns {*} + */ +export const deleteTempContract = async (req: Request, res: Response) => { + const { file } = req.params + + try { + const contractFile = path.join(env.CDN_TEMP_CONTRACTS, file) + if (await helper.exists(contractFile)) { + await fs.unlink(contractFile) + } + + res.sendStatus(200) + } catch (err) { + logger.error(`[supplier.deleteTempContract] ${i18n.t('DB_ERROR')} ${file}`, err) + res.status(400).send(i18n.t('ERROR') + err) + } +} diff --git a/api/src/controllers/userController.ts b/api/src/controllers/userController.ts index f1abab242..c1e7e8a3c 100644 --- a/api/src/controllers/userController.ts +++ b/api/src/controllers/userController.ts @@ -168,9 +168,31 @@ export const create = async (req: Request, res: Response) => { body.password = passwordHash } + const { contracts } = body + body.contracts = undefined + const user = new User(body) await user.save() + const finalContracts: bookcarsTypes.Contract[] = [] + if (contracts) { + for (const contract of contracts) { + if (contract.language && contract.file) { + const tempFile = path.join(env.CDN_TEMP_CONTRACTS, contract.file) + + if (await helper.exists(tempFile)) { + const filename = `${user.id}_${contract.language}${path.extname(tempFile)}` + const newPath = path.join(env.CDN_CONTRACTS, filename) + + await fs.rename(tempFile, newPath) + finalContracts.push({ language: contract.language, file: filename }) + } + } + } + user.contracts = finalContracts + await user.save() + } + // avatar if (body.avatar) { const avatar = path.join(env.CDN_TEMP_USERS, body.avatar) @@ -1412,6 +1434,17 @@ export const deleteUsers = async (req: Request, res: Response) => { } } + if (user.contracts) { + for (const contract of user.contracts) { + if (contract.file) { + const file = path.join(env.CDN_CONTRACTS, contract.file) + if (await helper.exists(file)) { + await fs.unlink(file) + } + } + } + } + if (user.type === bookcarsTypes.UserType.Supplier) { const additionalDrivers = (await Booking.find({ supplier: id, _additionalDriver: { $ne: null } }, { _id: 0, _additionalDriver: 1 })).map((b) => b._additionalDriver) await AdditionalDriver.deleteMany({ _id: { $in: additionalDrivers } }) diff --git a/api/src/models/User.ts b/api/src/models/User.ts index 6c3dda816..b96dea0ec 100644 --- a/api/src/models/User.ts +++ b/api/src/models/User.ts @@ -103,6 +103,17 @@ const userSchema = new Schema( customerId: { type: String, }, + contracts: [{ + language: { + type: String, + required: [true, "can't be blank"], + trim: true, + lowercase: true, + minLength: 2, + maxLength: 2, + }, + file: String, + }], expireAt: { // // Non verified and active users created from checkout with Stripe are temporary and diff --git a/api/src/routes/supplierRoutes.ts b/api/src/routes/supplierRoutes.ts index bec8f11c7..13453b47c 100644 --- a/api/src/routes/supplierRoutes.ts +++ b/api/src/routes/supplierRoutes.ts @@ -1,4 +1,5 @@ import express from 'express' +import multer from 'multer' import routeNames from '../config/supplierRoutes.config' import authJwt from '../middlewares/authJwt' import * as supplierController from '../controllers/supplierController' @@ -13,5 +14,9 @@ routes.route(routeNames.getSuppliers).get(authJwt.verifyToken, supplierControlle routes.route(routeNames.getAllSuppliers).get(supplierController.getAllSuppliers) routes.route(routeNames.getFrontendSuppliers).post(supplierController.getFrontendSuppliers) routes.route(routeNames.getBackendSuppliers).post(authJwt.verifyToken, supplierController.getBackendSuppliers) +routes.route(routeNames.createContract).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single('file')], supplierController.createContract) +routes.route(routeNames.updateContract).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single('file')], supplierController.updateContract) +routes.route(routeNames.deleteContract).post(authJwt.verifyToken, supplierController.deleteContract) +routes.route(routeNames.deleteTempContract).post(authJwt.verifyToken, supplierController.deleteTempContract) export default routes diff --git a/api/tests/booking.test.ts b/api/tests/booking.test.ts index 1f8e671e5..e941e38fe 100644 --- a/api/tests/booking.test.ts +++ b/api/tests/booking.test.ts @@ -1,11 +1,15 @@ import 'dotenv/config' import request from 'supertest' +import url from 'url' +import path from 'path' +import fs from 'node:fs/promises' import { nanoid } from 'nanoid' import mongoose from 'mongoose' import * as bookcarsTypes from ':bookcars-types' import app from '../src/app' import * as databaseHelper from '../src/common/databaseHelper' import * as testHelper from './testHelper' +import * as helper from '../src/common/helper' import Car from '../src/models/Car' import Booking from '../src/models/Booking' import AdditionalDriver from '../src/models/AdditionalDriver' @@ -15,6 +19,12 @@ import PushToken from '../src/models/PushToken' import Token from '../src/models/Token' import stripeAPI from '../src/stripe' +const __filename = url.fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const CONTRACT1 = 'contract1.pdf' +const CONTRACT1_PATH = path.join(__dirname, `./contracts/${CONTRACT1}`) + const DRIVER1_NAME = 'Driver 1' let SUPPLIER_ID: string @@ -49,6 +59,15 @@ beforeAll(async () => { const supplierName = testHelper.getSupplierName() SUPPLIER_ID = await testHelper.createSupplier(`${supplierName}@test.bookcars.ma`, supplierName) + const contractFileName = `${SUPPLIER_ID}_en.pdf` + const contractFile = path.join(env.CDN_CONTRACTS, contractFileName) + if (!await helper.exists(contractFile)) { + await fs.copyFile(CONTRACT1_PATH, contractFile) + } + const supplier = await User.findById(SUPPLIER_ID) + supplier!.contracts = [{ language: 'en', file: contractFileName }] + await supplier?.save() + // create driver 1 const driver1 = new User({ fullName: DRIVER1_NAME, @@ -351,14 +370,14 @@ describe('POST /api/checkout', () => { res = await request(app) .post('/api/checkout') .send(payload) - expect(res.statusCode).toBe(204) + expect(res.statusCode).toBe(400) payload.booking!.supplier = SUPPLIER_ID payload.booking!.driver = testHelper.GetRandromObjectIdAsString() res = await request(app) .post('/api/checkout') .send(payload) - expect(res.statusCode).toBe(204) + expect(res.statusCode).toBe(400) payload.booking = undefined res = await request(app) diff --git a/api/tests/car.test.ts b/api/tests/car.test.ts index 1732fc88e..1854ca7c5 100644 --- a/api/tests/car.test.ts +++ b/api/tests/car.test.ts @@ -18,9 +18,9 @@ const __filename = url.fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const IMAGE1 = 'bmw-x1.jpg' -const IMAGE1_PATH = path.resolve(__dirname, `./img/${IMAGE1}`) +const IMAGE1_PATH = path.join(__dirname, `./img/${IMAGE1}`) const IMAGE2 = 'bmw-x5.jpg' -const IMAGE2_PATH = path.resolve(__dirname, `./img/${IMAGE2}`) +const IMAGE2_PATH = path.join(__dirname, `./img/${IMAGE2}`) let SUPPLIER1_ID: string let SUPPLIER2_ID: string @@ -319,7 +319,7 @@ describe('POST /api/create-car-image', () => { .attach('image', IMAGE1_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - const filePath = path.resolve(env.CDN_TEMP_CARS, filename) + const filePath = path.join(env.CDN_TEMP_CARS, filename) const imageExists = await helper.exists(filePath) expect(imageExists).toBeTruthy() await fs.unlink(filePath) @@ -343,7 +343,7 @@ describe('POST /api/update-car-image/:id', () => { .attach('image', IMAGE2_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - const imageExists = await helper.exists(path.resolve(env.CDN_CARS, filename)) + const imageExists = await helper.exists(path.join(env.CDN_CARS, filename)) expect(imageExists).toBeTruthy() const car = await Car.findById(CAR_ID) expect(car).not.toBeNull() diff --git a/api/tests/contracts/contract1.pdf b/api/tests/contracts/contract1.pdf new file mode 100644 index 000000000..06a366a35 Binary files /dev/null and b/api/tests/contracts/contract1.pdf differ diff --git a/api/tests/contracts/contract2.pdf b/api/tests/contracts/contract2.pdf new file mode 100644 index 000000000..06a366a35 Binary files /dev/null and b/api/tests/contracts/contract2.pdf differ diff --git a/api/tests/location.test.ts b/api/tests/location.test.ts index a767065fa..919f48f18 100644 --- a/api/tests/location.test.ts +++ b/api/tests/location.test.ts @@ -20,11 +20,11 @@ const __filename = url.fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const IMAGE0 = 'location0.jpg' -const IMAGE0_PATH = path.resolve(__dirname, `./img/${IMAGE0}`) +const IMAGE0_PATH = path.join(__dirname, `./img/${IMAGE0}`) const IMAGE1 = 'location1.jpg' -const IMAGE1_PATH = path.resolve(__dirname, `./img/${IMAGE1}`) +const IMAGE1_PATH = path.join(__dirname, `./img/${IMAGE1}`) const IMAGE2 = 'location2.jpg' -const IMAGE2_PATH = path.resolve(__dirname, `./img/${IMAGE2}`) +const IMAGE2_PATH = path.join(__dirname, `./img/${IMAGE2}`) let LOCATION_ID: string @@ -320,7 +320,7 @@ describe('POST /api/create-location-image', () => { .attach('image', IMAGE1_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - const filePath = path.resolve(env.CDN_TEMP_LOCATIONS, filename) + const filePath = path.join(env.CDN_TEMP_LOCATIONS, filename) const imageExists = await helper.exists(filePath) expect(imageExists).toBeTruthy() await fs.unlink(filePath) @@ -344,7 +344,7 @@ describe('POST /api/update-location-image/:id', () => { .attach('image', IMAGE2_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - const imageExists = await helper.exists(path.resolve(env.CDN_LOCATIONS, filename)) + const imageExists = await helper.exists(path.join(env.CDN_LOCATIONS, filename)) expect(imageExists).toBeTruthy() const location = await Location.findById(LOCATION_ID) expect(location).not.toBeNull() @@ -395,14 +395,14 @@ describe('POST /api/delete-location-image/:id', () => { expect(location).not.toBeNull() expect(location?.image).toBeDefined() const filename = location?.image as string - let imageExists = await helper.exists(path.resolve(env.CDN_LOCATIONS, filename)) + let imageExists = await helper.exists(path.join(env.CDN_LOCATIONS, filename)) expect(imageExists).toBeTruthy() let res = await request(app) .post(`/api/delete-location-image/${LOCATION_ID}`) .set(env.X_ACCESS_TOKEN, token) expect(res.statusCode).toBe(200) - imageExists = await helper.exists(path.resolve(env.CDN_LOCATIONS, filename)) + imageExists = await helper.exists(path.join(env.CDN_LOCATIONS, filename)) expect(imageExists).toBeFalsy() location = await Location.findById(LOCATION_ID) expect(location?.image).toBeNull() @@ -439,7 +439,7 @@ describe('POST /api/delete-temp-location-image/:image', () => { res = await request(app) .post('/api/delete-temp-location-image/unknown.jpg') .set(env.X_ACCESS_TOKEN, token) - expect(res.statusCode).toBe(400) + expect(res.statusCode).toBe(200) await testHelper.signout(token) }) diff --git a/api/tests/supplier.test.ts b/api/tests/supplier.test.ts index 666f679c5..f57085314 100644 --- a/api/tests/supplier.test.ts +++ b/api/tests/supplier.test.ts @@ -19,6 +19,11 @@ import AdditionalDriver from '../src/models/AdditionalDriver' const __filename = url.fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +const CONTRACT1 = 'contract1.pdf' +const CONTRACT1_PATH = path.join(__dirname, `./contracts/${CONTRACT1}`) +const CONTRACT2 = 'contract2.pdf' +const CONTRACT2_PATH = path.join(__dirname, `./contracts/${CONTRACT2}`) + const LOCATION_ID = testHelper.GetRandromObjectIdAsString() let SUPPLIER1_ID: string @@ -273,16 +278,24 @@ describe('DELETE /api/delete-supplier/:id', () => { let supplier = await User.findById(supplierId) expect(supplier).not.toBeNull() let avatarName = 'avatar1.jpg' - let avatarPath = path.resolve(__dirname, `./img/${avatarName}`) + let avatarPath = path.join(__dirname, `./img/${avatarName}`) let avatar = path.join(env.CDN_USERS, avatarName) if (!await helper.exists(avatar)) { await fs.copyFile(avatarPath, avatar) } supplier!.avatar = avatarName + + const contractFileName = `${nanoid()}.pdf` + const contractFile = path.join(env.CDN_CONTRACTS, contractFileName) + if (!await helper.exists(contractFile)) { + await fs.copyFile(CONTRACT1_PATH, contractFile) + } + supplier!.contracts = [{ language: 'en', file: contractFileName }] + await supplier?.save() let locationId = await testHelper.createLocation('Location 1 EN', 'Location 1 FR') const carImageName = 'bmw-x1.jpg' - const carImagePath = path.resolve(__dirname, `./img/${carImageName}`) + const carImagePath = path.join(__dirname, `./img/${carImageName}`) let car = new Car({ name: 'BMW X1', supplier: supplierId, @@ -344,6 +357,8 @@ describe('DELETE /api/delete-supplier/:id', () => { expect(res.statusCode).toBe(200) supplier = await User.findById(supplierId) expect(supplier).toBeNull() + expect(await helper.exists(avatar)).toBeFalsy() + expect(await helper.exists(contractFile)).toBeFalsy() await testHelper.deleteLocation(locationId) res = await request(app) @@ -413,7 +428,7 @@ describe('DELETE /api/delete-supplier/:id', () => { supplier = await User.findById(supplierId) expect(supplier).not.toBeNull() avatarName = 'avatar1.jpg' - avatarPath = path.resolve(__dirname, `./img/${avatarName}`) + avatarPath = path.join(__dirname, `./img/${avatarName}`) avatar = path.join(env.CDN_USERS, avatarName) if (!await helper.exists(avatar)) { await fs.copyFile(avatarPath, avatar) @@ -635,3 +650,184 @@ describe('POST /api/backend-suppliers', () => { await testHelper.signout(token) }) }) + +describe('POST /api/create-contract', () => { + it('should create a contract', async () => { + const token = await testHelper.signinAsAdmin() + + // test success + let res = await request(app) + .post('/api/create-contract/en') + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(200) + const filename = res.body as string + const filePath = path.join(env.CDN_TEMP_CONTRACTS, filename) + const imageExists = await helper.exists(filePath) + expect(imageExists).toBeTruthy() + await fs.unlink(filePath) + + // test failure (file not sent) + res = await request(app) + .post('/api/create-contract/en') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + // test failure (language not valid) + res = await request(app) + .post('/api/create-contract/english') + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) +}) + +describe('POST /api/update-contract/:id', () => { + it('should update a contract', async () => { + const token = await testHelper.signinAsAdmin() + + // test success (no initial contract) + let supplier = await User.findById(SUPPLIER1_ID) + let res = await request(app) + .post(`/api/update-contract/${SUPPLIER1_ID}/en`) + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(200) + let filename = res.body as string + expect(filename).toBeTruthy() + expect(await helper.exists(path.join(env.CDN_CONTRACTS, filename))).toBeTruthy() + supplier = await User.findById(SUPPLIER1_ID) + expect(supplier).toBeTruthy() + expect(supplier?.contracts?.find((c) => c.language === 'en')?.file).toBe(filename) + + // test success (initial contract) + res = await request(app) + .post(`/api/update-contract/${SUPPLIER1_ID}/en`) + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT2_PATH) + expect(res.statusCode).toBe(200) + filename = res.body as string + expect(filename).toBeTruthy() + expect(await helper.exists(path.join(env.CDN_CONTRACTS, filename))).toBeTruthy() + supplier = await User.findById(SUPPLIER1_ID) + expect(filename).toBe(supplier?.contracts?.find((c) => c.language === 'en')?.file) + + // test success (contract file does not exist) + supplier!.contracts!.find((c) => c.language === 'en')!.file = `${nanoid()}.pdf` + await supplier?.save() + res = await request(app) + .post(`/api/update-contract/${SUPPLIER1_ID}/en`) + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(200) + filename = res.body as string + expect(filename).toBeTruthy() + expect(await helper.exists(path.join(env.CDN_CONTRACTS, filename))).toBeTruthy() + supplier = await User.findById(SUPPLIER1_ID) + expect(filename).toBe(supplier?.contracts?.find((c) => c.language === 'en')?.file) + supplier!.contracts!.find((c) => c.language === 'en')!.file = filename + await supplier?.save() + + // test failure (file not sent) + res = await request(app) + .post(`/api/update-contract/${SUPPLIER1_ID}/en`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + // test failure (supplier not found) + res = await request(app) + .post(`/api/update-contract/${testHelper.GetRandromObjectIdAsString()}/en`) + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(204) + + // test failure (supplier id not valid) + res = await request(app) + .post('/api/update-contract/0/en') + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(400) + + // test failure (language not valid) + res = await request(app) + .post(`/api/update-contract/${testHelper.GetRandromObjectIdAsString()}/english`) + .set(env.X_ACCESS_TOKEN, token) + .attach('file', CONTRACT1_PATH) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) +}) + +describe('POST /api/delete-contract/:id', () => { + it('should delete a contract', async () => { + const token = await testHelper.signinAsAdmin() + + let supplier = await User.findById(SUPPLIER1_ID) + expect(supplier).toBeTruthy() + expect(supplier?.contracts?.find((c) => c.language === 'en')?.file).toBeTruthy() + const filename = supplier?.contracts?.find((c) => c.language === 'en')?.file as string + let imageExists = await helper.exists(path.join(env.CDN_CONTRACTS, filename)) + expect(imageExists).toBeTruthy() + + // test success + let res = await request(app) + .post(`/api/delete-contract/${SUPPLIER1_ID}/en`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + imageExists = await helper.exists(path.join(env.CDN_CONTRACTS, filename)) + expect(imageExists).toBeFalsy() + supplier = await User.findById(SUPPLIER1_ID) + expect(supplier?.contracts?.find((c) => c.language === 'en')?.file).toBeFalsy() + + // test failure (supplier not found) + res = await request(app) + .post(`/api/delete-contract/${testHelper.GetRandromObjectIdAsString()}/en`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(204) + + // test failure (supplier id not valid) + res = await request(app) + .post('/api/delete-contract/invalid-id/en') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + // test failure (language id not valid) + res = await request(app) + .post(`/api/delete-contract/${testHelper.GetRandromObjectIdAsString()}/english`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(400) + + await testHelper.signout(token) + }) +}) + +describe('POST /api/delete-temp-contract/:image', () => { + it('should delete a temporary contract', async () => { + const token = await testHelper.signinAsAdmin() + + // init + const tempImage = path.join(env.CDN_TEMP_CONTRACTS, CONTRACT1) + if (!await helper.exists(tempImage)) { + await fs.copyFile(CONTRACT1_PATH, tempImage) + } + + // test success (temp file exists) + let res = await request(app) + .post(`/api/delete-temp-contract/${CONTRACT1}`) + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + const tempImageExists = await helper.exists(tempImage) + expect(tempImageExists).toBeFalsy() + + // test success (temp file not found) + res = await request(app) + .post('/api/delete-temp-contract/unknown.pdf') + .set(env.X_ACCESS_TOKEN, token) + expect(res.statusCode).toBe(200) + + await testHelper.signout(token) + }) +}) diff --git a/api/tests/user.test.ts b/api/tests/user.test.ts index 0279bd72e..be8f85401 100644 --- a/api/tests/user.test.ts +++ b/api/tests/user.test.ts @@ -9,6 +9,7 @@ import * as bookcarsTypes from ':bookcars-types' import app from '../src/app' import * as databaseHelper from '../src/common/databaseHelper' import * as testHelper from './testHelper' +import * as helper from '../src/common/helper' import * as env from '../src/config/env.config' import User from '../src/models/User' import Token from '../src/models/Token' @@ -16,15 +17,17 @@ import PushToken from '../src/models/PushToken' import Car from '../src/models/Car' import Booking from '../src/models/Booking' import AdditionalDriver from '../src/models/AdditionalDriver' -import * as helper from '../src/common/helper' const __filename = url.fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const AVATAR1 = 'avatar1.jpg' -const AVATAR1_PATH = path.resolve(__dirname, `./img/${AVATAR1}`) +const AVATAR1_PATH = path.join(__dirname, `./img/${AVATAR1}`) const AVATAR2 = 'avatar2.png' -const AVATAR2_PATH = path.resolve(__dirname, `./img/${AVATAR2}`) +const AVATAR2_PATH = path.join(__dirname, `./img/${AVATAR2}`) + +const CONTRACT1 = 'contract1.pdf' +const CONTRACT1_PATH = path.join(__dirname, `./contracts/${CONTRACT1}`) let USER1_ID: string let USER2_ID: string @@ -111,6 +114,12 @@ describe('POST /api/sign-up', () => { expect(token?.token.length).toBeGreaterThan(0) await token?.deleteOne() + payload.email = 'wrong-email' + res = await request(app) + .post('/api/sign-up') + .send(payload) + expect(res.statusCode).toBe(400) + res = await request(app) .post('/api/sign-up') expect(res.statusCode).toBe(400) @@ -157,6 +166,14 @@ describe('POST /api/create-user', () => { if (!await helper.exists(tempAvatar)) { await fs.copyFile(AVATAR1_PATH, tempAvatar) } + + const contractFileName = `${nanoid()}.pdf` + const contractFile = path.join(env.CDN_TEMP_CONTRACTS, contractFileName) + if (!await helper.exists(contractFile)) { + await fs.copyFile(CONTRACT1_PATH, contractFile) + } + const contracts = [{ language: 'en', file: contractFileName }] + let payload: bookcarsTypes.CreateUserPayload = { email: USER2_EMAIL, fullName: 'user2', @@ -166,6 +183,7 @@ describe('POST /api/create-user', () => { location: 'location', bio: 'bio', avatar: AVATAR1, + contracts, } let res = await request(app) .post('/api/create-user') @@ -183,6 +201,9 @@ describe('POST /api/create-user', () => { expect(user?.phone).toBe(payload.phone) expect(user?.location).toBe(payload.location) expect(user?.bio).toBe(payload.bio) + const contractFileResult = user?.contracts?.find((c) => c.language === 'en')?.file + expect(contractFileResult).toBeTruthy() + expect(await helper.exists(path.join(env.CDN_CONTRACTS, contractFileResult!))).toBeTruthy() let userToken = await Token.findOne({ user: USER2_ID }) expect(userToken).not.toBeNull() expect(userToken?.token.length).toBeGreaterThan(0) @@ -948,7 +969,7 @@ describe('POST /api/create-avatar', () => { .attach('image', AVATAR1_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - const filePath = path.resolve(env.CDN_TEMP_USERS, filename) + const filePath = path.join(env.CDN_TEMP_USERS, filename) const avatarExists = await helper.exists(filePath) expect(avatarExists).toBeTruthy() await fs.unlink(filePath) @@ -972,7 +993,7 @@ describe('POST /api/update-avatar/:userId', () => { .attach('image', AVATAR2_PATH) expect(res.statusCode).toBe(200) const filename = res.body as string - let avatarExists = await helper.exists(path.resolve(env.CDN_USERS, filename)) + let avatarExists = await helper.exists(path.join(env.CDN_USERS, filename)) expect(avatarExists).toBeTruthy() const user = await User.findById(USER1_ID) expect(user).not.toBeNull() @@ -986,7 +1007,7 @@ describe('POST /api/update-avatar/:userId', () => { .set(env.X_ACCESS_TOKEN, token) .attach('image', AVATAR2_PATH) expect(res.statusCode).toBe(200) - avatarExists = await helper.exists(path.resolve(env.CDN_USERS, filename)) + avatarExists = await helper.exists(path.join(env.CDN_USERS, filename)) expect(avatarExists).toBeTruthy() user!.avatar = undefined @@ -996,7 +1017,7 @@ describe('POST /api/update-avatar/:userId', () => { .set(env.X_ACCESS_TOKEN, token) .attach('image', AVATAR2_PATH) expect(res.statusCode).toBe(200) - avatarExists = await helper.exists(path.resolve(env.CDN_USERS, filename)) + avatarExists = await helper.exists(path.join(env.CDN_USERS, filename)) expect(avatarExists).toBeTruthy() res = await request(app) @@ -1290,7 +1311,7 @@ describe('POST /api/delete-users', () => { const supplierId = await testHelper.createSupplier(`${supplierName}@test.bookcars.ma`, supplierName) const locationId = await testHelper.createLocation('Location 1 EN', 'Location 1 FR') const imageName = 'bmw-x1.jpg' - const imagePath = path.resolve(__dirname, `./img/${imageName}`) + const imagePath = path.join(__dirname, `./img/${imageName}`) const image = path.join(env.CDN_CARS, imageName) if (!await helper.exists(image)) { await fs.copyFile(imagePath, image) diff --git a/backend/.env.docker.example b/backend/.env.docker.example index 1743008ef..d354d3d54 100644 --- a/backend/.env.docker.example +++ b/backend/.env.docker.example @@ -10,6 +10,8 @@ VITE_BC_CDN_CARS=http://localhost/cdn/bookcars/cars VITE_BC_CDN_TEMP_CARS=http://localhost/cdn/bookcars/temp/cars VITE_BC_CDN_LOCATIONS=http://localhost/cdn/bookcars/locations VITE_BC_CDN_TEMP_LOCATIONS=http://localhost/cdn/bookcars/temp/locations +VITE_BC_CDN_CONTRACTS=http://localhost/cdn/bookcars/contracts +VITE_BC_CDN_TEMP_CONTRACTS=http://localhost/cdn/bookcars/temp/contracts VITE_BC_SUPPLIER_IMAGE_WIDTH=60 VITE_BC_SUPPLIER_IMAGE_HEIGHT=30 VITE_BC_CAR_IMAGE_WIDTH=300 diff --git a/backend/.env.example b/backend/.env.example index 3af6727e3..d4e23b5b6 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,8 @@ VITE_BC_CDN_CARS=http://localhost/cdn/bookcars/cars VITE_BC_CDN_TEMP_CARS=http://localhost/cdn/bookcars/temp/cars VITE_BC_CDN_LOCATIONS=http://localhost/cdn/bookcars/locations VITE_BC_CDN_TEMP_LOCATIONS=http://localhost/cdn/bookcars/temp/locations +VITE_BC_CDN_CONTRACTS=http://localhost/cdn/bookcars/contracts +VITE_BC_CDN_TEMP_CONTRACTS=http://localhost/cdn/bookcars/temp/contracts VITE_BC_SUPPLIER_IMAGE_WIDTH=60 VITE_BC_SUPPLIER_IMAGE_HEIGHT=30 VITE_BC_CAR_IMAGE_WIDTH=300 diff --git a/packages/bookcars-types/index.ts b/packages/bookcars-types/index.ts index 855383b71..747d03e9e 100644 --- a/packages/bookcars-types/index.ts +++ b/packages/bookcars-types/index.ts @@ -238,6 +238,8 @@ export interface SignUpPayload { birthDate?: number | Date } +export type Contract = { language: string, file: string | null } + export interface CreateUserPayload { email?: string phone: string @@ -253,6 +255,7 @@ export interface CreateUserPayload { blacklisted?: boolean payLater?: boolean supplier?: string + contracts?: Contract[] } export interface UpdateUserPayload extends CreateUserPayload {