src/handlers/laundry.js
// @flow
import LaundryModel from '../db/models/laundry'
import type { LaundryRules } from '../db/models/laundry'
import { Handler, HandlerLibrary } from './handler'
import UserHandler from './user'
import type { MachineType } from '../db/models/machine'
import MachineHandler from './machine'
import BookingHandler from './booking'
import LaundryInvitationHandler from './laundry_invitation'
import * as error from '../utils/error'
import Debug from 'debug'
import uuid from 'uuid'
import config from 'config'
import type { ObjectId } from 'mongoose'
import moment from 'moment-timezone'
import { generateBase64UrlSafeCode, hashPassword, comparePassword } from '../utils/password'
import GoogleMapsClient from '@google/maps'
import type { EventOption as CalEvent } from 'ical-generator'
import type { Laundry as RestLaundry } from 'laundree-sdk/lib/sdk'
import type { Laundry as ReduxLaundry } from 'laundree-sdk/lib/redux'
const googleMapsClient = GoogleMapsClient.createClient({key: config.get('google.serverApiKey')})
const debug = Debug('laundree.handlers.laundry')
function objToMintues ({hour, minute}) {
return hour * 60 + minute
}
export type DateTimeObject = { year: number, month: number, day: number, hour: number, minute: number }
export type DateObject = { year: number, month: number, day: number }
class LaundryHandlerLibrary extends HandlerLibrary<ReduxLaundry, LaundryModel, RestLaundry, *> {
constructor () {
super(LaundryHandler, LaundryModel, {
create: obj => typeof obj === 'string' ? null : {type: 'CREATE_LAUNDRY', payload: obj.reduxModel()},
update: obj => typeof obj === 'string' ? null : {type: 'UPDATE_LAUNDRY', payload: obj.reduxModel()},
delete: obj => typeof obj !== 'string' ? null : {type: 'DELETE_LAUNDRY', payload: obj}
})
}
async createLaundry (owner: UserHandler, name: string, demo: boolean = false, timeZone: string = '', googlePlaceId: string = '') {
const model = await new LaundryModel({
name,
owners: [owner.model._id],
users: [owner.model._id],
timezone: timeZone,
googlePlaceId,
demo
}).save()
const laundry = new LaundryHandler(model)
this.emitEvent('create', laundry)
await owner._addLaundry(laundry)
return laundry
}
async createDemoLaundry (owner: UserHandler) {
const name = `Demo Laundry ${uuid.v4()}`
const laundry = await this.createLaundry(owner, name, true)
const machines = [
{name: 'Washer', type: 'wash'},
{
name: 'Dryer',
type: 'dry'
}]
for (const machine of machines) {
await laundry.createMachine(machine.name, machine.type)
}
return laundry
}
timeZoneFromGooglePlaceId (placeId: string) {
return new Promise((resolve, reject) => {
googleMapsClient.reverseGeocode({place_id: placeId}, (err, response) => {
if (err) {
return err.status === 400 ? resolve('') : reject(err)
}
if (response.status !== 200) return resolve('')
const {json: {results: [result]}} = response
if (!result) return resolve('')
const {geometry: {location}} = result
if (!location) return resolve('')
googleMapsClient.timezone({location, timestamp: new Date()}, (err, response) => {
if (err) return reject(err)
if (response.status !== 200) return resolve('')
resolve(response.json.timeZoneId)
})
})
})
}
}
const restUrlPrefix = `${config.get('api.base')}/laundries/`
export default class LaundryHandler extends Handler<LaundryModel, ReduxLaundry, RestLaundry> {
static restSummary (i: ObjectId | LaundryHandler) {
const id = Handler.handlerOrObjectIdToString(i)
return {id, href: restUrlPrefix + id}
}
static lib = new LaundryHandlerLibrary()
lib = LaundryHandler.lib
restUrl = restUrlPrefix + this.model.id
/**
* Delete the Laundry
* @return {Promise.<LaundryHandler>}
*/
async deleteLaundry () {
const machines = await this.fetchMachines()
await Promise.all(machines.map((machine) => machine._deleteMachine()))
const invites = await this.fetchInvites()
await Promise.all(invites.map((invite) => invite._deleteInvite()))
const users = await this.fetchUsers()
await Promise.all(users.map((user) => user._removeLaundry(this)))
await this.model.remove()
this.lib.emitEvent('delete', this)
return this
}
/**
* Eventually returns true iff the given user is a user of the laundry
* @param {UserHandler} user
* @return {boolean}
*/
isUser (user: UserHandler) {
return this.model.users.find((owner) => user.model._id.equals(owner))
}
/**
* Eventually returns true iff the given user is a owner of the laundry
* @param {UserHandler} user
* @return {boolean}
*/
isOwner (user: UserHandler) {
return this.model.owners.find((owner) => user.model._id.equals(owner))
}
/**
* Create a new machine with given name
* @param {string} name
* @param {string} type
* @param {boolean} broken
* @return {Promise.<MachineHandler>}
*/
async createMachine (name: string, type: MachineType, broken: boolean) {
const machine = await MachineHandler.lib._createMachine(this, name, type, broken)
this.model.machines.push(machine.model._id)
await this.save()
return machine
}
/**
* Create a new booking relative to the timezone of the laundry
* @param {MachineHandler} machine
* @param {UserHandler} owner
* @param {{year: int, month: int, day: int, hour: int, minute: int}} from
* @param {{year: int, month: int, day: int, hour: int, minute: int}} to
*/
createBooking (machine: MachineHandler, owner: UserHandler, from: DateTimeObject, to: DateTimeObject) {
const fromDate = this.dateFromObject(from)
const toDate = this.dateFromObject(to)
return machine.createBooking(owner, fromDate, toDate)
}
/**
* Creates an date from a object (relative to the timezone of the laundry).
* @param {{year: int, month: int, day: int, hour: int=, minute: int=}} object
* @return {Date}
*/
dateFromObject (object: DateObject | DateTimeObject): Date {
const mom = moment.tz(object, this.timezone())
const date = mom.toDate()
if (isNaN(date.getTime())) {
throw new Error('Invalid date object')
}
return date
}
/**
* @param {{year: int, month: int, day: int, hour: int, minute: int}} object
* @returns {boolean}
*/
validateDateObject (object: DateTimeObject) {
const mom = moment.tz(object, this.timezone())
return mom.isValid()
}
_objectToMoment (object: DateTimeObject) {
return moment.tz(object, this.timezone())
}
/**
* Creates an object from given date (relative to the timezone of the laundry).
* @param {Date} d
* @returns {{year: int, month: int, day: int, hour: int, minute: int}}
*/
dateToObject (d: Date) {
const mom = moment(d).tz(this.timezone())
return {year: mom.year(), month: mom.month(), day: mom.date(), hour: mom.hours(), minute: mom.minutes()}
}
/**
* Delete the given machine.
* @param {MachineHandler} machine
* @return {Promise}
*/
async deleteMachine (machine: MachineHandler) {
await machine._deleteMachine()
this.model.machines.pull(machine.model._id)
return this.save()
}
/**
* Delete the given invite
* @param {LaundryInvitationHandler} invite
* @return {Promise}
*/
async deleteInvite (invite: LaundryInvitationHandler) {
await invite._deleteInvite()
this.model.invites.pull(invite.model._id)
return this.save()
}
/**
* Fetch machines
* @returns {Promise.<MachineHandler[]>}
*/
fetchMachines (): Promise<MachineHandler[]> {
return MachineHandler.lib.find({_id: this.model.machines})
}
/**
* @returns {Promise.<LaundryInvitationHandler[]>}
*/
fetchInvites (): Promise<LaundryInvitationHandler[]> {
return LaundryInvitationHandler.lib.find({_id: this.model.invites})
}
/**
* @returns {Promise.<UserHandler[]>}
*/
fetchUsers (): Promise<UserHandler[]> {
return UserHandler.lib.find({_id: this.model.users})
}
fetchOwners (): Promise<UserHandler[]> {
return UserHandler.lib.find({_id: this.model.owners})
}
/**
* Add a user
* @param {UserHandler} user
* @return {Promise.<int>} The number of new users added
*/
async addUser (user: UserHandler) {
if (this.isUser(user) || user.model.demo) return 0
this.model.users.push(user.model._id)
await this.save()
await user._addLaundry(this)
return 1
}
/**
* Add a owner to this laundry
* @param {UserHandler} user
* @return {Promise.<int>} The number of owners added
*/
async addOwner (user: UserHandler) {
await this.addUser(user)
if (this.isOwner(user)) {
return 0
}
this.model.owners.push(user.model._id)
await this.save()
return 1
}
/**
* Will remove the given user from owner-list
* @param user
* @return {Promise}
*/
removeOwner (user: UserHandler) {
this.model.owners.pull(user.model._id)
return this.save()
}
/**
* Will remove given user from laundry. Both as user or potential owner.
* @param {UserHandler} user
* @return {Promise.<LaundryHandler>}
*/
async removeUser (user: UserHandler) {
this.model.users.pull(user.model._id)
this.model.owners.pull(user.model._id)
await this.save()
this._deleteBookings(user).catch(error.logError)
await user._removeLaundry(this)
return this
}
_deleteBookings (user: UserHandler) {
return BookingHandler.lib.deleteBookings({
owner: user.model._id,
laundry: this.model._id
})
}
updateLaundry ({name, timezone, rules, googlePlaceId}: { name?: string, timezone?: string, rules?: LaundryRules, googlePlaceId?: string }) {
debug('Updating laundry')
if (name) this.model.name = name
if (timezone) this.model.timezone = timezone
if (googlePlaceId) this.model.googlePlaceId = googlePlaceId
if (rules) this.model.rules = rules
return this.save()
}
/**
* Fetch bookings for laundry.
* Finds any booking with start before to and end after from
* @param {{year: int, month: int, day: int}} from
* @param {{year: int, month: int, day: int}} to
* @return {BookingHandler[]}
*/
fetchBookings (from: DateObject | DateTimeObject, to: DateObject | DateTimeObject) {
return BookingHandler.lib._fetchBookings(
this.dateFromObject(from),
this.dateFromObject(to),
this.model.machines)
}
/**
* Invite a user by email address.
* Returns an object containing either:
* * The user if a user exists and isn't invited
* * The invite if an invite hasn't already been sent
* * Neither
* @param {string} email
* @return {Promise.<{user: UserHandler=, invite: LaundryInvitationHandler=}>}
*/
async inviteUserByEmail (email: string): Promise<{ user?: UserHandler, invite?: LaundryInvitationHandler }> {
const user = await UserHandler.lib.findFromEmail(email)
if (user) {
const num = await this.addUser(user)
return num ? {user} : {}
}
const [i] = await LaundryInvitationHandler.lib.find({email: email.toLowerCase(), laundry: this.model._id})
if (i) {
await i.markUnused()
return {}
}
const invite = await this.createInvitation(email)
return {invite}
}
async createInvitation (email: string) {
const invite = await LaundryInvitationHandler
.lib
._createInvitation(this, email)
this.model.invites.push(invite.model._id)
await this.save()
return invite
}
toRest (): RestLaundry {
return {
name: this.model.name,
id: this.model.id,
href: this.restUrl,
owners: this.model.owners.map(UserHandler.restSummary),
users: this.model.users.map(UserHandler.restSummary),
machines: this.model.machines.map(MachineHandler.restSummary),
invites: this.model.invites.map(i => this.model.owners.map(LaundryInvitationHandler.restSummary)),
timezone: this.timezone(),
googlePlaceId: this.googlePlaceId(),
demo: this.model.demo,
rules: this.rules()
}
}
timezone () {
return this.model.timezone || config.get('timezone')
}
googlePlaceId () {
return this.model.googlePlaceId || config.get('googlePlaceId')
}
isDemo (): boolean {
return this.model.demo
}
rules (): LaundryRules {
const obj = this.model.rules.toObject()
if (
!obj || !obj.timeLimit ||
Object.keys(obj.timeLimit.from).length === 0 ||
Object.keys(obj.timeLimit.to).length === 0
) {
delete obj.timeLimit
}
return obj
}
/**
* Check given times against time-limit
* @param {{year: int, month: int, day: int, hour: int, minute: int}} from
* @param {{year: int, month: int, day: int, hour: int, minute: int}} to
* @returns {boolean}
*/
checkTimeLimit (from: DateTimeObject, to: DateTimeObject) {
const rules = this.rules()
if (!rules.timeLimit) {
return true
}
const {
from: currentFrom,
to: currentTo
} = rules.timeLimit
return objToMintues(from) >= objToMintues(currentFrom) && objToMintues(to) <= objToMintues(currentTo)
}
/**
* Check given times against daily limit
* @param {UserHandler} owner
* @param {{year: int, month: int, day: int, hour: int, minute: int}} from
* @param {{year: int, month: int, day: int, hour: int, minute: int}} to
* @returns {boolean}
*/
async checkDailyLimit (owner: UserHandler, from: DateTimeObject, to: DateTimeObject) {
if (this.model.rules.dailyLimit === undefined) {
return true
}
const {day, month, year} = from
const bookings = await BookingHandler.lib.find({
laundry: this.model._id,
owner: owner.model._id,
from: {$lt: this._objectToMoment({day, month, year, hour: 0, minute: 0}).add(1, 'day').toDate()},
to: {$gt: this._objectToMoment({day, month, year, hour: 0, minute: 0}).toDate()}
})
const sum = this._countBookingTimes(bookings, objToMintues(to) - objToMintues(from))
return sum <= this.model.rules.dailyLimit * 60
}
/**
* Check given times against limit
* @param {UserHandler} owner
* @param {{year: int, month: int, day: int, hour: int, minute: int}} from
* @param {{year: int, month: int, day: int, hour: int, minute: int}} to
* @returns {boolean}
*/
async checkLimit (owner: UserHandler, from: DateTimeObject, to: DateTimeObject) {
if (this.model.rules.limit === undefined) {
return true
}
const bookings = await BookingHandler
.lib
.find({
laundry: this.model._id,
owner: owner.model._id,
from: {$gt: new Date()}
})
const sum = this._countBookingTimes(bookings, objToMintues(to) - objToMintues(from))
return sum <= this.model.rules.limit * 60
}
_countBookingTimes (bookings: BookingHandler[], offset: number = 0) {
return bookings
.map(({model: {from, to}}) => ({
from: this.dateToObject(from),
to: this.dateToObject(to)
}))
.reduce((sum, {from, to}) => sum + objToMintues(to) - objToMintues(from), offset)
}
/**
* Check if given times are the same day wrt. the timezone of the laundry
* @param d1
* @param d2
* @returns {boolean}
*/
isSameDay (d1: DateTimeObject, d2: DateTimeObject) {
return moment.tz(d1, this.timezone()).format('YYYY-MM-DD') === moment.tz(d2, this.timezone()).format('YYYY-MM-DD')
}
/**
* Create new sign-up code in base64 url-safe format
* @returns {Promise.<string>}
*/
createInviteCode () {
return generateBase64UrlSafeCode(16)
.then(code => hashPassword(code)
.then(hash => {
this.model.signUpCodes.push(hash)
return this.save().then(() => code)
}))
}
/**
* @param {string} code
* @returns {Promise.<bool>}
*/
async verifyInviteCode (code: string) {
const results = await Promise.all(this.model.signUpCodes.map(hash => comparePassword(code, hash)))
return Boolean(results.find(v => v))
}
/**
* Lists bookings as events
* @returns {Promise.<{start: Date, end: Date, uid: string, timestamp: Date, url: string, summary: string}[]>}
*/
generateEvents (): Promise<CalEvent[]> {
return this
.fetchMachines()
.then(machines => Promise.all(machines.map(m => m.generateEvents())))
.then(events => events.reduce((m1, m2) => m1.concat(m2), []))
.then(events => events.map(({start, end, uid, timestamp, summary}) => ({
start,
end,
uid,
timestamp,
summary,
url: `${config.get('web.protocol')}://${config.get('web.host')}/laundries/${this.model.id}/timetable?offsetDate=${moment.tz(start, this.timezone()).format('YYYY-MM-DD')}`
})))
}
reduxModel () {
return {
id: this.model.id,
name: this.model.name,
machines: this.model.machines.map(id => id.toString()),
users: this.model.users.map(id => id.toString()),
owners: this.model.owners.map(id => id.toString()),
invites: this.model.invites.map(id => id.toString()),
timezone: this.timezone(),
googlePlaceId: this.googlePlaceId(),
demo: this.model.demo,
rules: this.rules()
}
}
}