UNPKG

@reshuffle/monday-redis-service

Version:
652 lines (585 loc) 19.4 kB
import { RedisConnector } from 'reshuffle-redis-connector' import { MondayConnector } from 'reshuffle-monday-connector' import { BaseConnector, Reshuffle } from 'reshuffle-base-connector' import { Cipher } from './Cipher' type MondayItem = Record<string, any> type MondayBoardItems = Record<string, MondayItem> const MONDAY_SYNC_TIMER_MS = parseInt( process.env.MONDAY_SYNC_TIMER_MS || '5000', 10, ) const UNDEFINED_NULL_PLACEHOLDER = 'UNDEFINED_OR_NULL' interface Options { boardName: string monday: MondayConnector redis: RedisConnector encryptionKey?: string encryptedColumnList?: string[] apiUserIdsToIgnoreOnNewEvent?: string[] } export class MondayRedisService extends BaseConnector { public readonly boardName: string public readonly monday: MondayConnector public readonly redis: RedisConnector private boardId?: number private readonly keyBase: string private readonly namesKey: string private readonly changesKey: string private readonly cipher?: Cipher private encryptedColumns: Record<string, boolean> = {} private destageIntervalID: NodeJS.Timer | null = null private mondaySyncIntervalID: NodeJS.Timer | null = null constructor(app: Reshuffle, options: Options, id?: string) { super(app, options, id) this.boardName = MondayRedisService.validateBoardName(options.boardName) this.keyBase = `MondayBoard/${encodeURIComponent(this.boardName)}` this.namesKey = `${this.keyBase}/names` this.changesKey = `${this.keyBase}/changes` if ( typeof options.monday !== 'object' || options.monday.constructor.name !== 'MondayConnector' ) { throw new Error(`Not a monday connector: ${options.monday}`) } this.monday = options.monday if ( typeof options.redis !== 'object' || options.redis.constructor.name !== 'RedisConnector' ) { throw new Error(`Not a redis connector: ${options.redis}`) } this.redis = options.redis if ( options.encryptionKey !== undefined && options.encryptedColumnList !== undefined ) { if (!Array.isArray(options.encryptedColumnList)) { throw new Error(`Invalid column list: ${options.encryptedColumnList}`) } for (const title of options.encryptedColumnList) { if (typeof title !== 'string' || title.trim().length === 0) { throw new Error(`Invalid column list: ${options.encryptedColumnList}`) } this.encryptedColumns[title] = true } this.cipher = new Cipher(options.encryptionKey) } this.resetDestageInterval() this.resetMondaySync() } private resetDestageInterval() { if (this.destageIntervalID) { clearInterval(this.destageIntervalID) } this.destageIntervalID = setInterval( async () => await this.destageChanges(), MONDAY_SYNC_TIMER_MS, ) } private resetMondaySync() { if (this.mondaySyncIntervalID) { clearInterval(this.mondaySyncIntervalID) } this.mondaySyncIntervalID = setInterval( async () => await this.syncBoardItemAdditionDeletion(), MONDAY_SYNC_TIMER_MS * 5, ) } private getChangeKey(itemId: string, title: string) { return `${itemId}:${encodeURIComponent(title)}` } private async syncBoardItemAdditionDeletion() { if (this.boardId) { const mondayItemIds = await this.monday.getBoardItemIds(this.boardId) const redisItemIds = await this.getBoardItemIds() const createdItems = mondayItemIds.filter( (id) => !redisItemIds.includes(id), ) const deletedItems = redisItemIds.filter( (id) => !mondayItemIds.includes(id), ) for (const deletedItemId of deletedItems) { const existInMonday = mondayItemIds.includes(deletedItemId) if (!existInMonday) { this.app .getLogger() .info( `Item ${deletedItemId} removed from board ${this.boardName}, syncing Redis cache`, ) await this.deleteItemInRedis(deletedItemId) } } for (const createdItemId of createdItems) { const exist = await this.redis.hexists( this.keyForItem(createdItemId), 'id', ) if (!exist) { this.app .getLogger() .info( `New item ${createdItemId} detected in board ${this.boardName}, syncing Redis cache`, ) const res = await this.monday.getItem(parseInt(createdItemId, 10)) const mondayItem = res && res.items && res.items.length && res.items[0] if (mondayItem) { await this.createItemInRedis({ id: createdItemId, ...mondayItem }) } } } } } private async destageChanges(skipChangeKey?: string) { const changes = await this.redis.getset(this.changesKey, '') if (changes) { await Promise.all( (changes as string) .split(' ') .slice(1) .sort() .filter((e, i, a) => i === a.indexOf(e)) // unique .filter((e) => e !== skipChangeKey) // Skip skipChangeKey .map((change) => { const [itemId, title] = change.split(':') return this.destageOneChange(itemId, decodeURIComponent(title)) }), ) } } private async destageOneChange(itemId: string, title: string) { const serial = await this.redis.hget(this.keyForItem(itemId), title) const value = this.deserialize(serial, title) if (title === 'name') { await this.monday.updateItemName(await this.getBoardId(), itemId, value) } else { await this.monday.updateColumnValues(await this.getBoardId(), itemId, { [title]: () => value, }) } } // Helpers //////////////////////////////////////////////////////// private keyForItem(itemId?: string) { // If the schema changes, you'll new to review the getBoardItemIds implementation return `${this.keyBase}/item/${itemId || '*'}` } private async getBoardItemIds(): Promise<string[]> { const keys = await this.redis.keys(this.keyForItem()) return keys.map((key) => key.substr(key.lastIndexOf('/') + 1)) } private serialize(value: any, title: string) { if (value === null || value === undefined) { return UNDEFINED_NULL_PLACEHOLDER } const json = JSON.stringify(value) return this.cipher && this.encryptedColumns[title] ? this.cipher.encrypt(json) : json } private deserialize(str: string | undefined, title: string) { if (!str || str === UNDEFINED_NULL_PLACEHOLDER) { return undefined } const json = this.cipher && this.encryptedColumns[title] ? this.cipher.decrypt(str) : str return JSON.parse(json) } private async getMondayBoardItems(): Promise<MondayBoardItems> { let board = await this.monday.getBoardItems(await this.getBoardId()) if (!board || board.name !== this.boardName) { this.boardId = undefined board = await this.monday.getBoardItems(await this.getBoardId()) } if (!board || board.name !== this.boardName) { throw new Error(`Unable to read Monday board: ${this.boardName}`) } return board.items } private async createItemInRedis(item: MondayItem): Promise<void> { await Promise.all([ ...Object.keys(item).map((title) => this.redis.hset( this.keyForItem(item.id), title, this.serialize(item[title], title), ), ), this.redis.hset(this.namesKey, item.name, item.id), ]) } private async deleteItemInRedis(itemId: string): Promise<void> { const item = await this.getBoardItemById(itemId) if (item) { await Promise.all([ this.redis.del(this.keyForItem(item.id)), this.redis.hdel(this.namesKey, item.name), ]) } } private async updateColumnValue( itemId: string, title: string, update: Promise<any>, ) { const change = this.getChangeKey(itemId, title) return Promise.all([ update, this.redis.append(this.changesKey, ' ' + change), ]) } // Initialize the Redis mirror for this board. public async initialize(): Promise<MondayRedisService> { this.app.getLogger().info(`Initializing ${this.boardName} board`) const initialized = await this.redis.setnx(this.changesKey, '') if (initialized === 1) { this.app.getLogger().info(`Populating Redis mirror for ${this.boardName}`) const items = await this.getMondayBoardItems() await Promise.all( Object.values(items).map((item) => this.createItemInRedis(item)), ) } this.boardId = await this.getBoardId() this.monday.on( { type: 'ChangeColumnValue', boardId: this.boardId, }, async (event) => { const { boardId, itemId, columnTitle, columnType, value, userId, } = event if ( this.configOptions.apiUserIdsToIgnoreOnNewEvent && this.configOptions.apiUserIdsToIgnoreOnNewEvent.includes(userId) ) { this.app .getLogger() .info( `Monday event received - skip changes from user: ${userId} (listed in configOptions.apiUserIdsToIgnoreOnNewEvent)`, ) return } let newValue switch (columnType) { case 'location': const res = await this.monday.getItem(parseInt(itemId, 10)) const mondayItem = res && res.items && res.items.length && res.items[0] newValue = mondayItem?.Location break case 'color': newValue = value.label break case 'board-relation': newValue = { linkedPulseIds: value.linkedPulseIds } break case 'text': case 'numeric': newValue = value ? value.value : value break case 'dropdown': newValue = value ? value.chosenValues : value break default: newValue = value } if (this.boardId === parseInt(boardId, 10)) { this.app .getLogger() .info( `Monday event received - Update Redis cache with ${JSON.stringify( newValue, )} for itemId ${itemId} (columnTitle: ${columnTitle})`, ) await this.redis.hset( this.keyForItem(itemId), columnTitle, this.serialize(newValue, columnTitle), ) this.resetMondaySync() await this.destageChanges(this.getChangeKey(itemId, columnTitle)) } }, ) this.monday.on( { type: 'UpdateName', boardId: this.boardId, }, async (event) => { const { boardId, itemId, previousValue, value, userId } = event if ( this.configOptions.apiUserIdsToIgnoreOnNewEvent && this.configOptions.apiUserIdsToIgnoreOnNewEvent.includes(userId) ) { this.app .getLogger() .info( `Monday event received - skip changes from user: ${userId} (listed in configOptions.apiUserIdsToIgnoreOnNewEvent)`, ) return } if ( this.boardId === parseInt(boardId, 10) && typeof value.name === 'string' ) { this.app .getLogger() .info( `Monday event received - Update item name in Redis cache (itemId: ${itemId}, previousName: ${previousValue.name}, itemName: ${value.name})`, ) await this.setItemName(itemId, value.name) } }, ) this.monday.on( { type: 'CreateItem', boardId: this.boardId, }, async (event) => { const { boardId, itemId, itemName, userId } = event if ( this.configOptions.apiUserIdsToIgnoreOnNewEvent && this.configOptions.apiUserIdsToIgnoreOnNewEvent.includes(userId) ) { this.app .getLogger() .info( `Monday event received - skip changes from user: ${userId} (listed in configOptions.apiUserIdsToIgnoreOnNewEvent)`, ) return } if (this.boardId === parseInt(boardId, 10)) { this.app .getLogger() .info( `Monday event received - Create new item in Redis cache (itemId: ${itemId}, itemName: ${itemName})`, ) const response = await this.monday.getItem(parseInt(itemId, 10)) if (response && response.items && response.items[0]) { this.resetMondaySync() await this.createItemInRedis({ id: itemId, ...response.items[0] }) } } }, ) return this } // Create a new item in Monday and the Redis mirror. // // @param name item name // @param columnValues values by column titles // // @return item object // public async createItem( name: string, columnValues: Record<string, any>, ): Promise<MondayItem> { const boardId = await this.getBoardId() const columnUpdaters: Record<string, () => any> = {} for (const [title, value] of Object.entries(columnValues)) { columnUpdaters[title] = () => value } const itemId = await this.monday.createItem(boardId, name, columnUpdaters) const item = { id: itemId, name, ...columnValues } await this.createItemInRedis(item) return item } // Get the id of this board. // // @return board id // public async getBoardId(): Promise<number> { if (!this.boardId) { const BoardIdInRedis = await this.redis.get(`boards/${this.boardName}`) if (BoardIdInRedis) { this.boardId = parseInt(BoardIdInRedis, 10) } else { this.boardId = await this.monday.getBoardIdByName(this.boardName) if (this.boardId) { await this.redis.set(`boards/${this.boardName}`, this.boardId) } } if (!this.boardId) { throw new Error(`Monday board not found: ${this.boardName}`) } } return this.boardId } // Get all items. // // // @return an array of objects with the item's id, name and column // value (undefined if not found) // public async getBoardItems(): Promise<MondayItem[]> { const ids = await this.getBoardItemIds() const items: MondayItem[] = [] if (ids) { for (const id of ids) { const item = await this.getBoardItemById(id) item && items.push(item) } } return items } // Get an item with the specified id from this board. // // @param itemId item id // // @return an object with the item's id, name and column // value (undefined if not found) // public async getBoardItemById( itemId: string, ): Promise<MondayItem | undefined> { MondayRedisService.validateId(itemId) const raw = await this.redis.hgetall(this.keyForItem(itemId)) if (raw) { const item: any = {} for (const title of Object.keys(raw)) { item[title] = this.deserialize(raw[title], title) } return item } } // Get an item with the specified name from this board. // // @param name item name // // @return an object with the item's id, name and column // value (undefined if not found) // public async getBoardItemByName( name: string, ): Promise<MondayItem | undefined> { MondayRedisService.validateBoardName(name) const id = await this.redis.hget(this.namesKey, name) return id && this.getBoardItemById(id) } // Set a new value for one column of a specific item. // // The new value is atomically written to the Redis mirror, and will // eventually be synchronized back to Monday. This assures updates // are atomic and reduces load on Monday, as multiple updates to the // same column are consolidated before Monday is updated. // // The new value is encrypted if necessary before caching. // // @param itemId id of the Monday item to be updated // @param title title of the Monday column to be updated // @param value new value // public async setColumnValue( itemId: string, title: string, // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types value: any, ): Promise<void> { await this.updateColumnValue( itemId, title, this.redis.hset( this.keyForItem(itemId), title, this.serialize(value, title), ), ) } public async setItemName(itemId: string, name: string): Promise<void> { const item = await this.getBoardItemById(itemId) await this.setColumnValue(itemId, 'name', name) if (item) { await this.redis.hdel(this.namesKey, item.name) } await this.redis.hset(this.namesKey, name, itemId) } // Increase (or decrease) the value for one column of a specific // item. The column must have a numeric value and must not be // encrypted. // // The new value is atomically written to the Redis mirror, and will // eventually be synchronized back to Monday. This assures updates // are atomic and reduces load on Monday, as multiple updates to the // same column are consolidated before Monday is updated. // // @param itemId id of the Monday item to be updated // @param title title of the Monday column to be updated // @param incr numeric increment (negative to decrement) // public async incrColumnValue( itemId: string, title: string, incr = 1, ): Promise<void> { await this.updateColumnValue( itemId, title, this.redis.hincrby(this.keyForItem(itemId), title, incr), ) } // Static ///////////////////////////////////////////////////////// // Validate an item id and throw an error otherwise. // // @param itemId item id // // @return same item id // public static validateId(itemId: string): string { if (typeof itemId !== 'string' || !/^\d{9,10}$/.test(itemId)) { throw new Error(`Invalid item id: ${itemId}`) } return itemId } // Validate an item name and throw an error otherwise. // // @param name item name // // @return same item name // public static validateBoardName(name: string): string { if (typeof name !== 'string' || name.trim().length === 0) { throw new Error(`Invalid name: ${name}`) } return name } } // Create a new service. // // @param boardName name of board // @param monday pre-configured Reshuffle Monday connector // @param redis pre-configured Reshuffle Redis connector // @param encryptionKey optional key for encrypting data in Redis (if // not specified data is mirrored in the clear) // @param encryptedColumnList optional list of columns to be encrypted // // @return new board accessor // export async function createAndInitializeMondayRedisService( app: Reshuffle, boardName: string, monday: MondayConnector, redis: RedisConnector, encryptionKey?: string, encryptedColumnList?: string[], apiUserIdsToIgnoreOnNewEvent?: string[], ): Promise<MondayRedisService> { const mrs = new MondayRedisService(app, { boardName, monday, redis, encryptionKey, encryptedColumnList, apiUserIdsToIgnoreOnNewEvent, }) return mrs.initialize() }