diff --git a/__tests__/integration/twitter.test.ts b/__tests__/integration/twitter.test.ts new file mode 100644 index 00000000..5ff9c5ca --- /dev/null +++ b/__tests__/integration/twitter.test.ts @@ -0,0 +1,380 @@ +import request from 'supertest'; +import httpStatus from 'http-status'; +import app from '../../src/app'; +import setupTestDB from '../utils/setupTestDB'; +import config from '../../src/config'; +import * as Neo4j from '../../src/neo4j'; +import { userOneAccessToken } from '../fixtures/token.fixture'; +import dateUtils from '../../src/utils/date'; +import { insertUsers, userOne } from '../fixtures/user.fixture'; +setupTestDB(); + +describe('Twitter routes', () => { + describe('GET /api/v1/twitter/{twitterId}/metrics/activity', () => { + + test('should return 200 and Activity Metrics data if req data is ok', async () => { + const oneDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(1); + const twoDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(2); + const threeDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(3); + const fourDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(4); + const fiveDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(5); + const sevenDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(7); + const eightDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(8); + + const numberOfPostsMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${eightDaysAgoTimestamp}}]->(t2) + MERGE (a2)-[:TWEETED {createdAt: ${fiveDaysAgoTimestamp}}]->(t3) + MERGE (a)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t4) + ` + const numberOfRepliesMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:REPLIED {createdAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (t5)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + ` + const numberOfRetweetsMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:RETWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (t5)-[:RETWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + ` + const numberOfLikesMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111", createdAt: ${fiveDaysAgoTimestamp}}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111", createdAt: ${fourDaysAgoTimestamp}}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112", createdAt: ${sevenDaysAgoTimestamp}}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111", createdAt: ${threeDaysAgoTimestamp}}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112", createdAt: ${eightDaysAgoTimestamp}}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:TWEETED {createdAt: ${fiveDaysAgoTimestamp}}]->(t4) + MERGE (a2)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t5) + + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t3) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t5) + + MERGE (a2)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (a2)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t5) + ` + const numberOfMentionsMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111", createdAt: ${fiveDaysAgoTimestamp}}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111", createdAt: ${fourDaysAgoTimestamp}}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112", createdAt: ${sevenDaysAgoTimestamp}}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111", createdAt: ${threeDaysAgoTimestamp}}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112", createdAt: ${eightDaysAgoTimestamp}}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + MERGE (a2)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t5) + + MERGE (t) -[:MENTIONED {createdAt: ${twoDaysAgoTimestamp}}] -> (a) + MERGE (t) -[:MENTIONED {createdAt: ${twoDaysAgoTimestamp}}] -> (a2) + MERGE (t2) -[:MENTIONED {createdAt: ${threeDaysAgoTimestamp}}] -> (a2) + MERGE (t3) -[:MENTIONED {createdAt: ${fourDaysAgoTimestamp}}] -> (a2) + ` + + await insertUsers([userOne]); + await Neo4j.write("match (n) detach delete (n);") + await Neo4j.write(numberOfPostsMockData) + await Neo4j.write(numberOfRepliesMockData) + await Neo4j.write(numberOfRetweetsMockData) + await Neo4j.write(numberOfLikesMockData) + await Neo4j.write(numberOfMentionsMockData) + + const res = await request(app) + .get(`/api/v1/twitter/1111/metrics/activity`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .expect(httpStatus.OK); + + expect(res.body.posts).toEqual(7) + expect(res.body.replies).toEqual(0) + expect(res.body.retweets).toEqual(0) + expect(res.body.likes).toEqual(1) + expect(res.body.mentions).toEqual(2) + }) + + test('should return 401 if access token is missing', async () => { + await request(app) + .get(`/api/v1/twitter/112/metrics/activity`) + .send() + .expect(httpStatus.UNAUTHORIZED); + }) + }) + + describe('GET /api/v1/twitter/{twitterId}/metrics/audience', () => { + + test('should return 200 and Audience Metrics data if req data is ok', async () => { + const oneDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(1); + const twoDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(2); + const threeDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(3); + const fourDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(4); + const fiveDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(5); + const sevenDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(7); + const eightDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(8); + + const NumberOfRepliesOthersMackMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:REPLIED {createdAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (t5)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + ` + const NumberOfRetweetsOthersMackMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:RETWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (t5)-[:RETWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + ` + const NumberOfLikesOthersMackMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111", createdAt: ${fiveDaysAgoTimestamp}}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111", createdAt: ${fourDaysAgoTimestamp}}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112", createdAt: ${sevenDaysAgoTimestamp}}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111", createdAt: ${threeDaysAgoTimestamp}}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112", createdAt: ${eightDaysAgoTimestamp}}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:TWEETED {createdAt: ${fiveDaysAgoTimestamp}}]->(t4) + MERGE (a2)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t5) + + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t3) + MERGE (a)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t5) + + MERGE (a2)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:LIKED {latestSavedAt: ${oneDaysAgoTimestamp}}]->(t5) + ` + const NumberOfMentionsOthersMackMockData = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111", createdAt: ${fiveDaysAgoTimestamp}}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111", createdAt: ${fourDaysAgoTimestamp}}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112", createdAt: ${sevenDaysAgoTimestamp}}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111", createdAt: ${threeDaysAgoTimestamp}}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112", createdAt: "{Epoch8dayAgo}"}) + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t2) + MERGE (a)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + MERGE (a2)-[:TWEETED {createdAt: ${fourDaysAgoTimestamp}}]->(t3) + MERGE (a2)-[:TWEETED {createdAt: ${threeDaysAgoTimestamp}}]->(t5) + + MERGE (t) -[:MENTIONED {createdAt: ${twoDaysAgoTimestamp}}] -> (a) + MERGE (t) -[:MENTIONED {createdAt: ${twoDaysAgoTimestamp}}] -> (a2) + MERGE (t2) -[:MENTIONED {createdAt: ${threeDaysAgoTimestamp}}] -> (a2) + MERGE (t3) -[:MENTIONED {createdAt: ${fourDaysAgoTimestamp}}] -> (a2) + ` + + await insertUsers([userOne]); + await Neo4j.write("match (n) detach delete (n);") + await Neo4j.write(NumberOfRepliesOthersMackMockData) + await Neo4j.write(NumberOfRetweetsOthersMackMockData) + await Neo4j.write(NumberOfLikesOthersMackMockData) + await Neo4j.write(NumberOfMentionsOthersMackMockData) + + const res = await request(app) + .get(`/api/v1/twitter/1111/metrics/audience`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .expect(httpStatus.OK); + + expect(res.body.replies).toEqual(2) + expect(res.body.retweets).toEqual(1) + expect(res.body.likes).toEqual(0) + expect(res.body.mentions).toEqual(0) + }) + + test('should return 401 if access token is missing', async () => { + config.notion.apiKey = 'invalid' + await request(app) + .get(`/api/v1/twitter/112/metrics/audience`) + .send() + .expect(httpStatus.UNAUTHORIZED); + }) + }) + + describe('GET /api/v1/twitter/{twitterId}/metrics/engagement', () => { + + test('should return 200 and Engagement Metrics data if req data is ok (1)', async () => { + const oneDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(1); + const twoDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(2); + const fourDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(4); + + const mockQuery = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (a3:TwitterAccount {userId: "1113"}) + MERGE (a4:TwitterAccount {userId: "1114"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + MERGE (t6:Tweet {tweetId: '128', authorId: "1113"}) + MERGE (t7:Tweet {tweetId: '129', authorId: "1113"}) + MERGE (t8:Tweet {tweetId: '130', authorId: "1114"}) + MERGE (t9:Tweet {tweetId: '131', authorId: "1114"}) + MERGE (t10:Tweet {tweetId: '132', authorId: "1114"}) + MERGE (t11:Tweet {tweetId: '133', authorId: "1114"}) + + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:QUOTED {createdAt: ${oneDaysAgoTimestamp}}]->(t9) + MERGE (t)-[:QUOTED {createdAt: ${oneDaysAgoTimestamp}}]->(t11) + MERGE (t4)-[:QUOTED {createdAt: ${oneDaysAgoTimestamp}}]->(t8) + + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t8) + MERGE (t5)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp} }]->(t9) + MERGE (t5)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp} }]->(a4) + MERGE (t6)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp} }]->(a4) + MERGE (t6)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp} }]->(t11) + MERGE (t7)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp} }]->(t) + MERGE (t7)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp} }]->(a2) + MERGE (t8)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp} }]->(a3) + MERGE (t9)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp} }]->(t2) + MERGE (t10)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp} }]->(t9) + MERGE (t11)-[:QUOTED {createdAt: ${fourDaysAgoTimestamp} }]->(t3) + ` + + await insertUsers([userOne]); + await Neo4j.write("match (n) detach delete (n);") + await Neo4j.write(mockQuery) + + const res = await request(app) + .get(`/api/v1/twitter/1114/metrics/engagement`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .expect(httpStatus.OK); + + expect(res.body.hqla).toEqual(1) + expect(res.body.hqhe).toEqual(2) + expect(res.body.lqla).toEqual(0) + expect(res.body.lqhe).toEqual(0) + }) + + test('should return 200 and Engagement Metrics data if req data is ok (2)', async () => { + const oneDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(1); + const twoDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(2); + const fourDaysAgoTimestamp = dateUtils.getXDaysAgoUTCtimestamp(4); + + const mockQuery = ` + MERGE (a:TwitterAccount {userId: "1111"}) + MERGE (a2:TwitterAccount {userId: "1112"}) + MERGE (a3:TwitterAccount {userId: "1113"}) + MERGE (a4:TwitterAccount {userId: "1114"}) + MERGE (t:Tweet {tweetId: "123", authorId: "1111"}) + MERGE (t2:Tweet {tweetId: '124', authorId: "1111"}) + MERGE (t3:Tweet {tweetId: '125', authorId: "1112"}) + MERGE (t4:Tweet {tweetId: '126', authorId: "1111"}) + MERGE (t5:Tweet {tweetId: '127', authorId: "1112"}) + MERGE (t6:Tweet {tweetId: '128', authorId: "1113"}) + MERGE (t7:Tweet {tweetId: '129', authorId: "1114"}) + MERGE (t8:Tweet {tweetId: '130', authorId: "1114"}) + MERGE (t9:Tweet {tweetId: '131', authorId: "1114"}) + MERGE (t10:Tweet {tweetId: '132', authorId: "1114"}) + MERGE (t11:Tweet {tweetId: '133', authorId: "1114"}) + + + MERGE (a)-[:TWEETED {createdAt: ${twoDaysAgoTimestamp}}]->(t) + MERGE (a)-[:TWEETED {createdAt: ${oneDaysAgoTimestamp}}]->(t4) + + MERGE (t2)-[:REPLIED {createdAt: ${oneDaysAgoTimestamp}}]->(t) + MERGE (t3)-[:REPLIED {createdAt: ${twoDaysAgoTimestamp}}]->(t8) + MERGE (t5)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t9) + MERGE (t6)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp}}]->(a4) + MERGE (t6)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t11) + MERGE (t7)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t) + MERGE (t7)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp}}]->(a2) + MERGE (t8)-[:MENTIONED {createdAt: ${fourDaysAgoTimestamp}}]->(a3) + MERGE (t8)-[:QUOTED {createdAt: ${fourDaysAgoTimestamp}}]->(t4) + MERGE (t9)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t2) + MERGE (t10)-[:REPLIED {createdAt: ${fourDaysAgoTimestamp}}]->(t9) + MERGE (t11)-[:QUOTED {createdAt: ${fourDaysAgoTimestamp}}]->(t3) + ` + + await insertUsers([userOne]); + await Neo4j.write("match (n) detach delete (n);") + await Neo4j.write(mockQuery) + + const res = await request(app) + .get(`/api/v1/twitter/1114/metrics/engagement`) + .set('Authorization', `Bearer ${userOneAccessToken}`) + .expect(httpStatus.OK); + + console.log(res.body) + expect(res.body.hqla).toEqual(2) + expect(res.body.hqhe).toEqual(0) + expect(res.body.lqla).toEqual(0) + expect(res.body.lqhe).toEqual(0) + }) + + test('should return 401 if access token is missing', async () => { + config.notion.apiKey = 'invalid' + await request(app) + .get(`/api/v1/twitter/112/metrics/engagement`) + .send() + .expect(httpStatus.UNAUTHORIZED); + }) + }) +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0cfbf990..4124b094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@notionhq/client": "^2.2.3", "@sentry/node": "^7.50.0", "@togethercrew.dev/db": "^2.4.99", - "@togethercrew.dev/tc-messagebroker": "^0.0.40", + "@togethercrew.dev/tc-messagebroker": "^0.0.42", "@types/express-session": "^1.17.7", "@types/morgan": "^1.9.5", "babel-jest": "^29.3.1", @@ -2896,9 +2896,9 @@ } }, "node_modules/@togethercrew.dev/tc-messagebroker": { - "version": "0.0.40", - "resolved": "https://registry.npmjs.org/@togethercrew.dev/tc-messagebroker/-/tc-messagebroker-0.0.40.tgz", - "integrity": "sha512-LrKVmhNbGm/pL6AVPRVqfGKcAM1Hm1+423rDRCb9E8tJSDsH4wZ0E/jfbO28lVEDzlAT3YGlGWgG8YI1d+qZjA==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@togethercrew.dev/tc-messagebroker/-/tc-messagebroker-0.0.42.tgz", + "integrity": "sha512-xIzGloLKd5B5FL6oLnuITNO3jZ/IldEMBmvftCxM8nKcR95nNmuhdnpfG1kXBXP+PveihvjbNg1iskqRWpAn6A==", "dependencies": { "@types/amqplib": "^0.10.1", "@types/uuid": "^9.0.1", diff --git a/package.json b/package.json index b7b07dbe..db507027 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@notionhq/client": "^2.2.3", "@sentry/node": "^7.50.0", "@togethercrew.dev/db": "^2.4.99", - "@togethercrew.dev/tc-messagebroker": "^0.0.40", + "@togethercrew.dev/tc-messagebroker": "^0.0.42", "@types/express-session": "^1.17.7", "@types/morgan": "^1.9.5", "babel-jest": "^29.3.1", diff --git a/src/controllers/twitter.controller.ts b/src/controllers/twitter.controller.ts index b4ca274e..cef7ba22 100644 --- a/src/controllers/twitter.controller.ts +++ b/src/controllers/twitter.controller.ts @@ -1,10 +1,11 @@ import { Response } from 'express'; -import { userService, } from '../services'; +import { guildService, userService, } from '../services'; import { IAuthRequest } from '../interfaces/request.interface'; -import { catchAsync } from "../utils"; +import { ApiError, catchAsync } from "../utils"; import httpStatus from 'http-status'; import { tokenTypes } from '../config/tokens'; import { Token } from '@togethercrew.dev/db'; +import twitterService from '../services/twitter.service'; const disconnectTwitter = catchAsync(async function (req: IAuthRequest, res: Response) { const user = req.user; @@ -18,7 +19,142 @@ const disconnectTwitter = catchAsync(async function (req: IAuthRequest, res: Res res.status(httpStatus.NO_CONTENT).send(); }); +const refreshTwitter = catchAsync(async function (req: IAuthRequest, res: Response) { + const { twitter_username } = req.body; + const discordId = req.user.discordId + + const guild = await guildService.getGuild({ user: discordId }); + if (!guild) { + throw new ApiError(440, 'Oops, something went wrong! Could you please try logging in'); + } + + twitterService.twitterRefresh(twitter_username, { discordId, guildId: guild.guildId }) + res.status(httpStatus.NO_CONTENT).send(); +}) + +type TwitterActivityResponse = { + posts: number | null + replies: number | null + retweets: number | null + likes: number | null + mentions: number | null +} +const activityMetrics = catchAsync(async function (req: IAuthRequest, res: Response) { + const userId = req.params.twitterId + + // TODO: also we can make it in a way that all below functions run in parallel + const postNumber = await twitterService.getUserPostNumber(userId) + const replyNumber = await twitterService.getUserReplyNumber(userId) + const retweetNumber = await twitterService.getUserRetweetNumber(userId) + const likeNumber = await twitterService.getUserLikeNumber(userId) + const mentionNumber = await twitterService.getUserMentionNumber(userId) + const activityMetrics = { + posts: postNumber, + replies: replyNumber, + retweets: retweetNumber, + likes: likeNumber, + mentions: mentionNumber + } + res.send(activityMetrics) +}); + +type TwitterAudienceResponse = { + replies: number | null + retweets: number | null + likes: number | null + mentions: number | null +} +const audienceMetrics = catchAsync(async function (req: IAuthRequest, res: Response) { + const userId = req.params.twitterId + + // TODO: also we can make it in a way that all below functions run in parallel + const replyNumber = await twitterService.getAudienceReplyNumber(userId) + const retweetNumber = await twitterService.getAudienceRetweetNumber(userId) + const likeNumber = await twitterService.getAudienceLikeNumber(userId) + const mentionNumber = await twitterService.getAudienceMentionNumber(userId) + const audienceMetrics = { + replies: replyNumber, + retweets: retweetNumber, + likes: likeNumber, + mentions: mentionNumber, + } + res.send(audienceMetrics) +}); + +type TwitterEngagementResponse = { + hqla: number + hqhe: number + lqla: number + lqhe: number +} +const engagementMetrics = catchAsync(async function (req: IAuthRequest, res: Response) { + const userId = req.params.twitterId + let hqla = 0 + let hqhe = 0 + let lqla = 0 + let lqhe = 0 + + const repliesInteraction = await twitterService.getRepliesInteraction(userId) + const quotesInteraction = await twitterService.getQuotesInteraction(userId) + const mentionsInteraction = await twitterService.getMentionsInteraction(userId) + + const retweetsInteraction = await twitterService.getRetweetsInteraction(userId) + const likesInteraction = await twitterService.getLikesInteraction(userId) + + const repliesInteractionUsers = repliesInteraction.map(ri => ri.userId) + const quotesInteractionUsers = quotesInteraction.map(qi => qi.userId) + const mentionsInteractionUsers = mentionsInteraction.map(mi => mi.userId) + + const retweetsInteractionUsers = retweetsInteraction.map(ri => ri.userId) + const likesInteractionUsers = likesInteraction.map(li => li.userId) + + + // calculate `hqla` and `hqhe` + const replyQuoteMentionUsers = new Set([...repliesInteractionUsers, ...quotesInteractionUsers, ...mentionsInteractionUsers]) + for(const userId of Array.from(replyQuoteMentionUsers)){ + const replyInteraction = repliesInteraction.find(ri => ri.userId == userId) + const quoteInteraction = quotesInteraction.find(qi => qi.userId == userId) + const mentionInteraction = mentionsInteraction.find(mi => mi.userId == userId) + + const replyInteractionNumber = replyInteraction?.replyCount || 0 + const quoteInteractionNumber = quoteInteraction?.quoteCount || 0 + const mentionInteractionNumber = mentionInteraction?.mentionCount || 0 + + if(replyInteractionNumber + quoteInteractionNumber + mentionInteractionNumber < 3) + hqla++ + else + hqhe++ + } + + // calculate `lqla` and `lqhe` + const retweetLikeUsers = new Set([...retweetsInteractionUsers, ...likesInteractionUsers]) + for(const userId of Array.from(retweetLikeUsers)){ + const retweetInteraction = retweetsInteraction.find(ri => ri.userId == userId) + const likeInteraction = likesInteraction.find(li => li.userId == userId) + + const retweetInteractionNumber = retweetInteraction?.retweetCount || 0 + const likeInteractionNumber = likeInteraction?.likeCount || 0 + + if(retweetInteractionNumber + likeInteractionNumber < 3) + lqla++ + else + lqhe++ + } + + const engagementMetrics = { + hqla: hqla, + hqhe: hqhe, + lqla: lqla, + lqhe: lqhe, + } + res.send(engagementMetrics) +}); + export default { - disconnectTwitter + disconnectTwitter, + refreshTwitter, + activityMetrics, + audienceMetrics, + engagementMetrics } diff --git a/src/docs/twitter.doc.yml b/src/docs/twitter.doc.yml index 28909b37..56ca0afe 100644 --- a/src/docs/twitter.doc.yml +++ b/src/docs/twitter.doc.yml @@ -1,4 +1,4 @@ -paths: +paths: /api/v1/twitter/disconnect: post: tags: @@ -14,8 +14,135 @@ paths: "401": description: Unauthorized $ref: "#/components/responses/Unauthorized" + + /api/v1/twitter/metrics/refresh: + post: + tags: + - [Twitter] + summary: Call Twitter Refresh saga + description: after calling this end-point a new TwitterRefresh saga will be called and new information will be received + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + twitter_username: + type: string + required: true + example: katerinabohlec + response: + "401": + description: Unauthorized + $ref: "#/components/responses/Unauthorized" + /api/v1/twitter/{twitterId}/metrics/activity: + get: + tags: + - [Twitter] + summary: get twitter Activity Metrics + description: get twitter Activity Metrics + security: + - bearerAuth: [] + parameters: + - in: path + name: twitterId + required: true + schema: + type: string + description: Twitter ID + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + posts: + type: number + replies: + type: number + retweets: + type: number + likes: + type: number + mentions: + type: number + "401": + description: Unauthorized + $ref: "#/components/responses/Unauthorized" + /api/v1/twitter/{twitterId}/metrics/audience: + get: + tags: + - [Twitter] + summary: get twitter Audience Metrics + description: get twitter Audience Metrics + security: + - bearerAuth: [] + parameters: + - in: path + name: twitterId + required: true + schema: + type: string + description: Twitter ID + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + replies: + type: number + retweets: + type: number + likes: + type: number + mentions: + type: number + "401": + description: Unauthorized + $ref: "#/components/responses/Unauthorized" - + /api/v1/twitter/{twitterId}/metrics/engagement: + get: + tags: + - [Twitter] + summary: get twitter Engagement Metrics + description: get twitter Engagement Metrics + security: + - bearerAuth: [] + parameters: + - in: path + name: twitterId + required: true + schema: + type: string + description: Twitter ID + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + hqla: + type: number + hqhe: + type: number + lqla: + type: number + lqhe: + type: number + "401": + description: Unauthorized + $ref: "#/components/responses/Unauthorized" diff --git a/src/routes/v1/twitter.route.ts b/src/routes/v1/twitter.route.ts index 4ebc5c18..d9d8770b 100644 --- a/src/routes/v1/twitter.route.ts +++ b/src/routes/v1/twitter.route.ts @@ -1,11 +1,16 @@ import express from "express"; import { twitterController } from "../../controllers"; +import twitterValidation from '../../validations/twitter.validation'; -import { auth } from '../../middlewares'; +import { validate, auth } from '../../middlewares'; const router = express.Router(); // Router router.post('/disconnect', auth(), twitterController.disconnectTwitter); +router.post('/metrics/refresh', auth(), validate(twitterValidation.refreshTweet), twitterController.refreshTwitter); +router.get('/:twitterId/metrics/activity', auth(), twitterController.activityMetrics); +router.get('/:twitterId/metrics/audience', auth(), twitterController.audienceMetrics); +router.get('/:twitterId/metrics/engagement', auth(), twitterController.engagementMetrics); export default router; diff --git a/src/services/saga.service.ts b/src/services/saga.service.ts index 57c1983f..a1b76344 100644 --- a/src/services/saga.service.ts +++ b/src/services/saga.service.ts @@ -23,7 +23,20 @@ async function createAndStartFetchMemberSaga(guildId: Snowflake) { await saga.start(() => { }) } +async function createAndStartRefreshTwitterSaga(twitter_username:string, other: { discordId: Snowflake, guildId: string, message: string }) { + const saga = await MBConnection.models.Saga.create({ + status: Status.NOT_STARTED, + data: { twitter_username, ...other }, + choreography: ChoreographyDict.TWITTER_REFRESH + }) + + // eslint-disable-next-line @typescript-eslint/no-empty-function + await saga.start(() => { }) + return saga +} + export default { createAndStartGuildSaga, - createAndStartFetchMemberSaga + createAndStartFetchMemberSaga, + createAndStartRefreshTwitterSaga } \ No newline at end of file diff --git a/src/services/twitter.service.ts b/src/services/twitter.service.ts new file mode 100644 index 00000000..b6be32a3 --- /dev/null +++ b/src/services/twitter.service.ts @@ -0,0 +1,363 @@ +import utils from '../utils/date'; +import * as Neo4j from '../neo4j'; +import sagaService from '../services/saga.service'; + + +async function twitterRefresh(twitterUsername: string, other: { discordId: string, guildId: string }) { + + const saga = sagaService.createAndStartRefreshTwitterSaga(twitterUsername, { + ...other, + message: "Your Twitter analysis has been completed. See the insights in your TogetherCrew dashboard https://app.togethercrew.com/" + }) + + return saga +} + +//#region Activity Metrics +async function getUserPostNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const userPostNumberQuery = ` + MATCH (a:TwitterAccount {userId: '${twitterId}'} )-[r:TWEETED]->(t:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} + RETURN COUNT(r) as post_count + ` + const neo4jData = await Neo4j.read(userPostNumberQuery) + const { records: postNumberRecords } = neo4jData + if (postNumberRecords.length == 0) return null + + const postNumberRecord = postNumberRecords[0] + const { _fieldLookup, _fields } = postNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const postNumber = _fields[_fieldLookup['post_count']] + return postNumber +} + +async function getUserReplyNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const userReplyNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} )-[r:REPLIED]->(m:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND m.authorId <> t.authorId + RETURN COUNT(r) as reply_count + ` + const neo4jData = await Neo4j.read(userReplyNumberQuery) + const { records: replyNumberRecords } = neo4jData + if (replyNumberRecords.length == 0) return null + + const replyNumberRecord = replyNumberRecords[0] + const { _fieldLookup, _fields } = replyNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const replyNumber = _fields[_fieldLookup['reply_count']] + return replyNumber +} + +async function getUserRetweetNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const userRetweetNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} )-[r:RETWEETED]->(m:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND m.authorId <> t.authorId + RETURN COUNT(r) as retweet_count + ` + const neo4jData = await Neo4j.read(userRetweetNumberQuery) + const { records: retweetNumberRecords } = neo4jData + if (retweetNumberRecords.length == 0) return null + + const retweetNumberRecord = retweetNumberRecords[0] + const { _fieldLookup, _fields } = retweetNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const retweetNumber = _fields[_fieldLookup['retweet_count']] + return retweetNumber +} + +async function getUserLikeNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const userLikeNumberQuery = ` + MATCH (a:TwitterAccount {userId: '${twitterId}'} )-[r:LIKED]->(m:Tweet) + WHERE m.createdAt >= ${sevenDaysAgoEpoch} AND a.userId <> m.authorId + RETURN COUNT(r) as like_counts + ` + const neo4jData = await Neo4j.read(userLikeNumberQuery) + const { records: likeNumberRecords } = neo4jData + if (likeNumberRecords.length == 0) return null + + const likeNumberRecord = likeNumberRecords[0] + const { _fieldLookup, _fields } = likeNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const linkNumber = _fields[_fieldLookup['like_counts']] + return linkNumber +} + +async function getUserMentionNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const userMentionNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} )-[r:MENTIONED]->(a:TwitterAccount) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND t.authorId <> a.userId + RETURN COUNT(r) as mention_count + ` + const neo4jData = await Neo4j.read(userMentionNumberQuery) + const { records: mentionNumberRecords } = neo4jData + if (mentionNumberRecords.length == 0) return null + + const mentionNumberRecord = mentionNumberRecords[0] + const { _fieldLookup, _fields } = mentionNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const mentionNumber = _fields[_fieldLookup['mention_count']] + return mentionNumber +} +//#endregion + +//#region Audience Metrics + +/** + * Number of replies others made on the user's posts + * @param twitterId id of a user + */ +async function getAudienceReplyNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const replyNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} )<-[r:REPLIED]-(m:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND m.authorId <> t.authorId + RETURN COUNT(r) as reply_count + ` + + const neo4jData = await Neo4j.read(replyNumberQuery) + const { records: replyNumberRecords } = neo4jData + if (replyNumberRecords.length == 0) return null + + const replyNumberRecord = replyNumberRecords[0] + const { _fieldLookup, _fields } = replyNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const replyNumber = _fields[_fieldLookup['reply_count']] + return replyNumber +} + +/** + * Number of retweets others made on the user's posts + * @param twitterId id of a user + */ +async function getAudienceRetweetNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const retweetNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} )<-[r:RETWEETED]-(m:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND m.authorId <> t.authorId + RETURN COUNT(r) as retweet_count + ` + + const neo4jData = await Neo4j.read(retweetNumberQuery) + const { records: retweetNumberRecords } = neo4jData + if (retweetNumberRecords.length == 0) return null + + const retweetNumberRecord = retweetNumberRecords[0] + const { _fieldLookup, _fields } = retweetNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const retweetNumber = _fields[_fieldLookup['retweet_count']] + return retweetNumber +} + +/** + * Number of likes others made on the user's posts + * @param twitterId id of a user + */ +async function getAudienceLikeNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const likeNumberQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'} ) <-[r:LIKED]- (a:TwitterAccount) + WHERE t.createdAt >= ${sevenDaysAgoEpoch} AND a.userId <> t.authorId + RETURN COUNT(r) as like_counts + ` + + const neo4jData = await Neo4j.read(likeNumberQuery) + const { records: likeNumberRecords } = neo4jData + if (likeNumberRecords.length == 0) return null + + const likeNumberRecord = likeNumberRecords[0] + const { _fieldLookup, _fields } = likeNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const linkNumber = _fields[_fieldLookup['like_counts']] + return linkNumber +} + +/** + * Number of Mentions a user received + * @param twitterId id of a user + */ +async function getAudienceMentionNumber(twitterId: string){ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const mentionNumberQuery = ` + MATCH (a:TwitterAccount {userId: '${twitterId}'} )<-[r:MENTIONED]-(t:Tweet) + WHERE r.createdAt >= ${sevenDaysAgoEpoch} AND a.userId <> t.authorId + RETURN COUNT(r) as mention_count + ` + + const neo4jData = await Neo4j.read(mentionNumberQuery) + const { records: mentionNumberRecords } = neo4jData + if (mentionNumberRecords.length == 0) return null + + const mentionNumberRecord = mentionNumberRecords[0] + const { _fieldLookup, _fields } = mentionNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + + const mentionNumber = _fields[_fieldLookup['mention_count']] + return mentionNumber +} + +//#endregion + +//#region Engagement Metrics + +type ReplyInteraction = { userId: string; replyCount: number; } +async function getRepliesInteraction(twitterId: string): Promise{ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const repliesInteractionQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'})<-[r:REPLIED]-(m:Tweet) + WHERE m.authorId <> t.authorId AND r.createdAt >= ${sevenDaysAgoEpoch} + RETURN m.authorId AS user, COUNT(*) as reply_count + ` + + const neo4jData = await Neo4j.read(repliesInteractionQuery) + const { records: replyNumberRecords } = neo4jData + if (replyNumberRecords.length == 0) return [] + + const repliesInteraction: ReplyInteraction[] = replyNumberRecords.map((replyNumberRecord) => { + const { _fieldLookup, _fields } = replyNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + const userId = _fields[_fieldLookup['user']] as unknown as string + const replyCount = _fields[_fieldLookup['reply_count']] as number + + return { userId, replyCount } + }) + + return repliesInteraction +} + +type QuoteInteraction = { userId: string; quoteCount: number; } +async function getQuotesInteraction(twitterId: string): Promise{ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const quotesInteractionQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'})<-[r:QUOTED]-(m:Tweet) + WHERE m.authorId <> t.authorId AND r.createdAt >= ${sevenDaysAgoEpoch} + RETURN m.authorId AS user, COUNT(*) as quote_count + ` + + const neo4jData = await Neo4j.read(quotesInteractionQuery) + const { records: quoteNumberRecords } = neo4jData + if (quoteNumberRecords.length == 0) return [] + + const quotesInteraction: QuoteInteraction[] = quoteNumberRecords.map((quoteNumberRecord) => { + const { _fieldLookup, _fields } = quoteNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + const userId = _fields[_fieldLookup['user']] as unknown as string + const quoteCount = _fields[_fieldLookup['quote_count']] as number + + return { userId, quoteCount } + }) + + return quotesInteraction +} + +type MentionInteraction = { userId: string; mentionCount: number; } +async function getMentionsInteraction(twitterId: string): Promise{ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const mentionsInteractionQuery = ` + MATCH (a:TwitterAccount {userId: '${twitterId}'})<-[r:MENTIONED]-(t:Tweet) + WHERE a.userId <> t.authorId AND r.createdAt >= ${sevenDaysAgoEpoch} + RETURN t.authorId AS user, COUNT (*) as mention_count + ` + + const neo4jData = await Neo4j.read(mentionsInteractionQuery) + const { records: mentionNumberRecords } = neo4jData + if (mentionNumberRecords.length == 0) return [] + + const mentionsInteraction: MentionInteraction[] = mentionNumberRecords.map((mentionNumberRecord) => { + const { _fieldLookup, _fields } = mentionNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + const userId = _fields[_fieldLookup['user']] as unknown as string + const mentionCount = _fields[_fieldLookup['mention_count']] as number + + return { userId, mentionCount } + }) + + return mentionsInteraction +} + +type RetweetInteraction = { userId: string; retweetCount: number; } +async function getRetweetsInteraction(twitterId: string): Promise{ + const sevenDaysAgoEpoch = utils.get7daysAgoUTCtimestamp() + + const retweetsInteractionQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'})<-[r:RETWEETED]-(m:Tweet) + WHERE t.authorId <> m.authorId AND r.createdAt >= ${sevenDaysAgoEpoch} + RETURN m.authorId AS user, COUNT (*) as retweet_count + ` + + const neo4jData = await Neo4j.read(retweetsInteractionQuery) + const { records: retweetNumberRecords } = neo4jData + if (retweetNumberRecords.length == 0) return [] + + const retweetsInteraction: RetweetInteraction[] = retweetNumberRecords.map((retweetNumberRecord) => { + const { _fieldLookup, _fields } = retweetNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + const userId = _fields[_fieldLookup['user']] as unknown as string + const retweetCount = _fields[_fieldLookup['retweet_count']] as number + + return { userId, retweetCount } + }) + + return retweetsInteraction +} + +type LikeInteraction = { userId: string; likeCount: number; } +async function getLikesInteraction(twitterId: string): Promise{ + + const userLikeNumberInteractionQuery = ` + MATCH (t:Tweet {authorId: '${twitterId}'}) <-[:LIKED]- (a:TwitterAccount) + WHERE a.userId <> t.authorId + RETURN a.userId AS user, COUNT(*) as likes_count + ` + + const neo4jData = await Neo4j.read(userLikeNumberInteractionQuery) + const { records: likeNumberRecords } = neo4jData + if (likeNumberRecords.length == 0) return [] + + const likesInteraction: LikeInteraction[] = likeNumberRecords.map((likeNumberRecord) => { + const { _fieldLookup, _fields } = likeNumberRecord as unknown as { _fieldLookup: Record, _fields: number[] } + const userId = _fields[_fieldLookup['user']] as unknown as string + const likeCount = _fields[_fieldLookup['likes_count']] as number + + return { userId, likeCount } + }) + + return likesInteraction +} + +//#endregion + +export default { + twitterRefresh, + + // Activity Metrics + getUserPostNumber, + getUserReplyNumber, + getUserRetweetNumber, + getUserLikeNumber, + getUserMentionNumber, + + // Audience Metrics + getAudienceReplyNumber, + getAudienceRetweetNumber, + getAudienceLikeNumber, + getAudienceMentionNumber, + + // Engagement Metrics + getRepliesInteraction, + getQuotesInteraction, + getMentionsInteraction, + getRetweetsInteraction, + getLikesInteraction, +} diff --git a/src/utils/date.ts b/src/utils/date.ts index 185b1a9a..b0721cbe 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -53,10 +53,40 @@ function getYesterdayUTCtimestamp(){ return yesterdayUTCtimestamp } +function get7daysAgoUTCtimestamp(){ + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + const year = sevenDaysAgo.getUTCFullYear() + const month = sevenDaysAgo.getUTCMonth() + const day = sevenDaysAgo.getUTCDate() + + const sevenDaysAgoUTC = new Date(Date.UTC(year, month, day)) + const sevenDaysAgoUTCtimestamp = sevenDaysAgoUTC.getTime() + + return sevenDaysAgoUTCtimestamp +} + +function getXDaysAgoUTCtimestamp(x: number){ + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - x) + + const year = sevenDaysAgo.getUTCFullYear() + const month = sevenDaysAgo.getUTCMonth() + const day = sevenDaysAgo.getUTCDate() + + const sevenDaysAgoUTC = new Date(Date.UTC(year, month, day)) + const sevenDaysAgoUTCtimestamp = sevenDaysAgoUTC.getTime() + + return sevenDaysAgoUTCtimestamp +} + export default { shiftHeatmapsHours, calculateAdjustedDate, - getYesterdayUTCtimestamp + getYesterdayUTCtimestamp, + get7daysAgoUTCtimestamp, + getXDaysAgoUTCtimestamp } diff --git a/src/validations/twitter.validation.ts b/src/validations/twitter.validation.ts new file mode 100644 index 00000000..0ae3c6ee --- /dev/null +++ b/src/validations/twitter.validation.ts @@ -0,0 +1,11 @@ +import Joi from "joi"; + +const refreshTweet = { + body: Joi.object().required().keys({ + twitter_username: Joi.string().required() + }), +}; + +export default { + refreshTweet, +} \ No newline at end of file