Skip to content

Commit

Permalink
feat(kudos): send kudos at the end of the retro (#9288)
Browse files Browse the repository at this point in the history
* feat(kudos): send kudos by text in standups

* Remove console logs

* Fix test

* Store unicode emoji too

* Link teamPromptResponseId

* Update slack notification

* Update email notification

* Mention notification analytics

* response mentioned toast analytics

* Enable mentions in retro reflections

* feat(kudos): enable mentions in retro reflections

* feat(kudos): send kudos at the end of the retro

* Handle anonymous notifications

* Make some params optional

* make editorRef optional

* Clear undo redo stack

* remove any

* rebuild

* make name and picture nullable

* Change anonymous to someone
  • Loading branch information
igorlesnenko authored Jan 12, 2024
1 parent c9d4110 commit aef83a7
Show file tree
Hide file tree
Showing 11 changed files with 163 additions and 18 deletions.
5 changes: 3 additions & 2 deletions packages/client/components/KudosReceivedNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {KudosReceivedNotification_notification$key} from '~/__generated__/KudosR
import NotificationTemplate from './NotificationTemplate'
import useAtmosphere from '../hooks/useAtmosphere'
import SendClientSideEvent from '../utils/SendClientSideEvent'
import anonymousAvatar from '../styles/theme/images/anonymous-avatar.svg'

interface Props {
notification: KudosReceivedNotification_notification$key
Expand Down Expand Up @@ -43,14 +44,14 @@ const KudosReceivedNotification = (props: Props) => {
<NotificationTemplate
message={
<>
{emojiUnicode} {name} gave you kudos in{' '}
{emojiUnicode} {name ?? 'Someone'} gave you kudos in{' '}
<Link to={`/meet/${meetingId}`} className='font-semibold text-sky-500 underline'>
{meetingName}
</Link>
</>
}
notification={notification}
avatar={picture}
avatar={picture ?? anonymousAvatar}
/>
)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client/components/NotificationTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import NotificationMessage from './NotificationMessage'
import NotificationRow from './NotificationRow'

interface Props {
avatar?: string
avatar?: string | null
message: ReactNode
notification: NotificationTemplate_notification$key
action?: ReactNode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const mapKudosReceivedToToast = (
autoDismiss: 5,
showDismissButton: true,
key: makeNotificationToastKey(notificationId),
message: `${emojiUnicode} ${name} gave you kudos in`,
message: `${emojiUnicode} ${name ?? 'Someone'} gave you kudos in`,
action: {
label: meetingName,
callback: () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/server/database/types/NotificationKudosReceived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Notification from './Notification'

interface Input {
userId: string
name: string
picture: string
name: string | null
picture: string | null
senderUserId: string
meetingName: string
meetingId: string
Expand All @@ -13,8 +13,8 @@ interface Input {

export default class NotificationKudosReceived extends Notification {
readonly type = 'KUDOS_RECEIVED'
name: string
picture: string
name: string | null
picture: string | null
senderUserId: string
meetingName: string
meetingId: string
Expand Down
114 changes: 113 additions & 1 deletion packages/server/graphql/mutations/helpers/safeEndRetrospective.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {SubscriptionChannel} from 'parabol-client/types/constEnums'
import {DISCUSS, PARABOL_AI_USER_ID} from 'parabol-client/utils/constants'
import getMeetingPhase from 'parabol-client/utils/getMeetingPhase'
import findStageById from 'parabol-client/utils/meetings/findStageById'
import {RawDraftContentState} from 'draft-js'
import {checkTeamsLimit} from '../../../billing/helpers/teamLimitsCheck'
import getRethink from '../../../database/rethinkDriver'
import {RDatum} from '../../../database/stricterR'
Expand All @@ -13,6 +14,7 @@ import {analytics} from '../../../utils/analytics/analytics'
import {getUserId} from '../../../utils/authorization'
import getPhase from '../../../utils/getPhase'
import publish from '../../../utils/publish'
import publishNotification from '../../public/mutations/helpers/publishNotification'
import RecallAIServerManager from '../../../utils/RecallAIServerManager'
import sendToSentry from '../../../utils/sendToSentry'
import standardError from '../../../utils/standardError'
Expand All @@ -26,13 +28,122 @@ import {IntegrationNotifier} from './notifications/IntegrationNotifier'
import removeEmptyTasks from './removeEmptyTasks'
import updateQualAIMeetingsCount from './updateQualAIMeetingsCount'
import gatherInsights from './gatherInsights'
import NotificationKudosReceived from '../../../database/types/NotificationKudosReceived'

const getTranscription = async (recallBotId?: string | null) => {
if (!recallBotId) return
const manager = new RecallAIServerManager()
return await manager.getBotTranscript(recallBotId)
}

const sendKudos = async (
meeting: MeetingRetrospective,
teamId: string,
context: InternalContext
) => {
const {dataLoader, socketId: mutatorId} = context
const operationId = dataLoader.share()
const subOptions = {mutatorId, operationId}
const {id: meetingId, disableAnonymity} = meeting
const isAnonymous = !disableAnonymity
const pg = getKysely()
const r = await getRethink()

const [reflections, team] = await Promise.all([
dataLoader.get('retroReflectionsByMeetingId').load(meetingId),
dataLoader.get('teams').loadNonNull(teamId)
])

const {giveKudosWithEmoji, kudosEmojiUnicode, kudosEmoji} = team

if (!giveKudosWithEmoji || !kudosEmojiUnicode) {
return
}

const kudosToInsert: {
senderUserId: string
receiverUserId: any
teamId: string
emoji: string
emojiUnicode: string
isAnonymous: boolean
reflectionId: string
}[] = []

const notificationsToInsert: NotificationKudosReceived[] = []

for (const reflection of reflections) {
const {id: reflectionId, content, plaintextContent, creatorId} = reflection
const senderUser = await dataLoader.get('users').loadNonNull(creatorId)

const contentJson = JSON.parse(content) as RawDraftContentState

if (plaintextContent.includes(kudosEmojiUnicode) && contentJson.entityMap) {
const mentions = Object.values(contentJson.entityMap).filter(
(entity) => entity.type === 'MENTION'
)

if (mentions.length) {
const userIds = [...new Set(mentions.map((mention) => mention.data.userId))].filter(
(userId) => userId !== creatorId
)

if (userIds.length) {
userIds.forEach((userId) => {
kudosToInsert.push({
senderUserId: creatorId,
receiverUserId: userId,
teamId,
emoji: kudosEmoji,
emojiUnicode: kudosEmojiUnicode,
isAnonymous,
reflectionId
})

notificationsToInsert.push(
new NotificationKudosReceived({
userId,
senderUserId: creatorId,
meetingId,
meetingName: meeting.name,
emoji: team.kudosEmoji,
emojiUnicode: team.kudosEmojiUnicode,
name: isAnonymous ? null : senderUser.preferredName,
picture: isAnonymous ? null : senderUser.picture
})
)
})
}
}
}
}

if (kudosToInsert.length) {
const [insertedKudoses] = await Promise.all([
pg
.insertInto('Kudos')
.values(kudosToInsert)
.returning(['id', 'senderUserId', 'receiverUserId', 'emoji', 'emojiUnicode'])
.execute(),
r.table('Notification').insert(notificationsToInsert).run()
])

insertedKudoses.forEach((kudos) => {
analytics.kudosSent(
{id: kudos.senderUserId},
teamId,
kudos.id,
kudos.receiverUserId,
isAnonymous
)
})

notificationsToInsert.forEach((notification) => {
publishNotification(notification, subOptions)
})
}
}

const summarizeRetroMeeting = async (meeting: MeetingRetrospective, context: InternalContext) => {
const {dataLoader, authToken} = context
const {id: meetingId, phases, facilitatorUserId, teamId, recallBotId} = meeting
Expand Down Expand Up @@ -177,7 +288,8 @@ const safeEndRetrospective = async ({
.filter({isActive: false})
.delete()
.run(),
updateTeamInsights(teamId, dataLoader)
updateTeamInsights(teamId, dataLoader),
sendKudos(meeting, teamId, context)
])
// wait for removeEmptyTasks before summarizeRetroMeeting
// don't await for the OpenAI response or it'll hang for a while when ending the retro
Expand Down
3 changes: 2 additions & 1 deletion packages/server/graphql/public/typeDefs/Kudos.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ type Kudos {

"""
Use who sent kudos
Can be null if kudos is anonymous
"""
senderUser: User!
senderUser: User

"""
emoji name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ type NotifyKudosReceived implements Notification {
"""
Sender name
"""
name: String!
name: String

"""
Sender picture
"""
picture: URL!
picture: URL

"""
Meeting name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ type NotifyRequestToJoinOrg implements Notification {
"""
Name of the user who made the request
"""
name: String!
name: String

"""
Picture of the user who made the request
"""
picture: URL!
picture: URL

"""
Request created by userId
Expand Down
4 changes: 2 additions & 2 deletions packages/server/graphql/public/types/Kudos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ const Kudos: KudosResolvers = {
receiverUser: async ({receiverUserId}, _args, {dataLoader}) => {
return dataLoader.get('users').loadNonNull(receiverUserId)
},
senderUser: async ({senderUserId}, _args, {dataLoader}) => {
return dataLoader.get('users').loadNonNull(senderUserId)
senderUser: async ({senderUserId, isAnonymous}, _args, {dataLoader}) => {
return isAnonymous ? null : dataLoader.get('users').loadNonNull(senderUserId)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {Client} from 'pg'
import getPgConfig from '../getPgConfig'

export async function up() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
ALTER TABLE "Kudos"
ADD COLUMN IF NOT EXISTS "isAnonymous" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS "reflectionId" VARCHAR(100);
`)
await client.end()
}

export async function down() {
const client = new Client(getPgConfig())
await client.connect()
await client.query(`
ALTER TABLE "Kudos"
DROP COLUMN "isAnonymous",
DROP COLUMN "reflectionId";
`)
await client.end()
}
11 changes: 9 additions & 2 deletions packages/server/utils/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,12 +715,19 @@ class Analytics {
this.track(user, 'AutoJoined Team', {userId: user.id, teamId})
}

kudosSent = (user: AnalyticsUser, teamId: string, kudosId: number, receiverUserId: string) => {
kudosSent = (
user: AnalyticsUser,
teamId: string,
kudosId: number,
receiverUserId: string,
isAnonymous = false
) => {
this.track(user, 'Kudos Sent', {
userId: user.id,
teamId,
kudosId,
receiverUserId
receiverUserId,
isAnonymous
})
}

Expand Down

0 comments on commit aef83a7

Please sign in to comment.