From bfd806ab29ef9d94c83c2c9792fea995a836e153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Bari=C4=87?= Date: Fri, 9 Aug 2024 11:46:44 +0200 Subject: [PATCH 1/6] feat(slack): add source name and image for attachment (#2122) --- __tests__/workers/postAddedSlackChannelSend.ts | 16 ++++++++-------- src/common/userIntegration.ts | 5 +++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/__tests__/workers/postAddedSlackChannelSend.ts b/__tests__/workers/postAddedSlackChannelSend.ts index 836a30c86..d6ae9765c 100644 --- a/__tests__/workers/postAddedSlackChannelSend.ts +++ b/__tests__/workers/postAddedSlackChannelSend.ts @@ -148,8 +148,8 @@ describe('postAddedSlackChannelSend worker', () => { channel: '1', attachments: [ { - author_icon: 'https://app.daily.dev/apple-touch-icon.png', - author_name: 'daily.dev', + author_icon: 'http://image.com/a', + author_name: 'A | daily.dev', image_url: 'https://daily.dev/image.jpg', title: 'P1', title_link: @@ -191,8 +191,8 @@ describe('postAddedSlackChannelSend worker', () => { channel: '1', attachments: [ { - author_icon: 'https://app.daily.dev/apple-touch-icon.png', - author_name: 'daily.dev', + author_icon: 'http//image.com/squadslackchannel', + author_name: 'Squad Slack Channel | daily.dev', image_url: 'https://daily.dev/image.jpg', title: 'Squad Channel Post 1', title_link: @@ -242,8 +242,8 @@ describe('postAddedSlackChannelSend worker', () => { channel: '1', attachments: [ { - author_icon: 'https://app.daily.dev/apple-touch-icon.png', - author_name: 'daily.dev', + author_icon: 'http//image.com/squadslackchannel', + author_name: 'Squad Slack Channel | daily.dev', image_url: 'https://daily.dev/image.jpg', title: 'Squad Channel Post 1', title_link: @@ -309,8 +309,8 @@ describe('postAddedSlackChannelSend worker', () => { channel: '1', attachments: [ { - author_icon: 'https://app.daily.dev/apple-touch-icon.png', - author_name: 'daily.dev', + author_icon: 'http//image.com/squadslackchannel', + author_name: 'Squad Slack Channel | daily.dev', image_url: 'https://daily.dev/image.jpg', title: 'Squad Channel Post 1', title_link: diff --git a/src/common/userIntegration.ts b/src/common/userIntegration.ts index 12c1f3038..9e8165dff 100644 --- a/src/common/userIntegration.ts +++ b/src/common/userIntegration.ts @@ -69,9 +69,10 @@ export const getAttachmentForPostType = async ({ postType: TPostType; postLink: string; }): Promise => { + const source = await post.source; const attachment: MessageAttachment = { - author_name: 'daily.dev', - author_icon: 'https://app.daily.dev/apple-touch-icon.png', + author_name: `${source.name} | daily.dev`, + author_icon: source.image, }; switch (postType) { From 1dfa07175fd787333d4e7dbe0a48275f9e6a6e0f Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 9 Aug 2024 16:11:01 +0200 Subject: [PATCH 2/6] feat: add option to search user suggestions (#2124) --- __tests__/search.ts | 119 +++++++++++++++++++++++++++++++++++++++++++ src/schema/search.ts | 53 +++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/__tests__/search.ts b/__tests__/search.ts index 44a91f2ca..ac50d154b 100644 --- a/__tests__/search.ts +++ b/__tests__/search.ts @@ -17,6 +17,7 @@ import { ArticlePost, Keyword, Source, User, UserPost } from '../src/entity'; import { postsFixture } from './fixture/post'; import { sourcesFixture } from './fixture/source'; import { usersFixture } from './fixture/user'; +import { ghostUser, updateFlagsStatement } from '../src/common'; let con: DataSource; let state: GraphQLTestingState; @@ -528,3 +529,121 @@ describe('query searchSourceSuggestions', () => { ]); }); }); + +describe('query searchUserSuggestions', () => { + const QUERY = (query: string): string => `{ + searchUserSuggestions(query: "${query}") { + query + hits { + id + title + subtitle + image + } + } + } +`; + + beforeEach(async () => { + await saveFixtures(con, User, usersFixture); + }); + + it('should return search suggestions', async () => { + const res = await client.query(QUERY('i')); + expect(res.errors).toBeFalsy(); + expect(res.data.searchUserSuggestions).toBeTruthy(); + + const result = res.data.searchUserSuggestions; + + expect(result.query).toBe('i'); + expect(result.hits).toHaveLength(3); + expect(result.hits).toMatchObject([ + { + id: '1', + image: 'https://daily.dev/ido.jpg', + subtitle: 'idoshamun', + title: 'Ido', + }, + { + id: '2', + image: 'https://daily.dev/tsahi.jpg', + subtitle: 'tsahidaily', + title: 'Tsahi', + }, + { + id: '3', + image: 'https://daily.dev/nimrod.jpg', + subtitle: 'nimroddaily', + title: 'Nimrod', + }, + ]); + }); + + it('should only return infoConfirmed users', async () => { + await con.getRepository(User).update({ id: '3' }, { infoConfirmed: false }); + const res = await client.query(QUERY('i')); + expect(res.data.searchUserSuggestions).toBeTruthy(); + + const result = res.data.searchUserSuggestions; + + expect(result.query).toBe('i'); + expect(result.hits).toHaveLength(2); + expect(result.hits).toMatchObject([ + { + id: '1', + image: 'https://daily.dev/ido.jpg', + subtitle: 'idoshamun', + title: 'Ido', + }, + { + id: '2', + image: 'https://daily.dev/tsahi.jpg', + subtitle: 'tsahidaily', + title: 'Tsahi', + }, + ]); + }); + + it('should only return vodr false users', async () => { + await con.getRepository(User).update( + { id: '3' }, + { + flags: updateFlagsStatement({ + vordr: true, + }), + }, + ); + const res = await client.query(QUERY('i')); + expect(res.data.searchUserSuggestions).toBeTruthy(); + + const result = res.data.searchUserSuggestions; + + expect(result.query).toBe('i'); + expect(result.hits).toHaveLength(2); + expect(result.hits).toMatchObject([ + { + id: '1', + image: 'https://daily.dev/ido.jpg', + subtitle: 'idoshamun', + title: 'Ido', + }, + { + id: '2', + image: 'https://daily.dev/tsahi.jpg', + subtitle: 'tsahidaily', + title: 'Tsahi', + }, + ]); + }); + + it('should not return 404 user', async () => { + await saveFixtures(con, User, [ghostUser]); + const res = await client.query(QUERY('ghost')); + expect(res.data.searchUserSuggestions).toBeTruthy(); + + const result = res.data.searchUserSuggestions; + + expect(result.query).toBe('ghost'); + expect(result.hits).toHaveLength(0); + }); +}); diff --git a/src/schema/search.ts b/src/schema/search.ts index 1ef4ae039..ab6662676 100644 --- a/src/schema/search.ts +++ b/src/schema/search.ts @@ -24,6 +24,8 @@ import { getSearchLimit, } from '../common/search'; import { getOffsetWithDefault } from 'graphql-relay'; +import { Brackets } from 'typeorm'; +import { whereVordrFilter } from '../common/vordr'; type GQLSearchSession = Pick; @@ -211,6 +213,26 @@ export const typeDefs = /* GraphQL */ ` """ version: Int = 2 ): SearchSuggestionsResults! + + """ + Get users for search users query + """ + searchUserSuggestions( + """ + The query to search for + """ + query: String! + + """ + Maximum number of users to return + """ + limit: Int = ${defaultSearchLimit} + + """ + Version of the search algorithm + """ + version: Int = 2 + ): SearchSuggestionsResults! } extend type Mutation { @@ -388,6 +410,37 @@ export const resolvers: IResolvers = traceResolvers< .limit(getSearchLimit({ limit })); const hits = await searchQuery.getRawMany(); + return { + query, + hits, + }; + }, + searchUserSuggestions: async ( + source, + { query, limit }: SearchSuggestionArgs, + ctx, + ): Promise => { + const searchQuery = ctx.con + .createQueryBuilder() + .select(`id, name as title, username as subtitle, image`) + .from('user', 'u') + .where('u.infoConfirmed = TRUE') + .andWhere("u.id != '404'") + .andWhere( + new Brackets((qb) => { + return qb + .where(`name ILIKE :query`, { + query: `%${query}%`, + }) + .orWhere(`username ILIKE :query`, { + query: `%${query}%`, + }); + }), + ) + .andWhere(whereVordrFilter('u')) + .limit(getSearchLimit({ limit })); + const hits = await searchQuery.getRawMany(); + return { query, hits, From 73c643735bc5ef8c877a6e3510987c29015dd16a Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Sun, 11 Aug 2024 10:41:54 +0200 Subject: [PATCH 3/6] chore: optimize user search indexes (#2126) --- src/entity/user/User.ts | 2 ++ src/migration/1723215750644-UserGinIndexes.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/migration/1723215750644-UserGinIndexes.ts diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index bc1e174ad..00d3b0fca 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -59,6 +59,8 @@ export type UserFlags = Partial<{ @Index('IDX_user_lowerusername_username', { synchronize: false }) @Index('IDX_user_lowertwitter', { synchronize: false }) @Index('IDX_user_loweremail', { synchronize: false }) +@Index('IDX_user_gin_username', { synchronize: false }) +@Index('IDX_user_gin_name', { synchronize: false }) export class User { @PrimaryColumn({ length: 36 }) id: string; diff --git a/src/migration/1723215750644-UserGinIndexes.ts b/src/migration/1723215750644-UserGinIndexes.ts new file mode 100644 index 000000000..7fc51e67c --- /dev/null +++ b/src/migration/1723215750644-UserGinIndexes.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserGinIndexes1723215750644 implements MigrationInterface { + name = 'UserGinIndexes1723215750644'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IDX_user_gin_username ON "user" USING GIN ("username" gin_trgm_ops)`); + await queryRunner.query(` + CREATE INDEX IDX_user_gin_name ON "user" USING GIN ("name" gin_trgm_ops)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_user_gin_username"`); + await queryRunner.query(`DROP INDEX "public"."IDX_user_gin_name"`); + } +} From 5ccdce4a2df2c3e1e650471777574a7957a9acfd Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Sun, 11 Aug 2024 15:23:25 +0200 Subject: [PATCH 4/6] chore: update 404 hardcode to ghostuser (#2125) --- __tests__/common/mailing.ts | 8 ++++-- __tests__/users.ts | 41 ++++------------------------- __tests__/workers/userCreatedCio.ts | 8 ++++-- __tests__/workers/userUpdatedCio.ts | 8 ++++-- src/common/markdown.ts | 3 ++- src/directive/user.ts | 5 ++-- src/entity/user/UserStats.ts | 3 ++- src/schema/leaderboard.ts | 8 +++--- src/schema/search.ts | 9 +++++-- 9 files changed, 41 insertions(+), 52 deletions(-) diff --git a/__tests__/common/mailing.ts b/__tests__/common/mailing.ts index e69008238..990969b3e 100644 --- a/__tests__/common/mailing.ts +++ b/__tests__/common/mailing.ts @@ -4,7 +4,11 @@ import { saveFixtures } from '../helpers'; import createOrGetConnection from '../../src/db'; import { User } from '../../src/entity'; import { usersFixture } from '../fixture/user'; -import { CioUnsubscribeTopic, syncSubscription } from '../../src/common'; +import { + CioUnsubscribeTopic, + ghostUser, + syncSubscription, +} from '../../src/common'; let con: DataSource; @@ -48,7 +52,7 @@ describe('mailing', () => { const users = await con.getRepository(User).find({ where: { - id: Not('404'), + id: Not(ghostUser.id), }, }); diff --git a/__tests__/users.ts b/__tests__/users.ts index f23aa09fb..7cbaf508a 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -58,6 +58,7 @@ import { stackoverflowSocialUrlMatch, threadsSocialUrlMatch, twitterSocialUrlMatch, + ghostUser, } from '../src/common'; import { DataSource, In, IsNull } from 'typeorm'; import createOrGetConnection from '../src/db'; @@ -2536,15 +2537,7 @@ describe('mutation deleteUser', () => { it('should delete user from database', async () => { loggedUser = '1'; - await con.getRepository(User).save([ - { - id: '404', - name: 'Not found', - image: 'https://daily.dev/404.jpg', - timezone: 'utc', - createdAt: new Date(), - }, - ]); + await con.getRepository(User).save([ghostUser]); await client.mutate(MUTATION); @@ -2558,15 +2551,7 @@ describe('mutation deleteUser', () => { it('should delete author ID from post', async () => { loggedUser = '1'; - await con.getRepository(User).save([ - { - id: '404', - name: 'Not found', - image: 'https://daily.dev/404.jpg', - timezone: 'utc', - createdAt: new Date(), - }, - ]); + await con.getRepository(User).save([ghostUser]); await client.mutate(MUTATION); @@ -2577,15 +2562,7 @@ describe('mutation deleteUser', () => { it('should delete scout ID from post', async () => { loggedUser = '1'; - await con.getRepository(User).save([ - { - id: '404', - name: 'Not found', - image: 'https://daily.dev/404.jpg', - timezone: 'utc', - createdAt: new Date(), - }, - ]); + await con.getRepository(User).save([ghostUser]); await client.mutate(MUTATION); @@ -2615,15 +2592,7 @@ describe('DELETE /v1/users/me', () => { const BASE_PATH = '/v1/users/me'; beforeEach(async () => { - await con.getRepository(User).save([ - { - id: '404', - name: 'Not found', - image: 'https://daily.dev/404.jpg', - timezone: 'utc', - createdAt: new Date(), - }, - ]); + await con.getRepository(User).save([ghostUser]); }); it('should not authorize when not logged in', async () => { diff --git a/__tests__/workers/userCreatedCio.ts b/__tests__/workers/userCreatedCio.ts index 50d54f237..7c05b840f 100644 --- a/__tests__/workers/userCreatedCio.ts +++ b/__tests__/workers/userCreatedCio.ts @@ -3,7 +3,11 @@ import worker from '../../src/workers/userCreatedCio'; import { ChangeObject } from '../../src/types'; import { expectSuccessfulTypedBackground } from '../helpers'; import { User } from '../../src/entity'; -import { getShortGenericInviteLink, PubSubSchema } from '../../src/common'; +import { + getShortGenericInviteLink, + ghostUser, + PubSubSchema, +} from '../../src/common'; import { cio } from '../../src/cio'; import { typedWorkers } from '../../src/workers'; import mocked = jest.mocked; @@ -83,7 +87,7 @@ describe('userCreatedCio', () => { }); it('should not update customer.io if user is ghost user', async () => { - const before: ChangeObject = { ...base, id: '404' }; + const before: ChangeObject = { ...base, id: ghostUser.id }; await expectSuccessfulTypedBackground(worker, { user: before, } as unknown as PubSubSchema['api.v1.user-created']); diff --git a/__tests__/workers/userUpdatedCio.ts b/__tests__/workers/userUpdatedCio.ts index 644ffc112..72ce6ecd2 100644 --- a/__tests__/workers/userUpdatedCio.ts +++ b/__tests__/workers/userUpdatedCio.ts @@ -3,7 +3,11 @@ import worker from '../../src/workers/userUpdatedCio'; import { ChangeObject } from '../../src/types'; import { expectSuccessfulTypedBackground } from '../helpers'; import { User } from '../../src/entity'; -import { getShortGenericInviteLink, PubSubSchema } from '../../src/common'; +import { + getShortGenericInviteLink, + ghostUser, + PubSubSchema, +} from '../../src/common'; import { cio } from '../../src/cio'; import { typedWorkers } from '../../src/workers'; import mocked = jest.mocked; @@ -86,7 +90,7 @@ describe('userUpdatedCio', () => { }); it('should not update customer.io if user is ghost user', async () => { - const before: ChangeObject = { ...base, id: '404' }; + const before: ChangeObject = { ...base, id: ghostUser.id }; await expectSuccessfulTypedBackground(worker, { newProfile: before, user: before, diff --git a/src/common/markdown.ts b/src/common/markdown.ts index 12441f74d..08c9e6e7a 100644 --- a/src/common/markdown.ts +++ b/src/common/markdown.ts @@ -5,6 +5,7 @@ import { CommentMention, PostMention, User } from '../entity'; import { DataSource, EntityManager } from 'typeorm'; import { MentionedUser } from '../schema/comments'; import { EntityTarget } from 'typeorm/common/EntityTarget'; +import { ghostUser } from './utils'; export const markdown: MarkdownIt = MarkdownIt({ html: false, @@ -23,7 +24,7 @@ export const markdown: MarkdownIt = MarkdownIt({ }); export const getMentionLink = ({ id, username }: MarkdownMention): string => { - const href = getUserProfileUrl(username || '404'); + const href = getUserProfileUrl(username || ghostUser.id); return `@${username}`; }; diff --git a/src/directive/user.ts b/src/directive/user.ts index 245f1a062..481f28670 100644 --- a/src/directive/user.ts +++ b/src/directive/user.ts @@ -16,6 +16,7 @@ import { User, View, } from '../entity'; +import { ghostUser } from '../common'; export const deleteUser = async ( con: DataSource, @@ -32,7 +33,7 @@ export const deleteUser = async ( await entityManager.getRepository(Comment).update( { userId }, { - userId: '404', + userId: ghostUser.id, }, ); await entityManager.getRepository(Comment).delete({ userId }); @@ -48,7 +49,7 @@ export const deleteUser = async ( // Manually set shared post to 404 dummy user await entityManager .getRepository(Post) - .update({ authorId: userId }, { authorId: '404' }); + .update({ authorId: userId }, { authorId: ghostUser.id }); await entityManager .getRepository(Post) .update({ scoutId: userId }, { scoutId: null }); diff --git a/src/entity/user/UserStats.ts b/src/entity/user/UserStats.ts index 311d194db..919912c3e 100644 --- a/src/entity/user/UserStats.ts +++ b/src/entity/user/UserStats.ts @@ -1,4 +1,5 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; +import { ghostUser } from '../../common'; @ViewEntity({ materialized: true, @@ -40,7 +41,7 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; ) .from('user', 'u') .andWhere('u.infoConfirmed = TRUE') - .andWhere("u.id != '404'"), + .andWhere(`u.id != :ghostId`, { ghostId: ghostUser.id }), }) export class UserStats { @ViewColumn() diff --git a/src/schema/leaderboard.ts b/src/schema/leaderboard.ts index 8b6492a77..975acdf44 100644 --- a/src/schema/leaderboard.ts +++ b/src/schema/leaderboard.ts @@ -4,7 +4,7 @@ import { traceResolvers } from './trace'; import { GQLUser } from './users'; import { User, UserStats, UserStreak } from '../entity'; import { DataSource, In, Not } from 'typeorm'; -import { getLimit } from '../common'; +import { getLimit, ghostUser } from '../common'; export type GQLUserLeaderboard = { score: number; @@ -118,7 +118,7 @@ export const resolvers: IResolvers = traceResolvers({ highestReputation: async (_, args, ctx): Promise => { const users = await ctx.con.getRepository(User).find({ where: { - id: Not(In(['404'])), + id: Not(In([ghostUser.id])), }, order: { reputation: 'DESC' }, take: getLimit(args), @@ -130,7 +130,7 @@ export const resolvers: IResolvers = traceResolvers({ const users = await ctx.con.getRepository(UserStreak).find({ where: { user: { - id: Not(In(['404'])), + id: Not(In([ghostUser.id])), }, }, order: { currentStreak: 'DESC' }, @@ -168,7 +168,7 @@ export const resolvers: IResolvers = traceResolvers({ const users = await ctx.con.getRepository(UserStreak).find({ where: { user: { - id: Not(In(['404'])), + id: Not(In([ghostUser.id])), }, }, order: { totalStreak: 'DESC' }, diff --git a/src/schema/search.ts b/src/schema/search.ts index ab6662676..2abe50172 100644 --- a/src/schema/search.ts +++ b/src/schema/search.ts @@ -14,7 +14,12 @@ import { GQLEmptyResponse, meiliOffsetGenerator } from './common'; import { Connection as ConnectionRelay } from 'graphql-relay/connection/connection'; import graphorm from '../graphorm'; import { ConnectionArguments } from 'graphql-relay/index'; -import { FeedArgs, feedResolver, fixedIdsFeedBuilder } from '../common'; +import { + FeedArgs, + feedResolver, + fixedIdsFeedBuilder, + ghostUser, +} from '../common'; import { GQLPost } from './posts'; import { MeiliPagination, searchMeili } from '../integrations/meilisearch'; import { Keyword, Post, Source, UserPost } from '../entity'; @@ -425,7 +430,7 @@ export const resolvers: IResolvers = traceResolvers< .select(`id, name as title, username as subtitle, image`) .from('user', 'u') .where('u.infoConfirmed = TRUE') - .andWhere("u.id != '404'") + .andWhere(`u.id != :ghostId`, { ghostId: ghostUser.id }) .andWhere( new Brackets((qb) => { return qb From 4f419ce45d85146ff87d482f134cbd9075fb763b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Bari=C4=87?= Date: Mon, 12 Aug 2024 14:10:32 +0200 Subject: [PATCH 5/6] feat: track slack integration connection (#2127) --- __tests__/integrations/slack.ts | 32 ++++++++++++++++++++++++++++++++ src/integrations/analytics.ts | 4 ++++ src/routes/integrations/slack.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/__tests__/integrations/slack.ts b/__tests__/integrations/slack.ts index a50aa6d2b..5f215c2a0 100644 --- a/__tests__/integrations/slack.ts +++ b/__tests__/integrations/slack.ts @@ -12,6 +12,18 @@ import { UserIntegrationType, } from '../../src/entity/UserIntegration'; import { SlackEvent } from '../../src/common'; +import { + AnalyticsEventName, + sendAnalyticsEvent, +} from '../../src/integrations/analytics'; + +jest.mock('../../src/integrations/analytics', () => ({ + ...(jest.requireActual('../../src/integrations/analytics') as Record< + string, + unknown + >), + sendAnalyticsEvent: jest.fn(), +})); let app: FastifyInstance; let con: DataSource; @@ -159,6 +171,16 @@ describe('GET /integrations/slack/auth/callback', () => { slackUserId: 'su1', }, }); + expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1); + expect(sendAnalyticsEvent).toHaveBeenCalledWith([ + { + event_name: AnalyticsEventName.ConfirmAddingWorkspace, + user_id: '1', + app_platform: 'api', + event_timestamp: expect.any(Date), + target_id: UserIntegrationType.Slack, + }, + ]); }); it('should update integration if it already exists', async () => { @@ -218,6 +240,16 @@ describe('GET /integrations/slack/auth/callback', () => { slackUserId: 'su1', }, }); + expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1); + expect(sendAnalyticsEvent).toHaveBeenCalledWith([ + { + event_name: AnalyticsEventName.ConfirmAddingWorkspace, + user_id: '1', + app_platform: 'api', + event_timestamp: expect.any(Date), + target_id: UserIntegrationType.Slack, + }, + ]); }); }); diff --git a/src/integrations/analytics.ts b/src/integrations/analytics.ts index b42ebdf22..851e78fa2 100644 --- a/src/integrations/analytics.ts +++ b/src/integrations/analytics.ts @@ -13,6 +13,10 @@ export type AnalyticsEvent = { user_id: string; }; +export enum AnalyticsEventName { + ConfirmAddingWorkspace = 'confirm adding workspace', +} + export async function sendAnalyticsEvent( events: T[], ): Promise { diff --git a/src/routes/integrations/slack.ts b/src/routes/integrations/slack.ts index 0ad54ea31..289885dfe 100644 --- a/src/routes/integrations/slack.ts +++ b/src/routes/integrations/slack.ts @@ -4,6 +4,7 @@ import { logger } from '../../logger'; import { IntegrationMetaSlack, UserIntegrationSlack, + UserIntegrationType, } from '../../entity/UserIntegration'; import { SlackAuthResponse } from '../../types'; import { RedirectError } from '../../errors'; @@ -14,6 +15,10 @@ import { verifySlackSignature, } from '../../common'; import fetch from 'node-fetch'; +import { + AnalyticsEventName, + sendAnalyticsEvent, +} from '../../integrations/analytics'; const redirectResponse = ({ res, @@ -107,7 +112,10 @@ export default async function (fastify: FastifyInstance): Promise { if (!response.ok) { const message = 'failed to get slack token'; - logger.error({ response }, message); + logger.error( + { err: new Error('HTTP error'), statusCode: response.status }, + message, + ); throw new RedirectError(message); } @@ -159,6 +167,25 @@ export default async function (fastify: FastifyInstance): Promise { }); } + try { + await sendAnalyticsEvent([ + { + event_timestamp: new Date(), + event_name: AnalyticsEventName.ConfirmAddingWorkspace, + app_platform: 'api', + user_id: req.userId, + target_id: UserIntegrationType.Slack, + }, + ]); + } catch (analyticsEventError) { + logger.error( + { + err: analyticsEventError, + }, + 'error sending slack workspace analytics event', + ); + } + return redirectResponse({ res, path: redirectPath }); } catch (error) { const isRedirectError = error instanceof RedirectError; From c6466de39f4f279010c53d3a6152705a5586bda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Bari=C4=87?= Date: Mon, 12 Aug 2024 16:36:37 +0200 Subject: [PATCH 6/6] feat: adjust slack messages (#2128) --- __tests__/integrations.ts | 10 +++ .../workers/postAddedSlackChannelSend.ts | 12 +-- src/common/mailing.ts | 18 +++++ src/schema/integrations.ts | 76 ++++++++++++++----- src/workers/postAddedSlackChannelSend.ts | 25 +++--- 5 files changed, 102 insertions(+), 39 deletions(-) diff --git a/__tests__/integrations.ts b/__tests__/integrations.ts index 13e4b3fb2..4995c8e70 100644 --- a/__tests__/integrations.ts +++ b/__tests__/integrations.ts @@ -278,6 +278,11 @@ describe('slack integration', () => { expect(res.errors).toBeFalsy(); expect(slackPostMessage).toHaveBeenCalledTimes(1); + expect(slackPostMessage).toHaveBeenCalledWith({ + channel: '1', + text: `Ido connected the \"\" Squad to this channel. Important updates from this Squad will be posted here 🙌`, + unfurl_links: false, + }); }); it('should update channel for source', async () => { @@ -328,6 +333,11 @@ describe('slack integration', () => { }); expect(slackPostMessage).toHaveBeenCalledTimes(2); + expect(slackPostMessage).toHaveBeenNthCalledWith(2, { + channel: '2', + text: `Ido connected the \"\" Squad to this channel. Important updates from this Squad will be posted here 🙌`, + unfurl_links: false, + }); }); it('should not allow connecting source if existing connection is already present', async () => { diff --git a/__tests__/workers/postAddedSlackChannelSend.ts b/__tests__/workers/postAddedSlackChannelSend.ts index d6ae9765c..90d991909 100644 --- a/__tests__/workers/postAddedSlackChannelSend.ts +++ b/__tests__/workers/postAddedSlackChannelSend.ts @@ -156,7 +156,7 @@ describe('postAddedSlackChannelSend worker', () => { 'http://localhost:5002/posts/p1?utm_source=notification&utm_medium=slack&utm_campaign=new_post', }, ], - text: 'New post on "A" source. ', + text: 'New post: ', unfurl_links: false, }); }); @@ -196,10 +196,10 @@ describe('postAddedSlackChannelSend worker', () => { image_url: 'https://daily.dev/image.jpg', title: 'Squad Channel Post 1', title_link: - 'http://localhost:5002/posts/squadslackchannelp1?jt=squadslackchanneltoken1&source=squadslackchannel&type=squad&utm_source=notification&utm_medium=slack&utm_campaign=new_post', + 'http://localhost:5002/posts/squadslackchannelp1?utm_source=notification&utm_medium=slack&utm_campaign=new_post&jt=squadslackchanneltoken1&source=squadslackchannel&type=squad', }, ], - text: 'New post on "Squad Slack Channel" Squad. ', + text: 'New post: ', unfurl_links: false, }); }); @@ -250,7 +250,7 @@ describe('postAddedSlackChannelSend worker', () => { 'http://localhost:5002/posts/squadslackchannelp1?utm_source=notification&utm_medium=slack&utm_campaign=new_post', }, ], - text: 'New post on "Squad Slack Channel" Squad. ', + text: 'New post: ', unfurl_links: false, }); }); @@ -314,10 +314,10 @@ describe('postAddedSlackChannelSend worker', () => { image_url: 'https://daily.dev/image.jpg', title: 'Squad Channel Post 1', title_link: - 'http://localhost:5002/posts/squadslackchannelp1?jt=squadslackchanneltoken1&source=squadslackchannel&type=squad&utm_source=notification&utm_medium=slack&utm_campaign=new_post', + 'http://localhost:5002/posts/squadslackchannelp1?utm_source=notification&utm_medium=slack&utm_campaign=new_post&jt=squadslackchanneltoken1&source=squadslackchannel&type=squad', }, ], - text: 'Ido shared a new post on "Squad Slack Channel" Squad. ', + text: 'Ido shared a new post: ', unfurl_links: false, }); }); diff --git a/src/common/mailing.ts b/src/common/mailing.ts index 401db67d6..1dc61dfe5 100644 --- a/src/common/mailing.ts +++ b/src/common/mailing.ts @@ -9,6 +9,7 @@ import { SendEmailRequestOptionalOptions } from 'customerio-node/lib/api/request import { SendEmailRequestWithTemplate } from 'customerio-node/dist/lib/api/requests'; import { DataSource } from 'typeorm'; import { + Source, User, UserPersonalizedDigest, UserPersonalizedDigestType, @@ -143,3 +144,20 @@ export const sendEmail = async ( await cioApi.sendEmail(req); } }; + +export const addPrivateSourceJoinParams = ({ + url, + source, + referralToken, +}: { + url: string; + source: Pick; + referralToken: string; +}): string => { + const urlObj = new URL(url); + urlObj.searchParams.set('jt', referralToken); + urlObj.searchParams.set('source', source.handle); + urlObj.searchParams.set('type', source.type); + + return urlObj.toString(); +}; diff --git a/src/schema/integrations.ts b/src/schema/integrations.ts index 7cd04b712..8e8a7d69f 100644 --- a/src/schema/integrations.ts +++ b/src/schema/integrations.ts @@ -8,6 +8,8 @@ import { getSlackIntegrationOrFail, GQLUserIntegration, toGQLEnum, + addNotificationUtm, + addPrivateSourceJoinParams, } from '../common'; import { GQLEmptyResponse } from './common'; import { ForbiddenError, ValidationError } from 'apollo-server-errors'; @@ -27,13 +29,14 @@ import { GQLSource, SourcePermissions, } from './sources'; -import { SourceType } from '../entity'; +import { SourceMember, SourceType } from '../entity'; import { Connection, ConnectionArguments } from 'graphql-relay'; import { GQLDatePageGeneratorConfig, queryPaginatedByDate, } from '../common/datePageGenerator'; import { ConversationsInfoResponse } from '@slack/web-api'; +import { SourceMemberRoles } from '../roles'; export type GQLSlackChannels = { id?: string; @@ -315,25 +318,38 @@ export const resolvers: IResolvers = traceResolvers({ SourcePermissions.ConnectSlack, ); - const [slackIntegration, existingSourceIntegration] = await Promise.all([ - ctx.con.getRepository(UserIntegrationSlack).findOneOrFail({ - where: { - id: args.integrationId, - userId: ctx.userId, - }, - relations: { - user: true, - }, - }), - ctx.con.getRepository(UserSourceIntegrationSlack).findOne({ - where: { - sourceId: args.sourceId, - }, - relations: { - userIntegration: true, - }, - }), - ]); + const [slackIntegration, existingSourceIntegration, sourceAdmin] = + await Promise.all([ + ctx.con.getRepository(UserIntegrationSlack).findOneOrFail({ + where: { + id: args.integrationId, + userId: ctx.userId, + }, + relations: { + user: true, + }, + }), + ctx.con.getRepository(UserSourceIntegrationSlack).findOne({ + where: { + sourceId: args.sourceId, + }, + relations: { + userIntegration: true, + }, + }), + source.private && source.type === SourceType.Squad + ? ctx.con.getRepository(SourceMember).findOne({ + select: ['referralToken'], + where: { + sourceId: source.id, + role: SourceMemberRoles.Admin, + }, + order: { + createdAt: 'ASC', + }, + }) + : null, + ]); const user = await slackIntegration.user; const existingUserIntegration = existingSourceIntegration ? await existingSourceIntegration.userIntegration @@ -400,9 +416,27 @@ export const resolvers: IResolvers = traceResolvers({ const sourceTypeName = source.type === SourceType.Squad ? 'Squad' : 'source'; + const squadLinkUrl = new URL( + `${process.env.COMMENTS_PREFIX}/${sourceTypeName === 'Squad' ? 'squads' : 'sources'}/${source.handle}`, + ); + let squadLink = addNotificationUtm( + squadLinkUrl.toString(), + 'slack', + 'connected', + ); + + if (sourceAdmin?.referralToken) { + squadLink = addPrivateSourceJoinParams({ + url: squadLink, + source, + referralToken: sourceAdmin?.referralToken, + }); + } + await client.chat.postMessage({ channel: args.channelId, - text: `${user.name || user.username} successfully connected "${source.name}" ${sourceTypeName} from daily.dev to this channel. Important updates from this ${sourceTypeName} will be posted here 🙌`, + text: `${user.name || user.username} connected the "<${squadLink}|${source.name}>" ${sourceTypeName} to this channel. Important updates from this ${sourceTypeName} will be posted here 🙌`, + unfurl_links: false, }); } diff --git a/src/workers/postAddedSlackChannelSend.ts b/src/workers/postAddedSlackChannelSend.ts index 69aa41709..765eb38ef 100644 --- a/src/workers/postAddedSlackChannelSend.ts +++ b/src/workers/postAddedSlackChannelSend.ts @@ -6,7 +6,7 @@ import { getAttachmentForPostType, getSlackClient, } from '../common/userIntegration'; -import { addNotificationUtm } from '../common'; +import { addNotificationUtm, addPrivateSourceJoinParams } from '../common'; import { SourceMemberRoles } from '../roles'; const sendQueueConcurrency = 10; @@ -43,6 +43,11 @@ export const postAddedSlackChannelSendWorker: TypedWorker<'api.v1.post-visible'> const postLinkPlain = `${process.env.COMMENTS_PREFIX}/posts/${post.id}`; const postLinkUrl = new URL(postLinkPlain); + let postLink = addNotificationUtm( + postLinkUrl.toString(), + 'slack', + 'new_post', + ); if (source.private && source.type === SourceType.Squad) { const admin: Pick = await con @@ -59,24 +64,20 @@ export const postAddedSlackChannelSendWorker: TypedWorker<'api.v1.post-visible'> }); if (admin?.referralToken) { - postLinkUrl.searchParams.set('jt', admin.referralToken); - postLinkUrl.searchParams.set('source', source.handle); - postLinkUrl.searchParams.set('type', source.type); + postLink = addPrivateSourceJoinParams({ + url: postLink, + source, + referralToken: admin.referralToken, + }); } } - const postLink = addNotificationUtm( - postLinkUrl.toString(), - 'slack', - 'new_post', - ); - const author = await post.author; const authorName = author?.name || author?.username; - let messageText = `New post on "${source.name}" ${sourceTypeName}. <${postLink}|${postLinkPlain}>`; + let messageText = `New post: <${postLink}|${postLinkPlain}>`; if (sourceTypeName === 'Squad' && authorName) { - messageText = `${authorName} shared a new post on "${source.name}" ${sourceTypeName}. <${postLink}|${postLinkPlain}>`; + messageText = `${authorName} shared a new post: <${postLink}|${postLinkPlain}>`; } const attachment = await getAttachmentForPostType({