@reshuffle/monday-redis-service
Version:
Reshuffle service for mirroring Monday to Redis
652 lines (585 loc) • 19.4 kB
text/typescript
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()
}