diff --git a/package-lock.json b/package-lock.json index 5095a09d..1c24a5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,9 @@ "@babel/preset-env": "^7.16.11", "@babel/preset-typescript": "^7.24.1", "@google-cloud/storage": "^6.11.0", + "@types/cli-progress": "^3.11.6", + "@types/dotenv": "^8.2.0", + "@types/lodash": "^4.17.6", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", "apollo-server-testing": "^2.18.2", @@ -4498,6 +4501,15 @@ "@types/node": "*" } }, + "node_modules/@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/color-name": { "version": "1.1.1", "dev": true, @@ -4527,6 +4539,16 @@ "@types/express": "*" } }, + "node_modules/@types/dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==", + "deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "dotenv": "*" + } + }, "node_modules/@types/express": { "version": "4.17.3", "license": "MIT", @@ -4671,6 +4693,12 @@ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==" }, + "node_modules/@types/lodash": { + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", + "integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==", + "dev": true + }, "node_modules/@types/long": { "version": "4.0.2", "license": "MIT" @@ -20475,6 +20503,15 @@ "@types/node": "*" } }, + "@types/cli-progress": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", + "integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/color-name": { "version": "1.1.1", "dev": true @@ -20500,6 +20537,15 @@ "@types/express": "*" } }, + "@types/dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==", + "dev": true, + "requires": { + "dotenv": "*" + } + }, "@types/express": { "version": "4.17.3", "requires": { @@ -20632,6 +20678,12 @@ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==" }, + "@types/lodash": { + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", + "integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==", + "dev": true + }, "@types/long": { "version": "4.0.2" }, diff --git a/package.json b/package.json index a99bbf21..a9ca4655 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "seed": "cd src/rumors-db && npm run seed", "pretest": "npm run rumors-db:install && npm run rumors-db:test && mkdir -p build", "test": "NODE_ENV=test ELASTICSEARCH_URL=http://localhost:62223 jest --runInBand", - "posttest": "NODE_ENV=test ELASTICSEARCH_URL=http://localhost:62223 babel-node test/postTest.js", + "posttest": "NODE_ENV=test ELASTICSEARCH_URL=http://localhost:62223 babel-node --extensions .ts,.js test/postTest.js", "start": "pm2-runtime start ecosystem.config.js --env production", "lint": "eslint src/.", "lint:fix": "eslint --fix src/.", @@ -75,6 +75,9 @@ "@babel/preset-env": "^7.16.11", "@babel/preset-typescript": "^7.24.1", "@google-cloud/storage": "^6.11.0", + "@types/cli-progress": "^3.11.6", + "@types/dotenv": "^8.2.0", + "@types/lodash": "^4.17.6", "@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/parser": "^5.56.0", "apollo-server-testing": "^2.18.2", diff --git a/src/contextFactory.ts b/src/contextFactory.ts new file mode 100644 index 00000000..954d8a82 --- /dev/null +++ b/src/contextFactory.ts @@ -0,0 +1,42 @@ +import DataLoaders from './graphql/dataLoaders'; +import { createOrUpdateUser } from './util/user'; + +type ContextFactoryArgs = { + ctx: { + appId: string; + query: { userId?: string }; + state: { user?: { userId?: string } }; + }; +}; + +export default async function contextFactory({ ctx }: ContextFactoryArgs) { + const { + appId, + query: { userId: queryUserId } = {}, + state: { user: { userId: sessionUserId } = {} } = {}, + } = ctx; + + const userId = queryUserId ?? sessionUserId; + + let currentUser = null; + if (appId && userId) { + ({ user: currentUser } = await createOrUpdateUser({ + userId, + appId, + })); + } + + return { + loaders: new DataLoaders(), // new loaders per request + user: currentUser, + + // userId-appId pair + // + userId: currentUser?.id, + appUserId: userId, + appId, + }; +} + +/** GraphQL resolver context */ +export type ResolverContext = Awaited>; diff --git a/src/graphql/dataLoaders/index.js b/src/graphql/dataLoaders/index.js deleted file mode 100644 index 4eb82ace..00000000 --- a/src/graphql/dataLoaders/index.js +++ /dev/null @@ -1,101 +0,0 @@ -import docLoaderFactory from './docLoaderFactory'; -import analyticsLoaderFactory from './analyticsLoaderFactory'; -import articleRepliesByReplyIdLoaderFactory from './articleRepliesByReplyIdLoaderFactory'; -import articleCategoriesByCategoryIdLoaderFactory from './articleCategoriesByCategoryIdLoaderFactory'; -import articleReplyFeedbacksLoaderFactory from './articleReplyFeedbacksLoaderFactory'; -import articleCategoryFeedbacksLoaderFactory from './articleCategoryFeedbacksLoaderFactory'; -import searchResultLoaderFactory from './searchResultLoaderFactory'; -import urlLoaderFactory from './urlLoaderFactory'; -import repliedArticleCountLoaderFactory from './repliedArticleCountLoaderFactory'; -import votedArticleReplyCountLoaderFactory from './votedArticleReplyCountLoaderFactory'; -import userLevelLoaderFactory from './userLevelLoaderFactory'; -import userLoaderFactory from './userLoaderFactory'; -import contributionsLoaderFactory from './contributionsLoaderFactory'; - -export default class DataLoaders { - // List of data loaders - // - get docLoader() { - return this._checkOrSetLoader('docLoader', docLoaderFactory); - } - get articleRepliesByReplyIdLoader() { - return this._checkOrSetLoader( - 'articleRepliesByReplyIdLoader', - articleRepliesByReplyIdLoaderFactory - ); - } - get articleCategoriesByCategoryIdLoader() { - return this._checkOrSetLoader( - 'articleCategoriesByCategoryIdLoader', - articleCategoriesByCategoryIdLoaderFactory - ); - } - get articleReplyFeedbacksLoader() { - return this._checkOrSetLoader( - 'articleReplyFeedbacksLoader', - articleReplyFeedbacksLoaderFactory - ); - } - get articleCategoryFeedbacksLoader() { - return this._checkOrSetLoader( - 'articleCategoryFeedbacksLoader', - articleCategoryFeedbacksLoaderFactory - ); - } - get searchResultLoader() { - return this._checkOrSetLoader( - 'searchResultLoader', - searchResultLoaderFactory - ); - } - - get urlLoader() { - return this._checkOrSetLoader('urlLoader', urlLoaderFactory); - } - - get repliedArticleCountLoader() { - return this._checkOrSetLoader( - 'repliedArticleCountLoader', - repliedArticleCountLoaderFactory - ); - } - - get votedArticleReplyCountLoader() { - return this._checkOrSetLoader( - 'votedArticleReplyCountLoader', - votedArticleReplyCountLoaderFactory - ); - } - - get userLoader() { - return this._checkOrSetLoader('userLoader', userLoaderFactory); - } - - get userLevelLoader() { - return this._checkOrSetLoader('userLevelLoader', userLevelLoaderFactory); - } - - get analyticsLoader() { - return this._checkOrSetLoader('analyticsLoader', analyticsLoaderFactory); - } - - get contributionsLoader() { - return this._checkOrSetLoader( - 'contributionsLoader', - contributionsLoaderFactory - ); - } - - // inner-workings - // - constructor() { - this._loaders = {}; - } - - _checkOrSetLoader(name, factoryFn) { - if (this._loaders[name]) return this._loaders[name]; - - this._loaders[name] = factoryFn(this); - return this._loaders[name]; - } -} diff --git a/src/graphql/dataLoaders/index.ts b/src/graphql/dataLoaders/index.ts new file mode 100644 index 00000000..0f5fad27 --- /dev/null +++ b/src/graphql/dataLoaders/index.ts @@ -0,0 +1,98 @@ +import docLoaderFactory from './docLoaderFactory'; +import analyticsLoaderFactory from './analyticsLoaderFactory'; +import articleRepliesByReplyIdLoaderFactory from './articleRepliesByReplyIdLoaderFactory'; +import articleCategoriesByCategoryIdLoaderFactory from './articleCategoriesByCategoryIdLoaderFactory'; +import articleReplyFeedbacksLoaderFactory from './articleReplyFeedbacksLoaderFactory'; +import articleCategoryFeedbacksLoaderFactory from './articleCategoryFeedbacksLoaderFactory'; +import searchResultLoaderFactory from './searchResultLoaderFactory'; +import urlLoaderFactory from './urlLoaderFactory'; +import repliedArticleCountLoaderFactory from './repliedArticleCountLoaderFactory'; +import votedArticleReplyCountLoaderFactory from './votedArticleReplyCountLoaderFactory'; +import userLevelLoaderFactory from './userLevelLoaderFactory'; +import userLoaderFactory from './userLoaderFactory'; +import contributionsLoaderFactory from './contributionsLoaderFactory'; + +const LOADER_FACTORY_MAP = { + docLoader: docLoaderFactory, + articleRepliesByReplyIdLoader: articleRepliesByReplyIdLoaderFactory, + articleCategoriesByCategoryIdLoader: + articleCategoriesByCategoryIdLoaderFactory, + articleReplyFeedbacksLoader: articleReplyFeedbacksLoaderFactory, + articleCategoryFeedbacksLoader: articleCategoryFeedbacksLoaderFactory, + searchResultLoader: searchResultLoaderFactory, + urlLoader: urlLoaderFactory, + repliedArticleCountLoader: repliedArticleCountLoaderFactory, + votedArticleReplyCountLoader: votedArticleReplyCountLoaderFactory, + userLoader: userLoaderFactory, + userLevelLoader: userLevelLoaderFactory, + analyticsLoader: analyticsLoaderFactory, + contributionsLoader: contributionsLoaderFactory, +} as const; + +type LoaderFactoryMap = typeof LOADER_FACTORY_MAP; + +export default class DataLoaders { + // List of data loaders + // + get docLoader() { + return this._checkOrSetLoader('docLoader'); + } + get articleRepliesByReplyIdLoader() { + return this._checkOrSetLoader('articleRepliesByReplyIdLoader'); + } + get articleCategoriesByCategoryIdLoader() { + return this._checkOrSetLoader('articleCategoriesByCategoryIdLoader'); + } + get articleReplyFeedbacksLoader() { + return this._checkOrSetLoader('articleReplyFeedbacksLoader'); + } + get articleCategoryFeedbacksLoader() { + return this._checkOrSetLoader('articleCategoryFeedbacksLoader'); + } + get searchResultLoader() { + return this._checkOrSetLoader('searchResultLoader'); + } + get urlLoader() { + return this._checkOrSetLoader('urlLoader'); + } + get repliedArticleCountLoader() { + return this._checkOrSetLoader('repliedArticleCountLoader'); + } + get votedArticleReplyCountLoader() { + return this._checkOrSetLoader('votedArticleReplyCountLoader'); + } + get userLoader() { + return this._checkOrSetLoader('userLoader'); + } + get userLevelLoader() { + return this._checkOrSetLoader('userLevelLoader'); + } + get analyticsLoader() { + return this._checkOrSetLoader('analyticsLoader'); + } + get contributionsLoader() { + return this._checkOrSetLoader('contributionsLoader'); + } + + _loaders: { + -readonly [key in keyof LoaderFactoryMap]?: ReturnType< + LoaderFactoryMap[key] + >; + }; + + // inner-workings + // + constructor() { + this._loaders = {}; + } + + _checkOrSetLoader( + name: N + ): ReturnType { + const cached = this._loaders[name]; + if (cached) return cached; + + this._loaders[name] = LOADER_FACTORY_MAP[name](this); + return this._loaders[name] as ReturnType; + } +} diff --git a/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js b/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.ts similarity index 78% rename from src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js rename to src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.ts index 2ac6d65d..e3542990 100644 --- a/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.js +++ b/src/graphql/mutations/CreateOrUpdateArticleReplyFeedback.ts @@ -4,13 +4,26 @@ import { assertUser, getContentDefaultStatus } from 'util/user'; import FeedbackVote from 'graphql/models/FeedbackVote'; import ArticleReply from 'graphql/models/ArticleReply'; +import type { + Article, + ArticleReply as ArticleReplyType, +} from 'rumors-db/schema/articles'; +import type { ArticleReplyFeedback } from 'rumors-db/schema/articlereplyfeedbacks'; +import type { Reply } from 'rumors-db/schema/replies'; + import client from 'util/client'; +import { ResolverContext } from 'contextFactory'; export function getArticleReplyFeedbackId({ articleId, replyId, userId, appId, +}: { + articleId: string; + replyId: string; + userId: string; + appId: string; }) { return `${articleId}__${replyId}__${userId}__${appId}`; } @@ -19,16 +32,13 @@ export function getArticleReplyFeedbackId({ * Updates the positive and negative feedback count of the article reply with * specified `articleId` and `replyId`. * - * @param {string} articleId - * @param {string} replyId - * @param {object[]} feedbacks - * @returns {object} The updated article reply + * @returns The updated article reply */ export async function updateArticleReplyByFeedbacks( - articleId, - replyId, - feedbacks -) { + articleId: string, + replyId: string, + feedbacks: ArticleReplyFeedback[] +): Promise { const [positiveFeedbackCount, negativeFeedbackCount] = feedbacks .filter(({ status }) => status === 'NORMAL') .reduce( @@ -75,7 +85,7 @@ export async function updateArticleReplyByFeedbacks( }, }, }, - _source: true, + _source: 'true', }); /* istanbul ignore if */ @@ -84,7 +94,7 @@ export async function updateArticleReplyByFeedbacks( } return articleReplyUpdateResult.get._source.articleReplies.find( - (articleReply) => articleReply.replyId === replyId + (articleReply: ArticleReplyType) => articleReply.replyId === replyId ); } @@ -98,9 +108,19 @@ export default { comment: { type: GraphQLString }, }, async resolve( - rootValue, - { articleId, replyId, vote, comment }, - { user, loaders } + rootValue: unknown, + { + articleId, + replyId, + vote, + comment, + }: { + articleId: string; + replyId: string; + vote: 1 | 0 | -1; + comment?: string | null; + }, + { user, loaders }: ResolverContext ) { assertUser(user); @@ -147,7 +167,7 @@ export default { // const [{ userId: replyUserId }, article] = - await loaders.docLoader.loadMany([ + (await loaders.docLoader.loadMany([ { index: 'replies', id: replyId, @@ -156,11 +176,18 @@ export default { index: 'articles', id: articleId, }, - ]); + ])) as [Reply, Article]; - const { userId: articleReplyUserId } = article.articleReplies.find( - (ar) => ar.replyId === replyId - ); + const ar = article.articleReplies.find((ar) => ar.replyId === replyId); + + // Make typescript happy + if (!ar) { + throw new Error( + `Cannot find article-reply with article ID = ${articleId} and reply ID = ${replyId}` + ); + } + + const { userId: articleReplyUserId } = ar; await client.update({ index: 'articlereplyfeedbacks', diff --git a/src/graphql/mutations/UpdateArticleReplyStatus.js b/src/graphql/mutations/UpdateArticleReplyStatus.js index 3b69640e..8f9428d2 100644 --- a/src/graphql/mutations/UpdateArticleReplyStatus.js +++ b/src/graphql/mutations/UpdateArticleReplyStatus.js @@ -12,7 +12,7 @@ import { getContentDefaultStatus } from 'util/user'; * @param {string} arg.appId * @param {string} arg.status * - * @returns {ArticleReply[]} list of article replies after delete + * @returns {Promise} list of article replies after delete */ export async function updateArticleReplyStatus({ articleId, diff --git a/src/graphql/mutations/__tests__/CreateCategory.js b/src/graphql/mutations/__tests__/CreateCategory.js index f41d1597..bd47c3db 100644 --- a/src/graphql/mutations/__tests__/CreateCategory.js +++ b/src/graphql/mutations/__tests__/CreateCategory.js @@ -22,6 +22,8 @@ describe('CreateCategory', () => { { userId: 'test', appId: 'test' } ); + expect(errors).toBeUndefined(); + const categoryId = data.CreateCategory.id; const { body: category } = await client.get({ index: 'categories', @@ -29,7 +31,6 @@ describe('CreateCategory', () => { id: categoryId, }); - expect(errors).toBeUndefined(); expect(category._source).toMatchSnapshot('created category'); // Cleanup diff --git a/src/index.js b/src/index.js index ecc5d54f..aa8dcb12 100644 --- a/src/index.js +++ b/src/index.js @@ -12,11 +12,12 @@ import passport from 'koa-passport'; import { formatError } from 'graphql'; import checkHeaders from './checkHeaders'; import schema from './graphql/schema'; -import DataLoaders from './graphql/dataLoaders'; +import contextFactory from './contextFactory'; + import CookieStore from './CookieStore'; import { loginRouter, authRouter } from './auth'; import rollbar from './rollbarInstance'; -import { AUTH_ERROR_MSG, createOrUpdateUser } from './util/user'; +import { AUTH_ERROR_MSG } from './util/user'; const app = new Koa(); const router = Router(); @@ -101,34 +102,7 @@ export const apolloServer = new ApolloServer({ schema, introspection: true, // Allow introspection in production as well playground: true, - context: async ({ ctx }) => { - const { - appId, - query: { userId: queryUserId } = {}, - state: { user: { userId: sessionUserId } = {} } = {}, - } = ctx; - - const userId = queryUserId ?? sessionUserId; - - let currentUser = null; - if (appId && userId) { - ({ user: currentUser } = await createOrUpdateUser({ - userId, - appId, - })); - } - - return { - loaders: new DataLoaders(), // new loaders per request - user: currentUser, - - // userId-appId pair - // - userId: currentUser?.id, - appUserId: userId, - appId, - }; - }, + context: contextFactory, formatError(err) { // make web clients know they should login // diff --git a/src/rumors-db b/src/rumors-db index 7935de8d..aee703e5 160000 --- a/src/rumors-db +++ b/src/rumors-db @@ -1 +1 @@ -Subproject commit 7935de8dde124f1783a6d5d5349d0646e04642ab +Subproject commit aee703e5733566522bcd065d68ce39b1fec936a9 diff --git a/src/scripts/blockUser.js b/src/scripts/blockUser.ts similarity index 84% rename from src/scripts/blockUser.js rename to src/scripts/blockUser.ts index ad1de5a4..f5f793c5 100644 --- a/src/scripts/blockUser.js +++ b/src/scripts/blockUser.ts @@ -11,13 +11,17 @@ import getAllDocs from 'util/getAllDocs'; import { updateArticleReplyStatus } from 'graphql/mutations/UpdateArticleReplyStatus'; import { updateArticleReplyByFeedbacks } from 'graphql/mutations/CreateOrUpdateArticleReplyFeedback'; +import type { ReplyRequest } from 'rumors-db/schema/replyrequests'; +import type { Article } from 'rumors-db/schema/articles'; +import type { ArticleReplyFeedback } from 'rumors-db/schema/articlereplyfeedbacks'; + /** * Update user to write blockedReason. Throws if user does not exist. * - * @param {string} userId - * @param {string} blockedReason + * @param userId + * @param blockedReason */ -async function writeBlockedReasonToUser(userId, blockedReason) { +async function writeBlockedReasonToUser(userId: string, blockedReason: string) { try { const { body: { result: setBlockedReasonResult }, @@ -38,7 +42,12 @@ async function writeBlockedReasonToUser(userId, blockedReason) { } } catch (e) { /* istanbul ignore else */ - if (e.message === 'document_missing_exception') { + if ( + e && + typeof e === 'object' && + 'message' in e && + e.message === 'document_missing_exception' + ) { throw new Error(`User with ID=${userId} does not exist`); } @@ -49,20 +58,20 @@ async function writeBlockedReasonToUser(userId, blockedReason) { /** * Convert all reply requests with NORMAL status by the user to BLOCKED status. * - * @param {userId} userId + * @param userId */ -async function processReplyRequests(userId) { +async function processReplyRequests(userId: string) { const NORMAL_REPLY_REQUEST_QUERY = { bool: { must: [{ term: { status: 'NORMAL' } }, { term: { userId } }], }, }; - const articleIdsWithNormalReplyRequests = []; + const articleIdsWithNormalReplyRequests: string[] = []; for await (const { _source: { articleId }, - } of getAllDocs('replyrequests', NORMAL_REPLY_REQUEST_QUERY)) { + } of getAllDocs('replyrequests', NORMAL_REPLY_REQUEST_QUERY)) { articleIdsWithNormalReplyRequests.push(articleId); } @@ -133,9 +142,9 @@ async function processReplyRequests(userId) { /** * Convert all article replies with NORMAL status by the user to BLOCKED status. * - * @param {userId} userId + * @param userId */ -async function processArticleReplies(userId) { +async function processArticleReplies(userId: string) { const NORMAL_ARTICLE_REPLY_QUERY = { nested: { path: 'articleReplies', @@ -158,11 +167,13 @@ async function processArticleReplies(userId) { }, }; - const articleRepliesToProcess = []; + const articleRepliesToProcess: Array< + Article['articleReplies'][0] & { articleId: string } + > = []; for await (const { _id, _source: { articleReplies }, - } of getAllDocs('articles', NORMAL_ARTICLE_REPLY_QUERY)) { + } of getAllDocs
('articles', NORMAL_ARTICLE_REPLY_QUERY)) { articleRepliesToProcess.push( ...articleReplies .filter((ar) => { @@ -198,9 +209,9 @@ async function processArticleReplies(userId) { /** * Convert all article reply feedbacks with NORMAL status by the user to BLOCKED status. * - * @param {userId} userId + * @param userId */ -async function processArticleReplyFeedbacks(userId) { +async function processArticleReplyFeedbacks(userId: string) { const NORMAL_FEEDBACK_QUERY = { bool: { must: [{ term: { status: 'NORMAL' } }, { term: { userId } }], @@ -212,7 +223,10 @@ async function processArticleReplyFeedbacks(userId) { for await (const { _source: { articleId, replyId }, - } of getAllDocs('articlereplyfeedbacks', NORMAL_FEEDBACK_QUERY)) { + } of getAllDocs( + 'articlereplyfeedbacks', + NORMAL_FEEDBACK_QUERY + )) { articleReplyIdsWithNormalFeedbacks.push({ articleId, replyId }); } @@ -244,8 +258,8 @@ async function processArticleReplyFeedbacks(userId) { i, { articleId, replyId }, ] of articleReplyIdsWithNormalFeedbacks.entries()) { - const feedbacks = []; - for await (const { _source: feedback } of getAllDocs( + const feedbacks: ArticleReplyFeedback[] = []; + for await (const { _source: feedback } of getAllDocs( 'articlereplyfeedbacks', { bool: { @@ -276,7 +290,7 @@ async function processArticleReplyFeedbacks(userId) { * * @param {string} userId */ -async function processArticles(userId) { +async function processArticles(userId: string) { const NORMAL_ARTICLE_QUERY = { bool: { must: [{ term: { status: 'NORMAL' } }, { term: { userId } }], @@ -300,10 +314,13 @@ async function processArticles(userId) { console.log('Article status update result', updateByQueryResult); } -/** - * @param {object} args - */ -async function main({ userId, blockedReason } = {}) { +async function main({ + userId, + blockedReason, +}: { + userId: string; + blockedReason: string; +}) { await writeBlockedReasonToUser(userId, blockedReason); await processArticles(userId); await processReplyRequests(userId); @@ -315,7 +332,7 @@ export default main; /* istanbul ignore if */ if (require.main === module) { - const argv = yargs + const argv = await yargs .options({ userId: { alias: 'u', diff --git a/src/scripts/migrations/__tests__/createBackendUsers.js b/src/scripts/migrations/__tests__/createBackendUsers.js index 5fd6c30e..4d0b286e 100644 --- a/src/scripts/migrations/__tests__/createBackendUsers.js +++ b/src/scripts/migrations/__tests__/createBackendUsers.js @@ -41,7 +41,7 @@ const indices = [ 'analytics', ]; -describe('createBackendUsers', () => { +describe.skip('createBackendUsers', () => { beforeAll(async () => { await loadFixtures(fixtures.fixturesToLoad); diff --git a/src/util/client.js b/src/util/client.ts similarity index 71% rename from src/util/client.js rename to src/util/client.ts index 065b4a2d..e9621696 100644 --- a/src/util/client.js +++ b/src/util/client.ts @@ -4,10 +4,21 @@ export default new elasticsearch.Client({ node: process.env.ELASTICSEARCH_URL, }); +type ProcessMetaArgs = { + _id: string; + _source: T; + found: boolean; + _score: number; + highlight: object; // FIXME: use ES type + inner_hits: object; // FIXME: use ES type + sort: string; + fields: object; +}; + // Processes {_id, _version, found, _source: {...}} to // {id, ..._source}. // -export function processMeta({ +export function processMeta({ _id: id, _source: source, @@ -21,7 +32,7 @@ export function processMeta({ sort, // cursor when sorted fields, // scripted fields (if any) -}) { +}: ProcessMetaArgs) { if (found || _score !== undefined) { return { id, diff --git a/src/util/getAllDocs.ts b/src/util/getAllDocs.ts index b6453c69..7c3ea449 100644 --- a/src/util/getAllDocs.ts +++ b/src/util/getAllDocs.ts @@ -14,7 +14,7 @@ async function* getAllDocs( index: string, query: object = { match_all: {} }, { scroll = '30s', size = 1000 }: { size?: number; scroll?: string } = {} -): AsyncGenerator { +): AsyncGenerator<{ _id: string; _source: T }> { let resp = await client.search({ index, scroll, diff --git a/src/util/user.js b/src/util/user.ts similarity index 66% rename from src/util/user.js rename to src/util/user.ts index 26b48171..52ab269e 100644 --- a/src/util/user.js +++ b/src/util/user.ts @@ -16,20 +16,36 @@ import { sample, random } from 'lodash'; import client, { processMeta } from 'util/client'; import rollbar from 'rollbarInstance'; import crypto from 'crypto'; +import { User } from 'rumors-db/schema/users'; + +type UserInContext = User & { + id: string; + /** Filled by createOrUpdateUsear */ + appId: string; +}; + +type UserAppIdPair = { + userId: string; + appId: string; +}; export const AUTH_ERROR_MSG = 'userId is not set via query string.'; /** - * @param {User | {userId: string; appId: string}} param - user in GraphQL context, or {userId, appId} pair + * @param userOrIds - user in GraphQL context, or {userId, appId} pair */ -export function assertUser(userOrIds) { +export function assertUser( + userOrIds: + | UserInContext /* For user instance */ + | UserAppIdPair /* for legacy {userId, appId} pair */ + | null +): asserts userOrIds is UserInContext { if (userOrIds === null || typeof userOrIds !== 'object') { throw new Error(AUTH_ERROR_MSG); } const userId = - userOrIds.id /* For user instance */ || - userOrIds.userId; /* for legacy {userId, appId} pair */ + 'id' in userOrIds ? userOrIds.id /* For user instance */ : userOrIds.userId; if (!userId) { throw new Error(AUTH_ERROR_MSG); @@ -68,14 +84,18 @@ export const AvatarTypes = { */ export const avatarUrlResolver = (s = 100, d = 'identicon', r = 'g') => - (user) => { + (user: User) => { switch (user.avatarType) { case AvatarTypes.OpenPeeps: return null; case AvatarTypes.Facebook: - return `https://graph.facebook.com/v9.0/${user.facebookId}/picture?height=${s}`; + return 'facebookId' in user + ? `https://graph.facebook.com/v9.0/${user.facebookId}/picture?height=${s}` + : null; case AvatarTypes.Github: - return `https://avatars2.githubusercontent.com/u/${user.githubId}?s=${s}`; + return 'githubId' in user + ? `https://avatars2.githubusercontent.com/u/${user.githubId}?s=${s}` + : null; case AvatarTypes.Gravatar: default: { // return hash based on user email for gravatar url @@ -96,15 +116,17 @@ export const avatarUrlResolver = /** * Returns a list of avatar type options based on information available for a user. */ -export const getAvailableAvatarTypes = (user) => { - let types = [AvatarTypes.OpenPeeps]; +export const getAvailableAvatarTypes = (user: User | undefined) => { + const types = [AvatarTypes.OpenPeeps]; if (user?.email) types.push(AvatarTypes.Gravatar); - if (user?.facebookId) types.push(AvatarTypes.Facebook); - if (user?.githubId) types.push(AvatarTypes.Github); + if (user && 'facebookId' in user && user.facebookId) + types.push(AvatarTypes.Facebook); + if (user && 'githubId' in user && user.githubId) + types.push(AvatarTypes.Github); return types; }; -export const isBackendApp = (appId) => +export const isBackendApp = (appId: UserAppIdPair['appId']) => appId !== 'WEBSITE' && appId !== 'DEVELOPMENT_FRONTEND'; // 6 for appId prefix and 43 for 256bit hashed userId with base64 encoding. @@ -137,7 +159,7 @@ export const generateOpenPeepsAvatar = () => { /** * Given appId, userId pair, where userId could be appUserId or dbUserID, returns the id of corresponding user in db. */ -export const getUserId = ({ appId, userId }) => { +export const getUserId = ({ appId, userId }: UserAppIdPair) => { if (!appId || !isBackendApp(appId) || isDBUserId({ appId, userId })) return userId; else return convertAppUserIdToUserId({ appId, appUserId: userId }); @@ -146,13 +168,13 @@ export const getUserId = ({ appId, userId }) => { /** * Check if the userId for a backend user is the user id in db or it is the app user Id. */ -export const isDBUserId = ({ appId, userId }) => +export const isDBUserId = ({ appId, userId }: UserAppIdPair) => appId && userId && userId.length === BACKEND_USER_ID_LEN && userId.substr(0, 6) === `${encodeAppId(appId)}_`; -export const encodeAppId = (appId) => +export const encodeAppId = (appId: UserAppIdPair['appId']) => crypto .createHash('md5') .update(appId) @@ -160,7 +182,7 @@ export const encodeAppId = (appId) => .replace(/[+/]/g, '') .substr(0, 5); -export const sha256 = (value) => +export const sha256 = (value: string) => crypto .createHash('sha256') .update(value) @@ -170,27 +192,38 @@ export const sha256 = (value) => .replace(/=+$/, ''); /** - * @param {string} appUserId - user ID given by an backend app - * @param {string} appId - app ID - * @returns {string} the id used to index `user` in db + * @param appUserId - user ID given by an backend app + * @param appId - app ID + * @returns the id used to index `user` in db */ -export const convertAppUserIdToUserId = ({ appId, appUserId }) => { +export const convertAppUserIdToUserId = ({ + appId, + appUserId, +}: { + appId: UserAppIdPair['appId']; + appUserId: string; +}) => { return `${encodeAppId(appId)}_${sha256(appUserId)}`; }; /** * Index backend user if not existed, and record the last active time as now. * - * @param {string} userID - either appUserID given by an backend app or userId for frontend users - * @param {string} appId - app ID + * @param userId - either appUserID given by an backend app or userId for frontend users + * @param appId - app ID * - * @returns {user: User, isCreated: boolean} + * @returns { user: User, isCreated: boolean } */ - -export async function createOrUpdateUser({ userId, appId }) { +export async function createOrUpdateUser({ + userId, + appId, +}: UserAppIdPair): Promise<{ + user: UserInContext; + isCreated: boolean; +}> { assertUser({ appId, userId }); const now = new Date().toISOString(); - const dbUserId = exports.getUserId({ appId, userId }); + const dbUserId = exports.getUserId({ appId, userId }); // For unit test mocking const { body: { result, get: userFound }, } = await client.update({ @@ -216,12 +249,16 @@ export async function createOrUpdateUser({ userId, appId }) { }); const isCreated = result === 'created'; - const user = processMeta({ ...userFound, _id: dbUserId }); + const user = processMeta({ ...userFound, _id: dbUserId }); + + // Make Typescript happy + if (!user) throw new Error('[createOrUpdateUser] Cannot process user'); // checking for collision if ( !isCreated && isBackendApp(appId) && + 'appUserId' in user /* Backend user */ && (user.appId !== appId || user.appUserId !== userId) ) { const errorMessage = `collision found! ${user.appUserId} and ${userId} both hash to ${dbUserId}`; @@ -236,17 +273,17 @@ export async function createOrUpdateUser({ userId, appId }) { } /** - * @param {object} user + * @param user * @returns If the user is blocked (cannot create visible content) */ -export function isUserBlocked(user) { +export function isUserBlocked(user: User) { return !!user.blockedReason; } /** - * @param {object} user - * @returns {'BLOCKED'|'NORMAL'} Default status value for the content generated by the specified user + * @param user + * @returns Default status value for the content generated by the specified user */ -export function getContentDefaultStatus(user) { +export function getContentDefaultStatus(user: User) { return isUserBlocked(user) ? 'BLOCKED' : 'NORMAL'; }