import { CreateBooking, Booking, mapBooking, RestoreBooking } from './model'; import { Config } from '../app-config'; import { createBooking as dbCreate, BookingsModel } from '../db/bookings'; import { parse, format, parseISO } from 'date-fns'; import { getAvailableDates, dateStartOfWeek } from '../dates'; import { incrementOfficeBookingCount, decrementOfficeBookingCount, getOfficeBookings, } from '../db/officeBookings'; import { incrementUserBookingCount, decrementUserBookingCount } from '../db/userBookings'; import { Forbidden, HttpError } from '../errors'; import { User, getUser } from '../users/model'; import { SES } from 'aws-sdk'; const audit = (step: string, details?: any) => console.info( JSON.stringify({ level: 'AUDIT', action: 'CreateBooking', step, details, }) ); const sendNotificationEmail = async ( emailAddress: string, fromAddress: string, date: string, user: string, office: string, reasonToBook: string ) => { const formattedDate = format(parseISO(date), 'dd/MM/yyyy'); const params: SES.SendEmailRequest = { Destination: { ToAddresses: [emailAddress] }, Message: { Body: { Text: { Charset: 'UTF-8', Data: `Date: ${formattedDate}\nUser: ${user}\nOffice: ${office}\n\nReason for booking:\n${reasonToBook}`, }, }, Subject: { Charset: 'UTF-8', Data: `Office Booker - ${formattedDate} | ${user} | ${office}`, }, }, Source: fromAddress, }; const ses = new SES(); return await ses.sendEmail(params).promise(); }; export const createBooking = async ( config: Config, currentUser: User, request: CreateBooking | RestoreBooking ): Promise<Booking> => { const isAuthorised = request.user === currentUser.email || currentUser.permissions.canManageAllBookings || currentUser.permissions.officesCanManageBookingsFor.find( (office) => office.id === request.office.id ) !== undefined; if (!isAuthorised) { throw new Forbidden(); } const parsed = parse(request.date, 'yyyy-MM-dd', new Date()); if (Number.isNaN(parsed.getTime())) { throw new HttpError({ internalMessage: `Invalid date format: ${request.date}`, status: 400, httpMessage: 'Invalid date format', }); } if (!getAvailableDates(config).includes(request.date)) { throw new HttpError({ internalMessage: `Date out of range: ${request.date}`, status: 400, httpMessage: 'Date out of range', }); } const requestedOffice = config.officeQuotas.find((office) => office.id === request.office.id); if (!requestedOffice) { throw new HttpError({ internalMessage: `Office not found: ${request.office}`, status: 400, httpMessage: 'Office not found', }); } const configureSendNotification = async () => { const { fromAddress, notificationToAddress, reasonToBookRequired } = config; if (!notificationToAddress) { return async () => {}; } //get the user from db const dbUser = await getUser(config, request.user.toLocaleLowerCase()); if (dbUser.autoApproved) { return async () => {}; } if (fromAddress === undefined) { throw Error(`Missing required env parameters for reason notifications: FROM_ADDRESS`); } if (reasonToBookRequired && !request.reasonToBook) { throw new HttpError({ httpMessage: `Invalid reason to book given`, status: 400, internalMessage: `No reason to book provided`, }); } const reasonToBook = request.reasonToBook ? request.reasonToBook : 'No reason provided'; return async () => { if (config.env !== 'test') { await sendNotificationEmail( notificationToAddress, fromAddress, request.date, request.user, requestedOffice.name, reasonToBook ); } }; }; const sendNotificationIfRequired = await configureSendNotification(); // Id date as a direct string const id = requestedOffice.id + '_' + request.date.replace(/-/gi, ''); const newBooking = <BookingsModel>{ id, parking: request.parking ?? false, officeId: requestedOffice.id, date: request.date, user: request.user, ...('created' in request ? { id: request.id, created: request.created } : {}), }; const userEmail = newBooking.user.toLocaleLowerCase(); const startOfWeek = dateStartOfWeek(newBooking.date); const officeBookings = await getOfficeBookings(config, requestedOffice.name, [newBooking.date]); const isQuotaExceeded = officeBookings[0]?.bookingCount >= requestedOffice.quota; const isParkingExceeded = newBooking.parking && officeBookings[0]?.parkingCount >= requestedOffice.parkingQuota; if (isQuotaExceeded || isParkingExceeded) { const whichExceeded = isQuotaExceeded && isParkingExceeded ? 'Office and parking quota' : isQuotaExceeded ? 'Office quota' : 'Office parking quota'; throw new HttpError({ internalMessage: `${whichExceeded} has exceeded for ${requestedOffice.name} on date: ${newBooking.date}`, status: 409, httpMessage: `${whichExceeded} exceeded`, }); } audit('1:IncrementingOfficeBookingCount', { newBooking, startOfWeek, currentUser }); const officeBookedSuccessfully = await incrementOfficeBookingCount( config, requestedOffice, newBooking.date, newBooking.parking ); if (!officeBookedSuccessfully) { const parkingInternalMessageAddition = newBooking.parking ? ` or parking quota of ${requestedOffice.parkingQuota}` : ''; const parkingMessageAddition = newBooking.parking ? ` or parking quota` : ''; throw new HttpError({ internalMessage: `Office quota of ${requestedOffice.quota}${parkingInternalMessageAddition} has exceeded for ${requestedOffice.name} on date: ${newBooking.date}`, status: 409, httpMessage: `Office quota${parkingMessageAddition} exceeded`, }); } audit('2:IncrementingUserBookingCount'); const dbUser = await getUser(config, userEmail); const userBookedSuccessfully = await incrementUserBookingCount( config, userEmail, dbUser.quota, startOfWeek ); if (!userBookedSuccessfully) { audit('2.1:DecrementingOfficeBookingCount'); await decrementOfficeBookingCount( config, requestedOffice.id, newBooking.date, newBooking.parking ); throw new HttpError({ internalMessage: `User quota of ${dbUser.quota} has exceeded for ${userEmail} on date: ${newBooking.date}`, status: 409, httpMessage: 'User quota exceeded', }); } audit('3:CreatingBooking'); const createdBooking = await dbCreate(config, newBooking); if (createdBooking === undefined) { try { audit('3.1:DecremetingUserBookingCount'); await decrementUserBookingCount(config, newBooking.user, startOfWeek); audit('3.1:DecremetingOfficeBookingCount'); await decrementOfficeBookingCount( config, requestedOffice.id, newBooking.date, newBooking.parking ); throw new HttpError({ internalMessage: `Duplicate booking found for ${userEmail} on date: ${newBooking.date}`, status: 409, httpMessage: `Can't have multiple bookings per day`, }); } catch (err) { if (err instanceof HttpError) { throw err; } throw new HttpError({ internalMessage: `Failed while rollowing back duplicate booking found for ${userEmail} on date: ${newBooking.date}\n${err.message}`, level: 'ERROR', status: 409, httpMessage: `Can't have multiple bookings per day`, }); } } audit('4:Completed'); await sendNotificationIfRequired(); return mapBooking(config, createdBooking); };