[NEW] Banner system and NPS (#20221)
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat> Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz>pull/20253/head
parent
818d707a2b
commit
b50175e182
@ -0,0 +1,50 @@ |
||||
import { Promise } from 'meteor/promise'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Match, check } from 'meteor/check'; |
||||
|
||||
import { API } from '../api'; |
||||
import { Banner } from '../../../../server/sdk'; |
||||
import { BannerPlatform } from '../../../../definition/IBanner'; |
||||
|
||||
API.v1.addRoute('banners.getNew', { authRequired: true }, { |
||||
get() { |
||||
check(this.queryParams, Match.ObjectIncluding({ |
||||
platform: String, |
||||
bid: Match.Maybe(String), |
||||
})); |
||||
|
||||
const { platform, bid: bannerId } = this.queryParams; |
||||
if (!platform) { |
||||
throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); |
||||
} |
||||
|
||||
if (!Object.values(BannerPlatform).includes(platform)) { |
||||
throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); |
||||
} |
||||
|
||||
const banners = Promise.await(Banner.getNewBannersForUser(this.userId, platform, bannerId)); |
||||
|
||||
return API.v1.success({ banners }); |
||||
}, |
||||
}); |
||||
|
||||
API.v1.addRoute('banners.dismiss', { authRequired: true }, { |
||||
post() { |
||||
check(this.bodyParams, Match.ObjectIncluding({ |
||||
bannerId: String, |
||||
})); |
||||
|
||||
const { bannerId } = this.bodyParams; |
||||
|
||||
if (!bannerId || !bannerId.trim()) { |
||||
throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.'); |
||||
} |
||||
|
||||
try { |
||||
Promise.await(Banner.dismiss(this.userId, bannerId)); |
||||
return API.v1.success(); |
||||
} catch (e) { |
||||
return API.v1.failure(); |
||||
} |
||||
}, |
||||
}); |
@ -0,0 +1,35 @@ |
||||
import { Collection, Cursor, FindOneOptions } from 'mongodb'; |
||||
|
||||
import { BannerPlatform, IBanner } from '../../../../definition/IBanner'; |
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
type T = IBanner; |
||||
export class BannersRaw extends BaseRaw<T> { |
||||
constructor( |
||||
public readonly col: Collection<T>, |
||||
public readonly trash?: Collection<T>, |
||||
) { |
||||
super(col, trash); |
||||
|
||||
this.col.createIndexes([ |
||||
{ key: { platform: 1, startAt: 1, expireAt: 1 } }, |
||||
]); |
||||
} |
||||
|
||||
findActiveByRoleOrId(roles: string[], platform: BannerPlatform, bannerId?: string, options?: FindOneOptions<T>): Cursor<T> { |
||||
const today = new Date(); |
||||
|
||||
const query = { |
||||
...bannerId && { _id: bannerId }, |
||||
platform, |
||||
startAt: { $lte: today }, |
||||
expireAt: { $gte: today }, |
||||
$or: [ |
||||
{ roles: { $in: roles } }, |
||||
{ roles: { $exists: false } }, |
||||
], |
||||
}; |
||||
|
||||
return this.col.find(query, options); |
||||
} |
||||
} |
@ -0,0 +1,27 @@ |
||||
import { Collection, Cursor, FindOneOptions } from 'mongodb'; |
||||
|
||||
import { IBannerDismiss } from '../../../../definition/IBanner'; |
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
type T = IBannerDismiss; |
||||
export class BannersDismissRaw extends BaseRaw<T> { |
||||
constructor( |
||||
public readonly col: Collection<T>, |
||||
public readonly trash?: Collection<T>, |
||||
) { |
||||
super(col, trash); |
||||
|
||||
this.col.createIndexes([ |
||||
{ key: { userId: 1, bannerId: 1 } }, |
||||
]); |
||||
} |
||||
|
||||
findByUserIdAndBannerId(userId: string, bannerIds: string[], options?: FindOneOptions<T>): Cursor<T> { |
||||
const query = { |
||||
userId, |
||||
bannerId: { $in: bannerIds }, |
||||
}; |
||||
|
||||
return this.col.find(query, options); |
||||
} |
||||
} |
@ -0,0 +1,90 @@ |
||||
import { UpdateWriteOpResult, Collection } from 'mongodb'; |
||||
|
||||
import { BaseRaw } from './BaseRaw'; |
||||
import { INps, NPSStatus } from '../../../../definition/INps'; |
||||
|
||||
type T = INps; |
||||
export class NpsRaw extends BaseRaw<T> { |
||||
constructor( |
||||
public readonly col: Collection<T>, |
||||
public readonly trash?: Collection<T>, |
||||
) { |
||||
super(col, trash); |
||||
|
||||
this.col.createIndexes([ |
||||
{ key: { status: 1, expireAt: 1 } }, |
||||
]); |
||||
} |
||||
|
||||
// get expired surveys still in progress
|
||||
async getOpenExpiredAndStartSending(): Promise<INps | undefined> { |
||||
const today = new Date(); |
||||
|
||||
const query = { |
||||
status: NPSStatus.OPEN, |
||||
expireAt: { $lte: today }, |
||||
}; |
||||
const update = { |
||||
$set: { |
||||
status: NPSStatus.SENDING, |
||||
}, |
||||
}; |
||||
const { value } = await this.col.findOneAndUpdate(query, update, { sort: { expireAt: 1 } }); |
||||
|
||||
return value; |
||||
} |
||||
|
||||
// get expired surveys already sending results
|
||||
async getOpenExpiredAlreadySending(): Promise<INps | null> { |
||||
const today = new Date(); |
||||
|
||||
const query = { |
||||
status: NPSStatus.SENDING, |
||||
expireAt: { $lte: today }, |
||||
}; |
||||
|
||||
return this.col.findOne(query); |
||||
} |
||||
|
||||
updateStatusById(_id: INps['_id'], status: INps['status']): Promise<UpdateWriteOpResult> { |
||||
const update = { |
||||
$set: { |
||||
status, |
||||
}, |
||||
}; |
||||
return this.col.updateOne({ _id }, update); |
||||
} |
||||
|
||||
save({ _id, startAt, expireAt, createdBy, status }: Pick<INps, '_id' | 'startAt' | 'expireAt' | 'createdBy' | 'status'>): Promise<UpdateWriteOpResult> { |
||||
return this.col.updateOne({ |
||||
_id, |
||||
}, { |
||||
$set: { |
||||
startAt, |
||||
_updatedAt: new Date(), |
||||
}, |
||||
$setOnInsert: { |
||||
expireAt, |
||||
createdBy, |
||||
createdAt: new Date(), |
||||
status, |
||||
}, |
||||
}, { |
||||
upsert: true, |
||||
}); |
||||
} |
||||
|
||||
closeAllByStatus(status: NPSStatus): Promise<UpdateWriteOpResult> { |
||||
const query = { |
||||
status, |
||||
}; |
||||
|
||||
const update = { |
||||
$set: { |
||||
status: NPSStatus.CLOSED, |
||||
}, |
||||
}; |
||||
|
||||
return this.col.updateMany(query, update); |
||||
} |
||||
} |
@ -0,0 +1,100 @@ |
||||
import { ObjectId, Collection, Cursor, FindOneOptions, UpdateWriteOpResult } from 'mongodb'; |
||||
|
||||
import { INpsVote, INpsVoteStatus } from '../../../../definition/INps'; |
||||
import { BaseRaw } from './BaseRaw'; |
||||
|
||||
type T = INpsVote; |
||||
export class NpsVoteRaw extends BaseRaw<T> { |
||||
constructor( |
||||
public readonly col: Collection<T>, |
||||
public readonly trash?: Collection<T>, |
||||
) { |
||||
super(col, trash); |
||||
|
||||
this.col.createIndexes([ |
||||
{ key: { npsId: 1, status: 1, sentAt: 1 } }, |
||||
{ key: { npsId: 1, identifier: 1 } }, |
||||
]); |
||||
} |
||||
|
||||
findNotSentByNpsId(npsId: string, options?: FindOneOptions<T>): Cursor<T> { |
||||
const query = { |
||||
npsId, |
||||
status: INpsVoteStatus.NEW, |
||||
}; |
||||
return this.col |
||||
.find(query, options) |
||||
.sort({ ts: 1 }) |
||||
.limit(1000); |
||||
} |
||||
|
||||
findByNpsIdAndStatus(npsId: string, status: INpsVoteStatus, options?: FindOneOptions<T>): Cursor<T> { |
||||
const query = { |
||||
npsId, |
||||
status, |
||||
}; |
||||
return this.col.find(query, options); |
||||
} |
||||
|
||||
findByNpsId(npsId: string, options?: FindOneOptions<T>): Cursor<T> { |
||||
const query = { |
||||
npsId, |
||||
}; |
||||
return this.col.find(query, options); |
||||
} |
||||
|
||||
save(vote: Omit<T, '_id' | '_updatedAt'>): Promise<UpdateWriteOpResult> { |
||||
const { |
||||
npsId, |
||||
identifier, |
||||
} = vote; |
||||
|
||||
const query = { |
||||
npsId, |
||||
identifier, |
||||
}; |
||||
const update = { |
||||
$set: { |
||||
...vote, |
||||
_updatedAt: new Date(), |
||||
}, |
||||
$setOnInsert: { |
||||
_id: new ObjectId().toHexString(), |
||||
}, |
||||
}; |
||||
|
||||
return this.col.updateOne(query, update, { upsert: true }); |
||||
} |
||||
|
||||
updateVotesToSent(voteIds: string[]): Promise<UpdateWriteOpResult> { |
||||
const query = { |
||||
_id: { $in: voteIds }, |
||||
}; |
||||
const update = { |
||||
$set: { |
||||
status: INpsVoteStatus.SENT, |
||||
}, |
||||
}; |
||||
return this.col.updateMany(query, update); |
||||
} |
||||
|
||||
updateOldSendingToNewByNpsId(npsId: string): Promise<UpdateWriteOpResult> { |
||||
const fiveMinutes = new Date(); |
||||
fiveMinutes.setMinutes(fiveMinutes.getMinutes() - 5); |
||||
|
||||
const query = { |
||||
npsId, |
||||
status: INpsVoteStatus.SENDING, |
||||
sentAt: { $lt: fiveMinutes }, |
||||
}; |
||||
const update = { |
||||
$set: { |
||||
status: INpsVoteStatus.NEW, |
||||
}, |
||||
$unset: { |
||||
sentAt: 1 as 1, // why do you do this to me TypeScript?
|
||||
}, |
||||
}; |
||||
return this.col.updateMany(query, update); |
||||
} |
||||
} |
@ -1,105 +0,0 @@ |
||||
.rc-alerts { |
||||
position: relative; |
||||
|
||||
z-index: 10; |
||||
|
||||
display: flex; |
||||
|
||||
padding: var(--alerts-padding-vertical) var(--alerts-padding); |
||||
|
||||
animation-name: dropdown-show; |
||||
animation-duration: 0.3s; |
||||
|
||||
text-align: center; |
||||
|
||||
color: var(--alerts-color); |
||||
|
||||
background: var(--alerts-background); |
||||
|
||||
font-size: var(--alerts-font-size); |
||||
align-items: center; |
||||
flex-grow: 0; |
||||
|
||||
&--danger { |
||||
background: var(--rc-color-error); |
||||
} |
||||
|
||||
&--alert { |
||||
background: var(--rc-color-alert); |
||||
} |
||||
|
||||
&--large > &__content { |
||||
flex-direction: column; |
||||
|
||||
text-align: start; |
||||
} |
||||
|
||||
&--has-action { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__icon { |
||||
cursor: pointer; |
||||
|
||||
color: inherit; |
||||
|
||||
flex-grow: 0; |
||||
|
||||
&--close { |
||||
transform: rotate(45deg); |
||||
} |
||||
} |
||||
|
||||
&__title { |
||||
font-weight: bold; |
||||
} |
||||
|
||||
&__title, |
||||
&__line { |
||||
display: block; |
||||
|
||||
overflow: hidden; |
||||
|
||||
white-space: nowrap; |
||||
text-overflow: ellipsis; |
||||
|
||||
flex-grow: 0; |
||||
} |
||||
|
||||
&__content > &__title + &__line { |
||||
margin: 0 var(--alerts-padding-vertical); |
||||
} |
||||
|
||||
&--large > &__content > &__title + &__line { |
||||
margin: 0; |
||||
} |
||||
|
||||
&--large > &__icon { |
||||
font-size: 30px; |
||||
} |
||||
|
||||
&--large > &__big-icon { |
||||
width: 40px; |
||||
height: 40px; |
||||
|
||||
font-size: 40px; |
||||
} |
||||
|
||||
&--large { |
||||
padding: var(--alerts-padding-vertical-large) var(--alerts-padding); |
||||
justify-content: start; |
||||
} |
||||
|
||||
&__content { |
||||
display: flex; |
||||
overflow: hidden; |
||||
|
||||
flex-direction: row; |
||||
|
||||
flex: 1; |
||||
|
||||
margin: 0 var(--alerts-padding-vertical); |
||||
|
||||
justify-content: center; |
||||
} |
||||
} |
@ -1,29 +0,0 @@ |
||||
<template name="alerts"> |
||||
<div class="rc-alerts {{modifiers}} {{customclass}}" data-popover="popover"> |
||||
{{#if icon}} |
||||
<div class="rc-alerts__big-icon rc-alerts__icon {{hasAction}} js-action"> |
||||
{{> icon icon=icon }} |
||||
</div> |
||||
{{/if}} |
||||
<div class="rc-alerts__content {{hasAction}} js-action"> |
||||
{{#if template}} |
||||
{{> Template.dynamic template=template data=data}} |
||||
{{else}} |
||||
{{#if title}} |
||||
<div class="rc-alerts__title"> |
||||
{{title}} |
||||
</div> |
||||
{{/if}} |
||||
<div class="rc-alerts__line"> |
||||
{{text}} |
||||
</div> |
||||
{{{html}}} |
||||
{{/if}} |
||||
</div> |
||||
{{#if closable}} |
||||
<div class="rc-alerts__icon js-close" aria-label="{{_ "Close"}}"> |
||||
{{> icon block="rc-alerts__icon rc-alerts__icon--close" icon='plus'}} |
||||
</div> |
||||
{{/if}} |
||||
</div> |
||||
</template> |
@ -1,73 +0,0 @@ |
||||
import './alerts.html'; |
||||
import { Blaze } from 'meteor/blaze'; |
||||
import { Template } from 'meteor/templating'; |
||||
|
||||
export const alerts = { |
||||
renderedAlert: null, |
||||
open(config) { |
||||
this.close(false); |
||||
|
||||
config.closable = typeof config.closable === typeof true ? config.closable : true; |
||||
|
||||
if (config.timer) { |
||||
this.timer = setTimeout(() => this.close(), config.timer); |
||||
} |
||||
|
||||
this.renderedAlert = Blaze.renderWithData(Template.alerts, config, document.body, document.body.querySelector('#alert-anchor')); |
||||
}, |
||||
close(dismiss = true) { |
||||
if (this.timer) { |
||||
clearTimeout(this.timer); |
||||
delete this.timer; |
||||
} |
||||
if (!this.renderedAlert) { |
||||
return false; |
||||
} |
||||
|
||||
Blaze.remove(this.renderedAlert); |
||||
|
||||
const { activeElement } = this.renderedAlert.dataVar.curValue; |
||||
if (activeElement) { |
||||
$(activeElement).removeClass('active'); |
||||
} |
||||
|
||||
dismiss && this.renderedAlert.dataVar.curValue.onClose && this.renderedAlert.dataVar.curValue.onClose(); |
||||
}, |
||||
}; |
||||
|
||||
Template.alerts.helpers({ |
||||
hasAction() { |
||||
return Template.instance().data.action ? 'rc-alerts--has-action' : ''; |
||||
}, |
||||
modifiers() { |
||||
return (Template.instance().data.modifiers || []).map((mod) => `rc-alerts--${ mod }`).join(' '); |
||||
}, |
||||
}); |
||||
|
||||
Template.alerts.onRendered(function() { |
||||
if (this.data.onRendered) { |
||||
this.data.onRendered(); |
||||
} |
||||
}); |
||||
|
||||
Template.alerts.onDestroyed(function() { |
||||
if (this.data.onDestroyed) { |
||||
this.data.onDestroyed(); |
||||
} |
||||
}); |
||||
|
||||
Template.alerts.events({ |
||||
'click .js-action'(e, instance) { |
||||
if (!this.action) { |
||||
return; |
||||
} |
||||
this.action.call(this, e, instance.data.data); |
||||
}, |
||||
'click .js-close'() { |
||||
alerts.close(); |
||||
}, |
||||
}); |
||||
|
||||
Template.alerts.helpers({ |
||||
isSafariIos: /iP(ad|hone|od).+Version\/[\d\.]+.*Safari/i.test(navigator.userAgent), |
||||
}); |
@ -0,0 +1,24 @@ |
||||
import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; |
||||
/* eslint-disable new-cap */ |
||||
// import { Banner, Icon } from '@rocket.chat/fuselage';
|
||||
// import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit';
|
||||
// import React, { Context, FC, useMemo } from 'react';
|
||||
import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; |
||||
// import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer';
|
||||
|
||||
// import { useEndpoint } from '../../contexts/ServerContext';
|
||||
import * as ActionManager from '../../../app/ui-message/client/ActionManager'; |
||||
import { UiKitPayload, UIKitActionEvent } from '../../../definition/UIKit'; |
||||
|
||||
const useUIKitHandleAction = <S extends UiKitPayload>(state: S): (event: UIKitActionEvent) => Promise<void> => useMutableCallback(async ({ blockId, value, appId, actionId }) => ActionManager.triggerBlockAction({ |
||||
container: { |
||||
type: UIKitIncomingInteractionContainerType.VIEW, |
||||
id: state.viewId || state.appId, |
||||
}, |
||||
actionId, |
||||
appId, |
||||
value, |
||||
blockId, |
||||
})); |
||||
|
||||
export { useUIKitHandleAction }; |
@ -0,0 +1,35 @@ |
||||
import { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; |
||||
/* eslint-disable new-cap */ |
||||
// import { Banner, Icon } from '@rocket.chat/fuselage';
|
||||
// import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit';
|
||||
// import React, { Context, FC, useMemo } from 'react';
|
||||
import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; |
||||
// import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer';
|
||||
|
||||
// import { useEndpoint } from '../../contexts/ServerContext';
|
||||
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; |
||||
import * as ActionManager from '../../../app/ui-message/client/ActionManager'; |
||||
import { UiKitPayload } from '../../../definition/UIKit'; |
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const emptyFn = (_error: any, _result: UIKitInteractionType | void): void => undefined; |
||||
|
||||
const useUIKitHandleClose = <S extends UiKitPayload>(state: S, fn = emptyFn): () => Promise<void | UIKitInteractionType> => { |
||||
const dispatchToastMessage = useToastMessageDispatch(); |
||||
return useMutableCallback(() => ActionManager.triggerCancel({ |
||||
appId: state.appId, |
||||
viewId: state.viewId, |
||||
view: { |
||||
...state, |
||||
id: state.viewId, |
||||
// state: groupStateByBlockId(values),
|
||||
}, |
||||
isCleared: true, |
||||
}).then((result) => fn(undefined, result)).catch((error) => { |
||||
dispatchToastMessage({ type: 'error', message: error }); |
||||
fn(error, undefined); |
||||
return Promise.reject(error); |
||||
})); |
||||
}; |
||||
|
||||
export { useUIKitHandleClose }; |
@ -0,0 +1,34 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { isErrorType, UIKitUserInteractionResult, UiKitPayload } from '../../../definition/UIKit'; |
||||
import * as ActionManager from '../../../app/ui-message/client/ActionManager'; |
||||
|
||||
|
||||
const useUIKitStateManager = <S extends UiKitPayload>(initialState: S): S => { |
||||
const [state, setState] = useState<S>(initialState); |
||||
|
||||
const { viewId } = state; |
||||
|
||||
useEffect(() => { |
||||
const handleUpdate = ({ ...data }: UIKitUserInteractionResult): void => { |
||||
if (isErrorType(data)) { |
||||
const { errors } = data; |
||||
setState((state) => ({ ...state, errors })); |
||||
return; |
||||
} |
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { type, ...rest } = data; |
||||
setState(rest as any); |
||||
}; |
||||
|
||||
ActionManager.on(viewId, handleUpdate); |
||||
|
||||
return (): void => { |
||||
ActionManager.off(viewId, handleUpdate); |
||||
}; |
||||
}, [viewId]); |
||||
|
||||
return state; |
||||
}; |
||||
|
||||
export { useUIKitStateManager }; |
@ -0,0 +1,18 @@ |
||||
import React from 'react'; |
||||
import { useSubscription } from 'use-subscription'; |
||||
|
||||
import MeteorProvider from '../providers/MeteorProvider'; |
||||
import { portalsSubscription } from '../reactAdapters'; |
||||
import BannerRegion from '../views/banners/BannerRegion'; |
||||
import PortalWrapper from './PortalWrapper'; |
||||
|
||||
const AppRoot = () => { |
||||
const portals = useSubscription(portalsSubscription); |
||||
|
||||
return <MeteorProvider> |
||||
<BannerRegion /> |
||||
{portals.map(({ key, portal }) => <PortalWrapper key={key} portal={portal} />)} |
||||
</MeteorProvider>; |
||||
}; |
||||
|
||||
export default AppRoot; |
@ -0,0 +1,13 @@ |
||||
import { PureComponent } from 'react'; |
||||
|
||||
class PortalWrapper extends PureComponent { |
||||
state = { errored: false } |
||||
|
||||
static getDerivedStateFromError = () => ({ errored: true }) |
||||
|
||||
componentDidCatch = () => {} |
||||
|
||||
render = () => (this.state.errored ? null : this.props.portal) |
||||
} |
||||
|
||||
export default PortalWrapper; |
@ -0,0 +1,75 @@ |
||||
import { Emitter } from '@rocket.chat/emitter'; |
||||
import { Subscription } from 'use-subscription'; |
||||
|
||||
import { mountRoot } from '../reactAdapters'; |
||||
import { UiKitBannerPayload } from '../../definition/UIKit'; |
||||
|
||||
export type LegacyBannerPayload = { |
||||
closable?: boolean; |
||||
title?: string; |
||||
text?: string; |
||||
html?: string; |
||||
icon?: string; |
||||
modifiers?: ('large' | 'danger')[]; |
||||
timer?: number; |
||||
action?: () => void; |
||||
onClose?: () => void; |
||||
}; |
||||
|
||||
type BannerPayload = LegacyBannerPayload | UiKitBannerPayload; |
||||
|
||||
export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload); |
||||
|
||||
const queue: BannerPayload[] = []; |
||||
const emitter = new Emitter(); |
||||
|
||||
export const firstSubscription: Subscription<BannerPayload | null> = { |
||||
getCurrentValue: () => queue[0] ?? null, |
||||
subscribe: (callback) => emitter.on('update-first', callback), |
||||
}; |
||||
|
||||
export const open = (payload: BannerPayload): void => { |
||||
mountRoot(); |
||||
|
||||
let index = -1; |
||||
|
||||
if (!isLegacyPayload(payload)) { |
||||
index = queue.findIndex((_payload) => !isLegacyPayload(_payload) && _payload.viewId === payload.viewId); |
||||
} |
||||
|
||||
if (index < 0) { |
||||
index = queue.length; |
||||
} |
||||
|
||||
queue[index] = payload; |
||||
|
||||
emitter.emit('update'); |
||||
|
||||
if (index === 0) { |
||||
emitter.emit('update-first'); |
||||
} |
||||
}; |
||||
|
||||
|
||||
export const closeById = (viewId: string): void => { |
||||
const index = queue.findIndex((banner) => !isLegacyPayload(banner) && banner.viewId === viewId); |
||||
if (index < 0) { |
||||
return; |
||||
} |
||||
|
||||
queue.splice(index, 1); |
||||
emitter.emit('update'); |
||||
emitter.emit('update-first'); |
||||
}; |
||||
|
||||
export const close = (): void => { |
||||
queue.shift(); |
||||
emitter.emit('update'); |
||||
emitter.emit('update-first'); |
||||
}; |
||||
|
||||
export const clear = (): void => { |
||||
queue.splice(0, queue.length); |
||||
emitter.emit('update'); |
||||
emitter.emit('update-first'); |
||||
}; |
@ -0,0 +1,20 @@ |
||||
import { Emitter } from '@rocket.chat/emitter'; |
||||
import { Subscription, Unsubscribe } from 'use-subscription'; |
||||
|
||||
type ValueSubscription<T> = Subscription<T> & { |
||||
setCurrentValue: (value: T) => void; |
||||
}; |
||||
|
||||
export const createValueSubscription = <T>(initialValue: T): ValueSubscription<T> => { |
||||
let value: T = initialValue; |
||||
const emitter = new Emitter(); |
||||
|
||||
return { |
||||
getCurrentValue: (): T => value, |
||||
setCurrentValue: (_value: T): void => { |
||||
value = _value; |
||||
emitter.emit('update'); |
||||
}, |
||||
subscribe: (callback): Unsubscribe => emitter.on('update', callback), |
||||
}; |
||||
}; |
@ -0,0 +1,63 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { Tracker } from 'meteor/tracker'; |
||||
|
||||
import * as banners from '../lib/banners'; |
||||
import { APIClient } from '../../app/utils/client'; |
||||
import { IBanner, BannerPlatform } from '../../definition/IBanner'; |
||||
import { Notifications } from '../../app/notifications/client'; |
||||
|
||||
const fetchInitialBanners = async (): Promise<void> => { |
||||
const response = await APIClient.get('v1/banners.getNew', { |
||||
platform: BannerPlatform.Web, |
||||
}) as { |
||||
banners: IBanner[]; |
||||
}; |
||||
|
||||
for (const banner of response.banners) { |
||||
banners.open({ |
||||
...banner.view, |
||||
viewId: banner.view.viewId || banner._id, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const handleNewBanner = async (event: { bannerId: string }): Promise<void> => { |
||||
const response = await APIClient.get('v1/banners.getNew', { |
||||
platform: BannerPlatform.Web, |
||||
bid: event.bannerId, |
||||
}) as { |
||||
banners: IBanner[]; |
||||
}; |
||||
|
||||
for (const banner of response.banners) { |
||||
banners.open({ |
||||
...banner.view, |
||||
viewId: banner.view.viewId || banner._id, |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
const watchBanners = (): (() => void) => { |
||||
fetchInitialBanners(); |
||||
|
||||
Notifications.onLogged('new-banner', handleNewBanner); |
||||
|
||||
return (): void => { |
||||
Notifications.unLogged(handleNewBanner); |
||||
banners.clear(); |
||||
}; |
||||
}; |
||||
|
||||
Meteor.startup(() => { |
||||
let unwatchBanners: () => void | undefined; |
||||
|
||||
Tracker.autorun(() => { |
||||
unwatchBanners?.(); |
||||
|
||||
if (!Meteor.userId()) { |
||||
return; |
||||
} |
||||
|
||||
unwatchBanners = Tracker.nonreactive(watchBanners); |
||||
}); |
||||
}); |
@ -0,0 +1,37 @@ |
||||
declare module '@rocket.chat/fuselage-ui-kit' { |
||||
import { IBlock } from '@rocket.chat/ui-kit'; |
||||
import { Context, FC, ReactChildren } from 'react'; |
||||
import { IDividerBlock, ISectionBlock, IActionsBlock, IContextBlock, IInputBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; |
||||
|
||||
export const kitContext: Context<{ |
||||
action: (action: { |
||||
blockId: string; |
||||
appId: string; |
||||
actionId: string; |
||||
value: unknown; |
||||
viewId: string; |
||||
}) => void | Promise<void>; |
||||
state?: (state: { |
||||
blockId: string; |
||||
appId: string; |
||||
actionId: string; |
||||
value: unknown; |
||||
}) => void | Promise<void>; |
||||
appId: string; |
||||
errors?: { |
||||
[fieldName: string]: string; |
||||
}; |
||||
}>; |
||||
|
||||
type UiKitComponentProps = { |
||||
render: (blocks: IBlock[]) => ReactChildren; |
||||
blocks: IBlock[]; |
||||
}; |
||||
export const UiKitComponent: FC<UiKitComponentProps>; |
||||
|
||||
type BannerBlocks = IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock; |
||||
|
||||
export const UiKitBanner: (blocks: BannerBlocks[], conditions?: { [param: string]: unknown }) => ReactChildren; |
||||
export const UiKitMessage: (blocks: IBlock[], conditions?: { [param: string]: unknown }) => ReactChildren; |
||||
export const UiKitModal: (blocks: IBlock[], conditions?: { [param: string]: unknown }) => ReactChildren; |
||||
} |
@ -0,0 +1,22 @@ |
||||
import React, { FC } from 'react'; |
||||
import { useSubscription } from 'use-subscription'; |
||||
|
||||
import * as banners from '../../lib/banners'; |
||||
import LegacyBanner from './LegacyBanner'; |
||||
import UiKitBanner from './UiKitBanner'; |
||||
|
||||
const BannerRegion: FC = () => { |
||||
const payload = useSubscription(banners.firstSubscription); |
||||
|
||||
if (!payload) { |
||||
return null; |
||||
} |
||||
|
||||
if (banners.isLegacyPayload(payload)) { |
||||
return <LegacyBanner config={payload} />; |
||||
} |
||||
|
||||
return <UiKitBanner payload={payload} />; |
||||
}; |
||||
|
||||
export default BannerRegion; |
@ -0,0 +1,63 @@ |
||||
import { Banner, Icon } from '@rocket.chat/fuselage'; |
||||
import React, { FC, useCallback, useEffect } from 'react'; |
||||
|
||||
import { LegacyBannerPayload } from '../../lib/banners'; |
||||
import * as banners from '../../lib/banners'; |
||||
|
||||
type LegacyBannerProps = { |
||||
config: LegacyBannerPayload; |
||||
}; |
||||
|
||||
const LegacyBanner: FC<LegacyBannerProps> = ({ config }) => { |
||||
const { |
||||
closable = true, |
||||
title, |
||||
text, |
||||
html, |
||||
icon, |
||||
modifiers, |
||||
} = config; |
||||
|
||||
const inline = !modifiers?.includes('large'); |
||||
const variant = modifiers?.includes('danger') ? 'danger' : 'info'; |
||||
|
||||
useEffect(() => { |
||||
if (!config.timer) { |
||||
return; |
||||
} |
||||
|
||||
const timer = setTimeout(() => { |
||||
config.onClose?.call(undefined); |
||||
banners.close(); |
||||
}, config.timer); |
||||
|
||||
return (): void => { |
||||
clearTimeout(timer); |
||||
}; |
||||
}, [config.onClose, config.timer]); |
||||
|
||||
const handleAction = useCallback(() => { |
||||
config.action?.call(undefined); |
||||
}, [config.action]); |
||||
|
||||
const handleClose = useCallback(() => { |
||||
config.onClose?.call(undefined); |
||||
banners.close(); |
||||
}, [config.onClose]); |
||||
|
||||
return <Banner |
||||
inline={inline} |
||||
actionable={!!config.action} |
||||
closeable={closable} |
||||
icon={icon ? <Icon name={icon} size={20} /> : undefined} |
||||
title={title} |
||||
variant={variant} |
||||
onAction={handleAction} |
||||
onClose={handleClose} |
||||
> |
||||
{text} |
||||
{html && <div dangerouslySetInnerHTML={{ __html: html }} />} |
||||
</Banner>; |
||||
}; |
||||
|
||||
export default LegacyBanner; |
@ -0,0 +1,50 @@ |
||||
/* eslint-disable new-cap */ |
||||
import { Banner, Icon } from '@rocket.chat/fuselage'; |
||||
import { kitContext, UiKitBanner as renderUiKitBannerBlocks } from '@rocket.chat/fuselage-ui-kit'; |
||||
import React, { Context, FC, useMemo } from 'react'; |
||||
|
||||
import * as banners from '../../lib/banners'; |
||||
// import { useEndpoint } from '../../contexts/ServerContext';
|
||||
import { UiKitBannerProps, UiKitBannerPayload } from '../../../definition/UIKit'; |
||||
import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager'; |
||||
import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose'; |
||||
import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction'; |
||||
|
||||
const UiKitBanner: FC<UiKitBannerProps> = ({ payload }) => { |
||||
const state = useUIKitStateManager<UiKitBannerPayload>(payload); |
||||
|
||||
const icon = useMemo(() => { |
||||
if (state.icon) { |
||||
return <Icon name={state.icon} size={20} />; |
||||
} |
||||
|
||||
return null; |
||||
}, [state.icon]); |
||||
|
||||
const handleClose = useUIKitHandleClose(state, () => banners.close()); |
||||
|
||||
const action = useUIKitHandleAction(state); |
||||
|
||||
const contextValue = useMemo<typeof kitContext extends Context<infer V> ? V : never>(() => ({ |
||||
action: async (...args) => { |
||||
await action(...args); |
||||
banners.closeById(state.viewId); |
||||
}, |
||||
appId: state.appId, |
||||
}), [action, state.appId, state.viewId]); |
||||
|
||||
return <Banner |
||||
closeable |
||||
icon={icon} |
||||
inline={state.inline} |
||||
title={state.title} |
||||
variant={state.variant} |
||||
onClose={handleClose} |
||||
> |
||||
<kitContext.Provider value={contextValue}> |
||||
{renderUiKitBannerBlocks(state.blocks, { engine: 'rocket.chat' })} |
||||
</kitContext.Provider> |
||||
</Banner>; |
||||
}; |
||||
|
||||
export default UiKitBanner; |
@ -0,0 +1,24 @@ |
||||
import { IRocketChatRecord } from './IRocketChatRecord'; |
||||
import { IUser } from './IUser'; |
||||
import { UiKitBannerPayload } from './UIKit'; |
||||
|
||||
export enum BannerPlatform { |
||||
Web = 'web', |
||||
Mobile = 'mobile', |
||||
} |
||||
export interface IBanner extends IRocketChatRecord { |
||||
platform: BannerPlatform[]; // pĺatforms a banner could be shown
|
||||
expireAt: Date; // date when banner should not be shown anymore
|
||||
startAt: Date; // start date a banner should be presented
|
||||
roles?: string[]; // only show the banner to this roles
|
||||
createdBy: Pick<IUser, '_id' | 'username' >; |
||||
createdAt: Date; |
||||
view: UiKitBannerPayload; |
||||
} |
||||
|
||||
export interface IBannerDismiss extends IRocketChatRecord { |
||||
userId: IUser['_id']; // user receiving the banner dismissed
|
||||
bannerId: IBanner['_id']; // banner dismissed
|
||||
dismissedAt: Date; // when is was dismissed
|
||||
dismissedBy: Pick<IUser, '_id' | 'username' >; // who dismissed (usually the same as userId)
|
||||
} |
@ -0,0 +1,35 @@ |
||||
import { IRocketChatRecord } from './IRocketChatRecord'; |
||||
import { IUser } from './IUser'; |
||||
|
||||
export enum NPSStatus { |
||||
OPEN = 'open', |
||||
SENDING = 'sending', |
||||
SENT = 'sent', |
||||
CLOSED = 'closed', |
||||
} |
||||
|
||||
export interface INps extends IRocketChatRecord { |
||||
startAt: Date; // start date a banner should be presented
|
||||
expireAt: Date; // date when banner should not be shown anymore
|
||||
createdBy: Pick<IUser, '_id' | 'username'>; |
||||
createdAt: Date; |
||||
status: NPSStatus; |
||||
} |
||||
|
||||
export enum INpsVoteStatus { |
||||
NEW = 'new', |
||||
SENDING = 'sending', |
||||
SENT = 'sent', |
||||
} |
||||
|
||||
export interface INpsVote extends IRocketChatRecord { |
||||
_id: string; |
||||
npsId: INps['_id']; |
||||
ts: Date; |
||||
identifier: string; // voter identifier
|
||||
roles: IUser['roles']; // voter roles
|
||||
score: number; |
||||
comment: string; |
||||
status: INpsVoteStatus; |
||||
sentAt?: Date; |
||||
} |
@ -0,0 +1,59 @@ |
||||
|
||||
import { UIKitInteractionType as UIKitInteractionTypeApi } from '@rocket.chat/apps-engine/definition/uikit'; |
||||
import { IDividerBlock, ISectionBlock, IActionsBlock, IContextBlock, IInputBlock } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; |
||||
|
||||
enum UIKitInteractionTypeExtended { |
||||
BANNER_OPEN = 'banner.open', |
||||
BANNER_UPDATE = 'banner.update', |
||||
BANNER_CLOSE = 'banner.close', |
||||
} |
||||
|
||||
export type UIKitInteractionType = UIKitInteractionTypeApi | UIKitInteractionTypeExtended; |
||||
|
||||
export const UIKitInteractionTypes = { |
||||
...UIKitInteractionTypeApi, |
||||
...UIKitInteractionTypeExtended, |
||||
}; |
||||
|
||||
|
||||
export type UiKitPayload = { |
||||
viewId: string; |
||||
appId: string; |
||||
blocks: (IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock)[]; |
||||
} |
||||
|
||||
export type UiKitBannerPayload = UiKitPayload & { |
||||
inline?: boolean; |
||||
variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; |
||||
icon?: string; |
||||
title?: string; |
||||
} |
||||
|
||||
|
||||
export type UIKitUserInteraction = { |
||||
type: UIKitInteractionType; |
||||
} & UiKitPayload; |
||||
|
||||
|
||||
export type UiKitBannerProps = { |
||||
payload: UiKitBannerPayload; |
||||
}; |
||||
|
||||
|
||||
export type UIKitUserInteractionResult = UIKitUserInteractionResultError | UIKitUserInteraction; |
||||
|
||||
type UIKitUserInteractionResultError = UIKitUserInteraction & { |
||||
type: UIKitInteractionTypeApi.ERRORS; |
||||
errors?: Array<{[key: string]: string}>; |
||||
}; |
||||
|
||||
export const isErrorType = (result: UIKitUserInteractionResult): result is UIKitUserInteractionResultError => result.type === UIKitInteractionTypeApi.ERRORS; |
||||
|
||||
|
||||
export type UIKitActionEvent = { |
||||
blockId: string; |
||||
value?: unknown; |
||||
appId: string; |
||||
actionId: string; |
||||
viewId: string; |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { settings } from '../../app/settings'; |
||||
import { NPS } from '../sdk'; |
||||
|
||||
function runNPS() { |
||||
// if NPS is disabled close any pending scheduled survey
|
||||
const enabled = settings.get('NPS_survey_enabled'); |
||||
if (!enabled) { |
||||
Promise.await(NPS.closeOpenSurveys()); |
||||
return; |
||||
} |
||||
Promise.await(NPS.sendResults()); |
||||
} |
||||
|
||||
export function npsCron(SyncedCron) { |
||||
SyncedCron.add({ |
||||
name: 'NPS', |
||||
schedule(parser) { |
||||
return parser.cron('9 3 * * *'); |
||||
}, |
||||
job: runNPS, |
||||
}); |
||||
} |
@ -0,0 +1,11 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
export function oembedCron(SyncedCron) { |
||||
SyncedCron.add({ |
||||
name: 'Cleanup OEmbed cache', |
||||
schedule(parser) { |
||||
return parser.cron('24 2 * * *'); |
||||
}, |
||||
job: () => Meteor.call('OEmbedCacheCleanup'), |
||||
}); |
||||
} |
@ -0,0 +1,64 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { HTTP } from 'meteor/http'; |
||||
|
||||
import { getWorkspaceAccessToken } from '../../app/cloud/server'; |
||||
import { statistics } from '../../app/statistics'; |
||||
import { settings } from '../../app/settings'; |
||||
|
||||
function generateStatistics(logger) { |
||||
const cronStatistics = statistics.save(); |
||||
|
||||
cronStatistics.host = Meteor.absoluteUrl(); |
||||
|
||||
if (!settings.get('Statistics_reporting')) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
const headers = {}; |
||||
const token = getWorkspaceAccessToken(); |
||||
|
||||
if (token) { |
||||
headers.Authorization = `Bearer ${ token }`; |
||||
} |
||||
|
||||
HTTP.post('https://collector.rocket.chat/', { |
||||
data: cronStatistics, |
||||
headers, |
||||
}); |
||||
} catch (error) { |
||||
/* error*/ |
||||
logger.warn('Failed to send usage report'); |
||||
} |
||||
} |
||||
|
||||
export function statsCron(SyncedCron, logger) { |
||||
if (settings.get('Troubleshoot_Disable_Statistics_Generator')) { |
||||
return; |
||||
} |
||||
|
||||
const name = 'Generate and save statistics'; |
||||
|
||||
let previousValue; |
||||
settings.get('Troubleshoot_Disable_Statistics_Generator', (key, value) => { |
||||
if (value === previousValue) { |
||||
return; |
||||
} |
||||
previousValue = value; |
||||
|
||||
if (value) { |
||||
SyncedCron.remove(name); |
||||
return; |
||||
} |
||||
|
||||
generateStatistics(); |
||||
|
||||
SyncedCron.add({ |
||||
name, |
||||
schedule(parser) { |
||||
return parser.cron('12 * * * *'); |
||||
}, |
||||
job: () => generateStatistics(logger), |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { IUiKitCoreApp } from '../../sdk/types/IUiKitCoreApp'; |
||||
import { Banner } from '../../sdk'; |
||||
|
||||
export class BannerModule implements IUiKitCoreApp { |
||||
appId = 'banner-core'; |
||||
|
||||
// when banner view is closed we need to dissmiss that banner for that user
|
||||
async viewClosed(payload: any): Promise<any> { |
||||
const { |
||||
payload: { |
||||
view: { |
||||
viewId: bannerId, |
||||
}, |
||||
}, |
||||
user: { |
||||
_id: userId, |
||||
}, |
||||
} = payload; |
||||
|
||||
return Banner.dismiss(userId, bannerId); |
||||
} |
||||
} |
@ -0,0 +1,70 @@ |
||||
import { IUiKitCoreApp } from '../../sdk/types/IUiKitCoreApp'; |
||||
import { Banner, NPS } from '../../sdk'; |
||||
import { createModal } from './nps/createModal'; |
||||
|
||||
export class Nps implements IUiKitCoreApp { |
||||
appId = 'nps-core'; |
||||
|
||||
async blockAction(payload: any): Promise<any> { |
||||
const { |
||||
triggerId, |
||||
container: { |
||||
id: bannerId, |
||||
}, |
||||
payload: { |
||||
value: score, |
||||
blockId: npsId, |
||||
}, |
||||
user, |
||||
} = payload; |
||||
|
||||
return createModal({ |
||||
appId: this.appId, |
||||
npsId, |
||||
bannerId, |
||||
triggerId, |
||||
score, |
||||
user, |
||||
}); |
||||
} |
||||
|
||||
async viewSubmit(payload: any): Promise<any> { |
||||
if (!payload.payload?.view?.state) { |
||||
throw new Error('Invalid payload'); |
||||
} |
||||
|
||||
const { |
||||
payload: { |
||||
view: { |
||||
state, |
||||
id: bannerId, |
||||
}, |
||||
}, |
||||
user: { |
||||
_id: userId, |
||||
roles, |
||||
}, |
||||
} = payload; |
||||
|
||||
const [npsId] = Object.keys(state); |
||||
|
||||
const { |
||||
[npsId]: { |
||||
score, |
||||
comment, |
||||
}, |
||||
} = state; |
||||
|
||||
await NPS.vote({ |
||||
npsId, |
||||
userId, |
||||
comment, |
||||
roles, |
||||
score, |
||||
}); |
||||
|
||||
await Banner.dismiss(userId, bannerId); |
||||
|
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; |
||||
|
||||
import { settings } from '../../../../app/settings/server'; |
||||
import { IUser } from '../../../../definition/IUser'; |
||||
|
||||
export type ModalParams = { |
||||
appId: string; |
||||
npsId: string; |
||||
bannerId: string; |
||||
triggerId: string; |
||||
score: string; |
||||
user: IUser; |
||||
} |
||||
|
||||
export const createModal = Meteor.bindEnvironment(({ appId, npsId, bannerId, triggerId, score, user }: ModalParams): any => { |
||||
const language = user.language || settings.get('Language') || 'en'; |
||||
|
||||
const options = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].map((score) => ({ |
||||
text: { |
||||
type: 'plain_text', |
||||
text: score, |
||||
emoji: true, |
||||
}, |
||||
value: score, |
||||
})); |
||||
|
||||
return { |
||||
type: 'modal.open', |
||||
triggerId, |
||||
appId, |
||||
view: { |
||||
appId, |
||||
type: 'modal', |
||||
id: bannerId, |
||||
title: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('We_appreciate_your_feedback', { lng: language }), |
||||
emoji: false, |
||||
}, |
||||
submit: { |
||||
type: 'button', |
||||
text: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('Send', { lng: language }), |
||||
emoji: false, |
||||
}, |
||||
actionId: 'send-vote', |
||||
}, |
||||
close: { |
||||
type: 'button', |
||||
text: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('Cancel', { lng: language }), |
||||
emoji: false, |
||||
}, |
||||
actionId: 'cancel', |
||||
}, |
||||
blocks: [{ |
||||
blockId: npsId, |
||||
type: 'input', |
||||
element: { |
||||
type: 'static_select', |
||||
placeholder: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('Score', { lng: language }), |
||||
emoji: false, |
||||
}, |
||||
initialValue: score, |
||||
options, |
||||
actionId: 'score', |
||||
}, |
||||
label: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('Score', { lng: language }), |
||||
emoji: false, |
||||
}, |
||||
}, |
||||
{ |
||||
blockId: npsId, |
||||
type: 'input', |
||||
element: { |
||||
type: 'plain_text_input', |
||||
multiline: true, |
||||
actionId: 'comment', |
||||
}, |
||||
label: { |
||||
type: 'plain_text', |
||||
text: TAPi18n.__('Why_did_you_chose__score__', { score, lng: language }), |
||||
emoji: false, |
||||
}, |
||||
}], |
||||
}, |
||||
}; |
||||
}); |
@ -0,0 +1,7 @@ |
||||
import { BannerPlatform, IBanner } from '../../../definition/IBanner'; |
||||
|
||||
export interface IBannerService { |
||||
getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]>; |
||||
create(banner: Omit<IBanner, '_id'>): Promise<IBanner>; |
||||
dismiss(userId: string, bannerId: string): Promise<boolean>; |
||||
} |
@ -0,0 +1,22 @@ |
||||
import { IUser } from '../../../definition/IUser'; |
||||
|
||||
export type NPSVotePayload = { |
||||
userId: string; |
||||
npsId: string; |
||||
roles: string[]; |
||||
score: number; |
||||
comment: string; |
||||
}; |
||||
|
||||
export type NPSCreatePayload = { |
||||
npsId: string; |
||||
startAt: Date; |
||||
expireAt: Date; |
||||
createdBy: Pick<IUser, '_id' | 'username'>; |
||||
}; |
||||
export interface INPSService { |
||||
create(nps: NPSCreatePayload): Promise<boolean>; |
||||
vote(vote: NPSVotePayload): Promise<void>; |
||||
sendResults(): Promise<void>; |
||||
closeOpenSurveys(): Promise<void>; |
||||
} |
@ -0,0 +1,16 @@ |
||||
import { IServiceClass } from './ServiceClass'; |
||||
|
||||
export interface IUiKitCoreApp { |
||||
appId: string; |
||||
|
||||
blockAction?(payload: any): Promise<any>; |
||||
viewClosed?(payload: any): Promise<any>; |
||||
viewSubmit?(payload: any): Promise<any>; |
||||
} |
||||
|
||||
export interface IUiKitCoreAppService extends IServiceClass { |
||||
isRegistered(appId: string): Promise<boolean>; |
||||
blockAction(payload: any): Promise<any>; |
||||
viewClosed(payload: any): Promise<any>; |
||||
viewSubmit(payload: any): Promise<any>; |
||||
} |
@ -0,0 +1,111 @@ |
||||
import { Db } from 'mongodb'; |
||||
import { v4 as uuidv4 } from 'uuid'; |
||||
|
||||
import { ServiceClass } from '../../sdk/types/ServiceClass'; |
||||
import { BannersRaw } from '../../../app/models/server/raw/Banners'; |
||||
import { BannersDismissRaw } from '../../../app/models/server/raw/BannersDismiss'; |
||||
import { UsersRaw } from '../../../app/models/server/raw/Users'; |
||||
import { IBannerService } from '../../sdk/types/IBannerService'; |
||||
import { BannerPlatform, IBanner } from '../../../definition/IBanner'; |
||||
import { api } from '../../sdk/api'; |
||||
|
||||
export class BannerService extends ServiceClass implements IBannerService { |
||||
protected name = 'banner'; |
||||
|
||||
private Banners: BannersRaw; |
||||
|
||||
private BannersDismiss: BannersDismissRaw; |
||||
|
||||
private Users: UsersRaw; |
||||
|
||||
constructor(db: Db) { |
||||
super(); |
||||
|
||||
this.Banners = new BannersRaw(db.collection('rocketchat_banner')); |
||||
this.BannersDismiss = new BannersDismissRaw(db.collection('rocketchat_banner_dismiss')); |
||||
this.Users = new UsersRaw(db.collection('users')); |
||||
} |
||||
|
||||
async create(doc: Omit<IBanner, '_id'>): Promise<IBanner> { |
||||
const bannerId = uuidv4(); |
||||
|
||||
doc.view.appId = 'banner-core'; |
||||
doc.view.viewId = bannerId; |
||||
|
||||
const invalidPlatform = doc.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform)); |
||||
if (invalidPlatform) { |
||||
throw new Error('Invalid platform'); |
||||
} |
||||
|
||||
if (doc.startAt > doc.expireAt) { |
||||
throw new Error('Start date cannot be later than expire date'); |
||||
} |
||||
|
||||
if (doc.expireAt < new Date()) { |
||||
throw new Error('Cannot create banner already expired'); |
||||
} |
||||
|
||||
await this.Banners.insertOne({ |
||||
_id: bannerId, |
||||
...doc, |
||||
}); |
||||
|
||||
const banner = await this.Banners.findOneById(bannerId); |
||||
if (!banner) { |
||||
throw new Error('error-creating-banner'); |
||||
} |
||||
|
||||
api.broadcast('banner.new', banner._id); |
||||
|
||||
return banner; |
||||
} |
||||
|
||||
async getNewBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]> { |
||||
const { roles } = await this.Users.findOneById(userId, { projection: { roles: 1 } }); |
||||
|
||||
const banners = await this.Banners.findActiveByRoleOrId(roles, platform, bannerId).toArray(); |
||||
|
||||
const bannerIds = banners.map(({ _id }) => _id); |
||||
|
||||
const result = await this.BannersDismiss.findByUserIdAndBannerId(userId, bannerIds, { projection: { bannerId: 1, _id: 0 } }).toArray(); |
||||
|
||||
const dismissed = new Set(result.map(({ bannerId }) => bannerId)); |
||||
|
||||
return banners.filter((banner) => !dismissed.has(banner._id)); |
||||
} |
||||
|
||||
async dismiss(userId: string, bannerId: string): Promise<boolean> { |
||||
if (!userId || !bannerId) { |
||||
throw new Error('Invalid params'); |
||||
} |
||||
|
||||
const banner = await this.Banners.findOneById(bannerId); |
||||
if (!banner) { |
||||
throw new Error('Banner not found'); |
||||
} |
||||
|
||||
const user = await this.Users.findOneById(userId, { projection: { username: 1 } }); |
||||
if (!user) { |
||||
throw new Error('User not found'); |
||||
} |
||||
|
||||
const dismissedBy = { |
||||
_id: user._id, |
||||
username: user.username, |
||||
}; |
||||
|
||||
const today = new Date(); |
||||
|
||||
const doc = { |
||||
userId, |
||||
bannerId, |
||||
dismissedBy, |
||||
dismissedAt: today, |
||||
_updatedAt: today, |
||||
}; |
||||
|
||||
await this.BannersDismiss.insertOne(doc); |
||||
|
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,42 @@ |
||||
import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; |
||||
import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; |
||||
import moment from 'moment'; |
||||
|
||||
import { settings } from '../../../app/settings/server'; |
||||
import { IBanner, BannerPlatform } from '../../../definition/IBanner'; |
||||
|
||||
export const getBannerForAdmins = Meteor.bindEnvironment((): Omit<IBanner, '_id'> => { |
||||
const lng = settings.get('Language') || 'en'; |
||||
|
||||
const today = new Date(); |
||||
const inTwoMonths = new Date(); |
||||
inTwoMonths.setMonth(inTwoMonths.getMonth() + 2); |
||||
|
||||
return { |
||||
platform: [BannerPlatform.Web], |
||||
createdAt: today, |
||||
expireAt: inTwoMonths, |
||||
startAt: today, |
||||
roles: ['admin'], |
||||
createdBy: { |
||||
_id: 'rocket.cat', |
||||
username: 'rocket.cat', |
||||
}, |
||||
_updatedAt: new Date(), |
||||
view: { |
||||
viewId: '', |
||||
appId: '', |
||||
blocks: [{ |
||||
type: BlockType.SECTION, |
||||
blockId: 'attention', |
||||
text: { |
||||
type: TextObjectType.PLAINTEXT, |
||||
text: TAPi18n.__('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { date: moment(inTwoMonths).format('YYYY-MM-DD'), lng }), |
||||
emoji: false, |
||||
}, |
||||
}], |
||||
}, |
||||
}; |
||||
}); |
@ -0,0 +1,31 @@ |
||||
import { HTTP } from 'meteor/http'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { settings } from '../../../app/settings/server'; |
||||
import { getWorkspaceAccessToken } from '../../../app/cloud/server'; |
||||
import { INpsVote } from '../../../definition/INps'; |
||||
|
||||
type NPSResultPayload = { |
||||
total: number; |
||||
votes: INpsVote[]; |
||||
} |
||||
|
||||
export const sendToCloud = Meteor.bindEnvironment(function sendToCloud(npsId: string, data: NPSResultPayload) { |
||||
const token: string = getWorkspaceAccessToken(true); |
||||
if (!token) { |
||||
return false; |
||||
} |
||||
|
||||
const cloudUrl = settings.get('Cloud_Url'); |
||||
|
||||
try { |
||||
return HTTP.post(`${ cloudUrl }/v1/nps/surveys/${ npsId }/results`, { |
||||
headers: { |
||||
Authorization: `Bearer ${ token }`, |
||||
}, |
||||
data, |
||||
}); |
||||
} catch (e) { |
||||
throw new Error(e); |
||||
} |
||||
}); |
@ -0,0 +1,196 @@ |
||||
import { createHash } from 'crypto'; |
||||
|
||||
import { Db } from 'mongodb'; |
||||
|
||||
import { NpsRaw } from '../../../app/models/server/raw/Nps'; |
||||
import { NpsVoteRaw } from '../../../app/models/server/raw/NpsVote'; |
||||
import { SettingsRaw } from '../../../app/models/server/raw/Settings'; |
||||
import { NPSStatus, INpsVoteStatus, INpsVote } from '../../../definition/INps'; |
||||
import { INPSService, NPSVotePayload, NPSCreatePayload } from '../../sdk/types/INPSService'; |
||||
import { ServiceClass } from '../../sdk/types/ServiceClass'; |
||||
import { Banner, NPS } from '../../sdk'; |
||||
import { sendToCloud } from './sendToCloud'; |
||||
import { getBannerForAdmins } from './getBannerForAdmins'; |
||||
|
||||
export class NPSService extends ServiceClass implements INPSService { |
||||
protected name = 'nps'; |
||||
|
||||
private Nps: NpsRaw; |
||||
|
||||
private Settings: SettingsRaw; |
||||
|
||||
private NpsVote: NpsVoteRaw; |
||||
|
||||
constructor(db: Db) { |
||||
super(); |
||||
|
||||
this.Nps = new NpsRaw(db.collection('rocketchat_nps')); |
||||
this.NpsVote = new NpsVoteRaw(db.collection('rocketchat_nps_vote')); |
||||
this.Settings = new SettingsRaw(db.collection('rocketchat_settings')); |
||||
} |
||||
|
||||
async create(nps: NPSCreatePayload): Promise<boolean> { |
||||
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled'); |
||||
if (!npsEnabled) { |
||||
throw new Error('Server opted-out for NPS surveys'); |
||||
} |
||||
|
||||
const any = await this.Nps.findOne({}, { projection: { _id: 1 } }); |
||||
if (!any) { |
||||
Banner.create(getBannerForAdmins()); |
||||
} |
||||
|
||||
const { |
||||
npsId, |
||||
startAt, |
||||
expireAt, |
||||
createdBy, |
||||
} = nps; |
||||
|
||||
const { result } = await this.Nps.save({ |
||||
_id: npsId, |
||||
startAt, |
||||
expireAt, |
||||
createdBy, |
||||
status: NPSStatus.OPEN, |
||||
}); |
||||
if (!result) { |
||||
throw new Error('Error creating NPS'); |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
async sendResults(): Promise<void> { |
||||
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled'); |
||||
if (!npsEnabled) { |
||||
return; |
||||
} |
||||
|
||||
const npsSending = await this.Nps.getOpenExpiredAlreadySending(); |
||||
|
||||
const nps = npsSending || await this.Nps.getOpenExpiredAndStartSending(); |
||||
if (!nps) { |
||||
return; |
||||
} |
||||
|
||||
const total = await this.NpsVote.findByNpsId(nps._id).count(); |
||||
|
||||
const votesToSend = await this.NpsVote.findNotSentByNpsId(nps._id).toArray(); |
||||
|
||||
// if there is nothing to sent, check if something gone wrong
|
||||
if (votesToSend.length === 0) { |
||||
// check if still has votes left to send
|
||||
const totalSent = await this.NpsVote.findByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT).count(); |
||||
if (totalSent === total) { |
||||
await this.Nps.updateStatusById(nps._id, NPSStatus.SENT); |
||||
return; |
||||
} |
||||
|
||||
// update old votes (sent 5 minutes ago or more) in 'sending' status back to 'new'
|
||||
await this.NpsVote.updateOldSendingToNewByNpsId(nps._id); |
||||
|
||||
// try again in 5 minutes
|
||||
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000); |
||||
return; |
||||
} |
||||
|
||||
const today = new Date(); |
||||
|
||||
const sending = await Promise.all(votesToSend.map(async (vote) => { |
||||
const { value } = await this.NpsVote.col.findOneAndUpdate({ |
||||
_id: vote._id, |
||||
status: INpsVoteStatus.NEW, |
||||
}, { |
||||
$set: { |
||||
status: INpsVoteStatus.SENDING, |
||||
sentAt: today, |
||||
}, |
||||
}, { |
||||
projection: { |
||||
identifier: 1, |
||||
roles: 1, |
||||
score: 1, |
||||
comment: 1, |
||||
}, |
||||
}); |
||||
return value; |
||||
})); |
||||
|
||||
const votes = sending.filter(Boolean) as INpsVote[]; |
||||
if (votes.length > 0) { |
||||
const voteIds = votes.map(({ _id }) => _id); |
||||
|
||||
const payload = { |
||||
total, |
||||
votes, |
||||
}; |
||||
sendToCloud(nps._id, payload); |
||||
|
||||
this.NpsVote.updateVotesToSent(voteIds); |
||||
} |
||||
|
||||
const totalSent = await this.NpsVote.findByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT).count(); |
||||
if (totalSent < total) { |
||||
// send more in five minutes
|
||||
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000); |
||||
return; |
||||
} |
||||
|
||||
await this.Nps.updateStatusById(nps._id, NPSStatus.SENT); |
||||
} |
||||
|
||||
async vote({ |
||||
userId, |
||||
npsId, |
||||
roles, |
||||
score, |
||||
comment, |
||||
}: NPSVotePayload): Promise<void> { |
||||
const npsEnabled = await this.Settings.getValueById('NPS_survey_enabled'); |
||||
if (!npsEnabled) { |
||||
return; |
||||
} |
||||
|
||||
if (!npsId || typeof npsId !== 'string') { |
||||
throw new Error('Invalid NPS id'); |
||||
} |
||||
|
||||
const nps = await this.Nps.findOneById(npsId, { projection: { status: 1, startAt: 1, expireAt: 1 } }); |
||||
if (!nps) { |
||||
return; |
||||
} |
||||
|
||||
if (nps.status !== NPSStatus.OPEN) { |
||||
throw new Error('NPS not open for votes'); |
||||
} |
||||
|
||||
const today = new Date(); |
||||
if (today > nps.expireAt) { |
||||
throw new Error('NPS expired'); |
||||
} |
||||
|
||||
if (today < nps.startAt) { |
||||
throw new Error('NPS survey not started'); |
||||
} |
||||
|
||||
const identifier = createHash('sha256').update(`${ userId }${ npsId }`).digest('hex'); |
||||
|
||||
const result = await this.NpsVote.save({ |
||||
ts: new Date(), |
||||
npsId, |
||||
identifier, |
||||
roles, |
||||
score, |
||||
comment, |
||||
status: INpsVoteStatus.NEW, |
||||
}); |
||||
if (!result) { |
||||
throw new Error('Error saving NPS vote'); |
||||
} |
||||
} |
||||
|
||||
async closeOpenSurveys(): Promise<void> { |
||||
await this.Nps.closeAllByStatus(NPSStatus.OPEN); |
||||
} |
||||
} |
@ -0,0 +1,59 @@ |
||||
import { ServiceClass } from '../../sdk/types/ServiceClass'; |
||||
import { IUiKitCoreApp, IUiKitCoreAppService } from '../../sdk/types/IUiKitCoreApp'; |
||||
|
||||
const registeredApps = new Map(); |
||||
|
||||
const getAppModule = (appId: string): any => { |
||||
const module = registeredApps.get(appId); |
||||
|
||||
if (typeof module === 'undefined') { |
||||
throw new Error('invalid service name'); |
||||
} |
||||
|
||||
return module; |
||||
}; |
||||
|
||||
export const registerCoreApp = (module: IUiKitCoreApp): void => { |
||||
registeredApps.set(module.appId, module); |
||||
}; |
||||
|
||||
export class UiKitCoreApp extends ServiceClass implements IUiKitCoreAppService { |
||||
protected name = 'uikit-core-app'; |
||||
|
||||
async isRegistered(appId: string): Promise<boolean> { |
||||
return registeredApps.has(appId); |
||||
} |
||||
|
||||
async blockAction(payload: any): Promise<any> { |
||||
const { appId } = payload; |
||||
|
||||
const service = getAppModule(appId); |
||||
if (!service) { |
||||
return; |
||||
} |
||||
|
||||
return service.blockAction?.(payload); |
||||
} |
||||
|
||||
async viewClosed(payload: any): Promise<any> { |
||||
const { appId } = payload; |
||||
|
||||
const service = getAppModule(appId); |
||||
if (!service) { |
||||
return; |
||||
} |
||||
|
||||
return service.viewClosed?.(payload); |
||||
} |
||||
|
||||
async viewSubmit(payload: any): Promise<any> { |
||||
const { appId } = payload; |
||||
|
||||
const service = getAppModule(appId); |
||||
if (!service) { |
||||
return; |
||||
} |
||||
|
||||
return service.viewSubmit?.(payload); |
||||
} |
||||
} |
@ -0,0 +1,6 @@ |
||||
import { Nps } from '../modules/core-apps/nps.module'; |
||||
import { BannerModule } from '../modules/core-apps/banner.module'; |
||||
import { registerCoreApp } from '../services/uikit-core-app/service'; |
||||
|
||||
registerCoreApp(new Nps()); |
||||
registerCoreApp(new BannerModule()); |
Loading…
Reference in new issue