From b19b04bbe6b850817358a6f39f2444f6224dbd4b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 15:24:37 +0800 Subject: [PATCH 1/6] Add recommendedBy nested field in page index --- packages/api/src/elastic/types.ts | 7 +++++++ .../db/elastic_migrations/index_settings.json | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index 46b9722f7..a1554f42b 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -188,6 +188,12 @@ export interface Highlight { highlightPositionAnchorIndex?: number | null } +export interface Group { + id: string + name: string + recommendedAt: Date +} + export interface Page { id: string userId: string @@ -224,6 +230,7 @@ export interface Page { readAt?: Date listenedAt?: Date wordsCount?: number + recommendedBy?: Group[] } export interface SearchItem { diff --git a/packages/db/elastic_migrations/index_settings.json b/packages/db/elastic_migrations/index_settings.json index b0668664a..5d4018d4b 100644 --- a/packages/db/elastic_migrations/index_settings.json +++ b/packages/db/elastic_migrations/index_settings.json @@ -160,6 +160,21 @@ }, "wordsCount": { "type": "integer" + }, + "recommendedBy": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword", + "normalizer": "lowercase_normalizer" + }, + "recommendedAt": { + "type": "date" + } + } } } } From a7d0525d2eea4d06828a83add7e03ca6b88ee53a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 18:26:16 +0800 Subject: [PATCH 2/6] Add recommendPage helper function --- packages/api/src/elastic/groups.ts | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/api/src/elastic/groups.ts diff --git a/packages/api/src/elastic/groups.ts b/packages/api/src/elastic/groups.ts new file mode 100644 index 000000000..d87d5f086 --- /dev/null +++ b/packages/api/src/elastic/groups.ts @@ -0,0 +1,37 @@ +import { Group, Page, PageContext } from './types' +import { createPage, updatePage } from './pages' + +export const recommendPage = async ( + ctx: PageContext, + page: Page, + userId: string, + group: Group, + existingPage?: Page +): Promise => { + try { + if (existingPage) { + // update recommendedBy in the existing page + const recommendedBy = (existingPage.recommendedBy || []).concat(group) + + await updatePage( + existingPage.id, + { + recommendedBy, + }, + ctx + ) + return existingPage.id + } + + // create a new page + const newPage = { + ...page, + recommendedBy: [group], + userId, + } + + return createPage(newPage, ctx) + } catch (err) { + console.error(err) + } +} From 5fd137d23e8866782aabd1d9faf9ce3bfd821758 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 18:49:13 +0800 Subject: [PATCH 3/6] Add recommendation cloud task --- packages/api/src/util.ts | 7 +++-- packages/api/src/utils/createTask.ts | 40 ++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 0ca9ad8b8..1b8ff76f0 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -62,9 +62,10 @@ interface BackendEnv { name: string contentFetchUrl: string contentFetchGCFUrl: string - reminderTaskHanderUrl: string + reminderTaskHandlerUrl: string integrationTaskHandlerUrl: string textToSpeechTaskHandlerUrl: string + recommendationTaskHandlerUrl: string } fileUpload: { gcsUploadBucket: string @@ -152,6 +153,7 @@ const nullableEnvVars = [ 'AZURE_SPEECH_KEY', 'AZURE_SPEECH_REGION', 'GCP_LOCATION', + 'RECOMMENDATION_TASK_HANDLER_URL', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -234,9 +236,10 @@ export function getEnv(): BackendEnv { name: parse('PUPPETEER_QUEUE_NAME'), contentFetchUrl: parse('CONTENT_FETCH_URL'), contentFetchGCFUrl: parse('CONTENT_FETCH_GCF_URL'), - reminderTaskHanderUrl: parse('REMINDER_TASK_HANDLER_URL'), + reminderTaskHandlerUrl: parse('REMINDER_TASK_HANDLER_URL'), integrationTaskHandlerUrl: parse('INTEGRATION_TASK_HANDLER_URL'), textToSpeechTaskHandlerUrl: parse('TEXT_TO_SPEECH_TASK_HANDLER_URL'), + recommendationTaskHandlerUrl: parse('RECOMMENDATION_TASK_HANDLER_URL'), } const imageProxy = { url: parse('IMAGE_PROXY_URL'), diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index cefe0fead..fe7d9743e 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -10,6 +10,7 @@ import { nanoid } from 'nanoid' import { google } from '@google-cloud/tasks/build/protos/protos' import { IntegrationType } from '../entity/integration' import { signFeatureToken } from '../services/features' +import { Group } from '../elastic/types' import View = google.cloud.tasks.v2.Task.View const logger = buildLogger('app.dispatch') @@ -95,7 +96,7 @@ export const createAppEngineTask = async ({ project, queue = env.queue.name, location = env.queue.location, - taskHandlerUrl = env.queue.reminderTaskHanderUrl, + taskHandlerUrl = env.queue.reminderTaskHandlerUrl, payload, priority = 'high', scheduleTime, @@ -278,7 +279,7 @@ export const enqueueReminder = async ( project: GOOGLE_CLOUD_PROJECT, payload, scheduleTime, - taskHandlerUrl: env.queue.reminderTaskHanderUrl, + taskHandlerUrl: env.queue.reminderTaskHandlerUrl, }) if (!createdTasks || !createdTasks[0].name) { @@ -405,4 +406,39 @@ export const enqueueTextToSpeech = async ({ return createdTasks[0].name } +export const enqueueRecommendation = async ( + recommendedUserId: string, + recommendedPageId: string, + userId: string, + group: Group +): Promise => { + const { GOOGLE_CLOUD_PROJECT } = process.env + const payload = { + userId, + recommendedUserId, + recommendedPageId, + group, + } + + // If there is no Google Cloud Project Id exposed, it means that we are in local environment + if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { + return nanoid() + } + + const createdTasks = await createHttpTaskWithToken({ + project: GOOGLE_CLOUD_PROJECT, + payload, + taskHandlerUrl: env.queue.recommendationTaskHandlerUrl, + }) + + if (!createdTasks || !createdTasks[0].name) { + logger.error(`Unable to get the name of the task`, { + payload, + createdTasks, + }) + throw new CreateTaskError(`Unable to get the name of the task`) + } + return createdTasks[0].name +} + export default createHttpTaskWithToken From 0e3d1ced4668213da962231a2be77378297298dc Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 19:10:41 +0800 Subject: [PATCH 4/6] Add recommend rest api to handler tasks --- .../elastic/{groups.ts => recommendation.ts} | 14 +++-- packages/api/src/routers/page_router.ts | 56 ++++++++++++++++++- packages/api/src/utils/createTask.ts | 6 +- 3 files changed, 64 insertions(+), 12 deletions(-) rename packages/api/src/elastic/{groups.ts => recommendation.ts} (67%) diff --git a/packages/api/src/elastic/groups.ts b/packages/api/src/elastic/recommendation.ts similarity index 67% rename from packages/api/src/elastic/groups.ts rename to packages/api/src/elastic/recommendation.ts index d87d5f086..a1b45a707 100644 --- a/packages/api/src/elastic/groups.ts +++ b/packages/api/src/elastic/recommendation.ts @@ -1,14 +1,18 @@ import { Group, Page, PageContext } from './types' -import { createPage, updatePage } from './pages' +import { createPage, getPageByParam, updatePage } from './pages' -export const recommendPage = async ( +export const addRecommendation = async ( ctx: PageContext, page: Page, - userId: string, - group: Group, - existingPage?: Page + group: Group ): Promise => { try { + const userId = ctx.uid + // check if the page is already recommended to the group + const existingPage = await getPageByParam({ + userId, + url: page.url, + }) if (existingPage) { // update recommendedBy in the existing page const recommendedBy = (existingPage.recommendedBy || []).concat(group) diff --git a/packages/api/src/routers/page_router.ts b/packages/api/src/routers/page_router.ts index 39ae6295a..5a9a6d14a 100644 --- a/packages/api/src/routers/page_router.ts +++ b/packages/api/src/routers/page_router.ts @@ -5,17 +5,14 @@ import express from 'express' import { ArticleSavingRequestStatus, - CreateArticleErrorCode, PageType, UploadFileStatus, } from '../generated/graphql' -import { isSiteBlockedForParse } from '../utils/blocked' import cors from 'cors' import { env } from '../env' import { buildLogger } from '../utils/logger' import * as jwt from 'jsonwebtoken' import { corsConfig } from '../utils/corsConfig' -import { createPageSaveRequest } from '../services/create_page_save_request' import { initModels } from '../server' import { kx } from '../datalayer/knex_config' import { @@ -32,6 +29,8 @@ import { import { Claims } from '../resolvers/types' import { createPage, getPageByParam, updatePage } from '../elastic/pages' import { createPubSubClient } from '../datalayer/pubsub' +import { Group } from '../elastic/types' +import { addRecommendation } from '../elastic/recommendation' const logger = buildLogger('app.dispatch') @@ -149,5 +148,56 @@ export function pageRouter() { return res.redirect(signedUrl) }) + // Add recommended pages to a user's library + router.options( + '/recommend', + cors({ ...corsConfig, maxAge: 600 }) + ) + router.post( + '/recommend', + cors(corsConfig), + async (req, res) => { + const token = req?.cookies?.auth || req?.headers?.authorization + if (!token || !jwt.verify(token, env.server.jwtSecret)) { + return res.status(401).send({ errorCode: 'UNAUTHORIZED' }) + } + const claims = jwt.decode(token) as Claims + + const { + userId: recommendedUserId, + pageId, + group, + } = req.body as { + userId: string + pageId: string + group: Group + } + if (!recommendedUserId || !pageId || !group) { + return res.status(400).send({ errorCode: 'BAD_DATA' }) + } + + const ctx = { + uid: recommendedUserId, + pubsub: createPubSubClient(), + } + + const page = await getPageByParam({ + userId: claims.uid, + _id: pageId, + }) + if (!page) { + return res.status(404).send({ errorCode: 'NOT_FOUND' }) + } + + const recommendedPageId = await addRecommendation(ctx, page, group) + if (!recommendedPageId) { + logger.error('Failed to add recommendation to page') + return res.sendStatus(500) + } + + return res.send({ recommendedPageId }) + } + ) + return router } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index fe7d9743e..a6041668a 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -407,16 +407,14 @@ export const enqueueTextToSpeech = async ({ } export const enqueueRecommendation = async ( - recommendedUserId: string, - recommendedPageId: string, userId: string, + pageId: string, group: Group ): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { userId, - recommendedUserId, - recommendedPageId, + pageId, group, } From 7707f100f22e23da72f3f85c1a5a696b9348183b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 19:45:26 +0800 Subject: [PATCH 5/6] Add recommend gql api --- packages/api/src/elastic/recommendation.ts | 10 ++- packages/api/src/elastic/types.ts | 5 +- .../api/src/entity/groups/group_membership.ts | 2 +- packages/api/src/generated/graphql.ts | 56 +++++++++++++ packages/api/src/generated/schema.graphql | 22 +++++ .../api/src/resolvers/function_resolvers.ts | 3 + .../src/resolvers/recommendations/index.ts | 82 +++++++++++++++++++ packages/api/src/routers/page_router.ts | 14 ++-- packages/api/src/schema.ts | 22 +++++ packages/api/src/utils/createTask.ts | 6 +- 10 files changed, 207 insertions(+), 15 deletions(-) diff --git a/packages/api/src/elastic/recommendation.ts b/packages/api/src/elastic/recommendation.ts index a1b45a707..3f2aaccce 100644 --- a/packages/api/src/elastic/recommendation.ts +++ b/packages/api/src/elastic/recommendation.ts @@ -1,10 +1,10 @@ -import { Group, Page, PageContext } from './types' +import { Page, PageContext, Recommendation } from './types' import { createPage, getPageByParam, updatePage } from './pages' export const addRecommendation = async ( ctx: PageContext, page: Page, - group: Group + recommendation: Recommendation ): Promise => { try { const userId = ctx.uid @@ -15,7 +15,9 @@ export const addRecommendation = async ( }) if (existingPage) { // update recommendedBy in the existing page - const recommendedBy = (existingPage.recommendedBy || []).concat(group) + const recommendedBy = (existingPage.recommendedBy || []).concat( + recommendation + ) await updatePage( existingPage.id, @@ -30,7 +32,7 @@ export const addRecommendation = async ( // create a new page const newPage = { ...page, - recommendedBy: [group], + recommendedBy: [recommendation], userId, } diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index a1554f42b..c838750c6 100644 --- a/packages/api/src/elastic/types.ts +++ b/packages/api/src/elastic/types.ts @@ -188,7 +188,7 @@ export interface Highlight { highlightPositionAnchorIndex?: number | null } -export interface Group { +export interface Recommendation { id: string name: string recommendedAt: Date @@ -230,7 +230,7 @@ export interface Page { readAt?: Date listenedAt?: Date wordsCount?: number - recommendedBy?: Group[] + recommendedBy?: Recommendation[] } export interface SearchItem { @@ -263,6 +263,7 @@ export interface SearchItem { wordsCount?: number siteName?: string siteIcon?: string + recommendedBy?: Recommendation[] } const keys = ['_id', 'url', 'slug', 'userId', 'uploadFileId', 'state'] as const diff --git a/packages/api/src/entity/groups/group_membership.ts b/packages/api/src/entity/groups/group_membership.ts index 30b92d943..0bfc01c63 100644 --- a/packages/api/src/entity/groups/group_membership.ts +++ b/packages/api/src/entity/groups/group_membership.ts @@ -16,7 +16,7 @@ import { Invite } from './invite' @Entity() export class GroupMembership { @PrimaryGeneratedColumn('uuid') - id?: string + id!: string @OneToOne(() => User) @JoinColumn() diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index f9db0f7e3..43d380468 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1132,6 +1132,7 @@ export type Mutation = { moveFilter: MoveFilterResult; moveLabel: MoveLabelResult; optInFeature: OptInFeatureResult; + recommend: RecommendResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; @@ -1301,6 +1302,11 @@ export type MutationOptInFeatureArgs = { }; +export type MutationRecommendArgs = { + input: RecommendInput; +}; + + export type MutationReportItemArgs = { input: ReportItemInput; }; @@ -1743,6 +1749,29 @@ export type RecentSearchesSuccess = { searches: Array; }; +export type RecommendError = { + __typename?: 'RecommendError'; + errorCodes: Array; +}; + +export enum RecommendErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type RecommendInput = { + groupIds: Array; + pageId: Scalars['ID']; +}; + +export type RecommendResult = RecommendError | RecommendSuccess; + +export type RecommendSuccess = { + __typename?: 'RecommendSuccess'; + taskNames: Array; +}; + export type RecommendationGroup = { __typename?: 'RecommendationGroup'; admins: Array; @@ -3157,6 +3186,11 @@ export type ResolversTypes = { RecentSearchesErrorCode: RecentSearchesErrorCode; RecentSearchesResult: ResolversTypes['RecentSearchesError'] | ResolversTypes['RecentSearchesSuccess']; RecentSearchesSuccess: ResolverTypeWrapper; + RecommendError: ResolverTypeWrapper; + RecommendErrorCode: RecommendErrorCode; + RecommendInput: RecommendInput; + RecommendResult: ResolversTypes['RecommendError'] | ResolversTypes['RecommendSuccess']; + RecommendSuccess: ResolverTypeWrapper; RecommendationGroup: ResolverTypeWrapper; Reminder: ResolverTypeWrapper; ReminderError: ResolverTypeWrapper; @@ -3550,6 +3584,10 @@ export type ResolversParentTypes = { RecentSearchesError: RecentSearchesError; RecentSearchesResult: ResolversParentTypes['RecentSearchesError'] | ResolversParentTypes['RecentSearchesSuccess']; RecentSearchesSuccess: RecentSearchesSuccess; + RecommendError: RecommendError; + RecommendInput: RecommendInput; + RecommendResult: ResolversParentTypes['RecommendError'] | ResolversParentTypes['RecommendSuccess']; + RecommendSuccess: RecommendSuccess; RecommendationGroup: RecommendationGroup; Reminder: Reminder; ReminderError: ReminderError; @@ -4547,6 +4585,7 @@ export type MutationResolvers>; moveLabel?: Resolver>; optInFeature?: Resolver>; + recommend?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; @@ -4722,6 +4761,20 @@ export type RecentSearchesSuccessResolvers; }; +export type RecommendErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type RecommendResultResolvers = { + __resolveType: TypeResolveFn<'RecommendError' | 'RecommendSuccess', ParentType, ContextType>; +}; + +export type RecommendSuccessResolvers = { + taskNames?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RecommendationGroupResolvers = { admins?: Resolver, ParentType, ContextType>; createdAt?: Resolver; @@ -5591,6 +5644,9 @@ export type Resolvers = { RecentSearchesError?: RecentSearchesErrorResolvers; RecentSearchesResult?: RecentSearchesResultResolvers; RecentSearchesSuccess?: RecentSearchesSuccessResolvers; + RecommendError?: RecommendErrorResolvers; + RecommendResult?: RecommendResultResolvers; + RecommendSuccess?: RecommendSuccessResolvers; RecommendationGroup?: RecommendationGroupResolvers; Reminder?: ReminderResolvers; ReminderError?: ReminderErrorResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 447b3a776..99f807e79 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1010,6 +1010,7 @@ type Mutation { moveFilter(input: MoveFilterInput!): MoveFilterResult! moveLabel(input: MoveLabelInput!): MoveLabelResult! optInFeature(input: OptInFeatureInput!): OptInFeatureResult! + recommend(input: RecommendInput!): RecommendResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! @@ -1219,6 +1220,27 @@ type RecentSearchesSuccess { searches: [RecentSearch!]! } +type RecommendError { + errorCodes: [RecommendErrorCode!]! +} + +enum RecommendErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input RecommendInput { + groupIds: [ID!]! + pageId: ID! +} + +union RecommendResult = RecommendError | RecommendSuccess + +type RecommendSuccess { + taskNames: [String!]! +} + type RecommendationGroup { admins: [User!]! createdAt: Date! diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 3d7a05808..13805ed4c 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -63,6 +63,7 @@ import { moveFilterResolver, moveLabelResolver, newsletterEmailsResolver, + recommendResolver, reminderResolver, reportItemResolver, revokeApiKeyResolver, @@ -189,6 +190,7 @@ export const functionResolvers = { deleteFilter: deleteFilterResolver, moveFilter: moveFilterResolver, createGroup: createGroupResolver, + recommend: recommendResolver, }, Query: { me: getMeUserResolver, @@ -640,4 +642,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('MoveFilter'), ...resultResolveTypeResolver('CreateGroup'), ...resultResolveTypeResolver('Groups'), + ...resultResolveTypeResolver('Recommend'), } diff --git a/packages/api/src/resolvers/recommendations/index.ts b/packages/api/src/resolvers/recommendations/index.ts index 68cd8a04a..cd74fadbb 100644 --- a/packages/api/src/resolvers/recommendations/index.ts +++ b/packages/api/src/resolvers/recommendations/index.ts @@ -6,6 +6,10 @@ import { GroupsErrorCode, GroupsSuccess, MutationCreateGroupArgs, + MutationRecommendArgs, + RecommendError, + RecommendErrorCode, + RecommendSuccess, } from '../../generated/graphql' import { createGroup, @@ -15,6 +19,10 @@ import { import { authorized, userDataToUser } from '../../utils/helpers' import { getRepository } from '../../entity/utils' import { User } from '../../entity/user' +import { Group } from '../../entity/groups/group' +import { In } from 'typeorm' +import { getPageByParam } from '../../elastic/pages' +import { enqueueRecommendation } from '../../utils/createTask' export const createGroupResolver = authorized< CreateGroupSuccess, @@ -116,3 +124,77 @@ export const groupsResolver = authorized( } } ) + +export const recommendResolver = authorized< + RecommendSuccess, + RecommendError, + MutationRecommendArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('Recommend', { + input, + labels: { + source: 'resolver', + resolver: 'recommendResolver', + uid, + }, + }) + + try { + const user = await getRepository(User).findOneBy({ + id: uid, + }) + if (!user) { + return { + errorCodes: [RecommendErrorCode.Unauthorized], + } + } + + const groups = await getRepository(Group).find({ + where: { id: In(input.groupIds) }, + }) + if (groups.length === 0) { + return { + errorCodes: [RecommendErrorCode.NotFound], + } + } + + const page = await getPageByParam({ _id: input.pageId, userId: uid }) + if (!page) { + return { + errorCodes: [RecommendErrorCode.NotFound], + } + } + + const taskNames = await Promise.all( + groups + .map((group) => + group.members + .filter((member) => member.id !== uid) + .map((member) => + enqueueRecommendation(member.id, page.id, { + ...group, + recommendedAt: new Date(), + }) + ) + ) + .flat() + ) + + return { + taskNames, + } + } catch (error) { + log.error('Error recommending', { + error, + labels: { + source: 'resolver', + resolver: 'recommendResolver', + uid, + }, + }) + + return { + errorCodes: [RecommendErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/routers/page_router.ts b/packages/api/src/routers/page_router.ts index 5a9a6d14a..6edb936a6 100644 --- a/packages/api/src/routers/page_router.ts +++ b/packages/api/src/routers/page_router.ts @@ -29,7 +29,7 @@ import { import { Claims } from '../resolvers/types' import { createPage, getPageByParam, updatePage } from '../elastic/pages' import { createPubSubClient } from '../datalayer/pubsub' -import { Group } from '../elastic/types' +import { Recommendation } from '../elastic/types' import { addRecommendation } from '../elastic/recommendation' const logger = buildLogger('app.dispatch') @@ -166,13 +166,13 @@ export function pageRouter() { const { userId: recommendedUserId, pageId, - group, + recommendation, } = req.body as { userId: string pageId: string - group: Group + recommendation: Recommendation } - if (!recommendedUserId || !pageId || !group) { + if (!recommendedUserId || !pageId || !recommendation) { return res.status(400).send({ errorCode: 'BAD_DATA' }) } @@ -189,7 +189,11 @@ export function pageRouter() { return res.status(404).send({ errorCode: 'NOT_FOUND' }) } - const recommendedPageId = await addRecommendation(ctx, page, group) + const recommendedPageId = await addRecommendation( + ctx, + page, + recommendation + ) if (!recommendedPageId) { logger.error('Failed to add recommendation to page') return res.sendStatus(500) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 0a5ed9910..21c14697a 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2183,6 +2183,27 @@ const schema = gql` BAD_REQUEST } + input RecommendInput { + pageId: ID! + groupIds: [ID!]! + } + + union RecommendResult = RecommendSuccess | RecommendError + + type RecommendSuccess { + taskNames: [String!]! + } + + type RecommendError { + errorCodes: [RecommendErrorCode!]! + } + + enum RecommendErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2261,6 +2282,7 @@ const schema = gql` deleteFilter(id: ID!): DeleteFilterResult! moveFilter(input: MoveFilterInput!): MoveFilterResult! createGroup(input: CreateGroupInput!): CreateGroupResult! + recommend(input: RecommendInput!): RecommendResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index a6041668a..562a0e854 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -10,7 +10,7 @@ import { nanoid } from 'nanoid' import { google } from '@google-cloud/tasks/build/protos/protos' import { IntegrationType } from '../entity/integration' import { signFeatureToken } from '../services/features' -import { Group } from '../elastic/types' +import { Recommendation } from '../elastic/types' import View = google.cloud.tasks.v2.Task.View const logger = buildLogger('app.dispatch') @@ -409,13 +409,13 @@ export const enqueueTextToSpeech = async ({ export const enqueueRecommendation = async ( userId: string, pageId: string, - group: Group + recommendation: Recommendation ): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { userId, pageId, - group, + recommendation, } // If there is no Google Cloud Project Id exposed, it means that we are in local environment From 6b65c386a088e957020391fbdce7592cd3af3df1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Fri, 2 Dec 2022 21:15:33 +0800 Subject: [PATCH 6/6] Add authentication to the request header --- packages/api/src/elastic/recommendation.ts | 18 +++++++++++---- .../src/resolvers/recommendations/index.ts | 21 +++++++++++++----- packages/api/src/routers/page_router.ts | 10 +++------ packages/api/src/utils/createTask.ts | 22 +++++++++++++++++-- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/packages/api/src/elastic/recommendation.ts b/packages/api/src/elastic/recommendation.ts index 3f2aaccce..511b3fae1 100644 --- a/packages/api/src/elastic/recommendation.ts +++ b/packages/api/src/elastic/recommendation.ts @@ -7,13 +7,16 @@ export const addRecommendation = async ( recommendation: Recommendation ): Promise => { try { - const userId = ctx.uid // check if the page is already recommended to the group const existingPage = await getPageByParam({ - userId, + userId: ctx.uid, url: page.url, }) if (existingPage) { + if (existingPage.recommendedBy?.includes(recommendation)) { + return existingPage._id + } + // update recommendedBy in the existing page const recommendedBy = (existingPage.recommendedBy || []).concat( recommendation @@ -30,10 +33,17 @@ export const addRecommendation = async ( } // create a new page - const newPage = { + const newPage: Page = { ...page, + id: '', recommendedBy: [recommendation], - userId, + userId: ctx.uid, + readingProgressPercent: 0, + readingProgressAnchorIndex: 0, + sharedAt: new Date(), + highlights: [], + readAt: undefined, + labels: [], } return createPage(newPage, ctx) diff --git a/packages/api/src/resolvers/recommendations/index.ts b/packages/api/src/resolvers/recommendations/index.ts index cd74fadbb..9e92501e6 100644 --- a/packages/api/src/resolvers/recommendations/index.ts +++ b/packages/api/src/resolvers/recommendations/index.ts @@ -23,6 +23,7 @@ import { Group } from '../../entity/groups/group' import { In } from 'typeorm' import { getPageByParam } from '../../elastic/pages' import { enqueueRecommendation } from '../../utils/createTask' +import { env } from '../../env' export const createGroupResolver = authorized< CreateGroupSuccess, @@ -129,7 +130,7 @@ export const recommendResolver = authorized< RecommendSuccess, RecommendError, MutationRecommendArgs ->(async (_, { input }, { claims: { uid }, log }) => { +>(async (_, { input }, { claims: { uid }, log, signToken }) => { log.info('Recommend', { input, labels: { @@ -151,6 +152,7 @@ export const recommendResolver = authorized< const groups = await getRepository(Group).find({ where: { id: In(input.groupIds) }, + relations: ['members', 'members.user'], }) if (groups.length === 0) { return { @@ -165,16 +167,23 @@ export const recommendResolver = authorized< } } + const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day + const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string const taskNames = await Promise.all( groups .map((group) => group.members - .filter((member) => member.id !== uid) + .filter((member) => member.user.id !== uid) .map((member) => - enqueueRecommendation(member.id, page.id, { - ...group, - recommendedAt: new Date(), - }) + enqueueRecommendation( + member.user.id, + page.id, + { + ...group, + recommendedAt: new Date(), + }, + auth + ) ) ) .flat() diff --git a/packages/api/src/routers/page_router.ts b/packages/api/src/routers/page_router.ts index 6edb936a6..da74925fc 100644 --- a/packages/api/src/routers/page_router.ts +++ b/packages/api/src/routers/page_router.ts @@ -163,21 +163,17 @@ export function pageRouter() { } const claims = jwt.decode(token) as Claims - const { - userId: recommendedUserId, - pageId, - recommendation, - } = req.body as { + const { userId, pageId, recommendation } = req.body as { userId: string pageId: string recommendation: Recommendation } - if (!recommendedUserId || !pageId || !recommendation) { + if (!userId || !pageId || !recommendation) { return res.status(400).send({ errorCode: 'BAD_DATA' }) } const ctx = { - uid: recommendedUserId, + uid: userId, pubsub: createPubSubClient(), } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 562a0e854..66ade68e5 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -27,6 +27,7 @@ const createHttpTaskWithToken = async ({ payload, priority = 'high', scheduleTime, + requestHeaders, }: { project: string queue?: string @@ -36,6 +37,7 @@ const createHttpTaskWithToken = async ({ payload: unknown priority?: 'low' | 'high' scheduleTime?: number + requestHeaders?: Record }): Promise< [ protos.google.cloud.tasks.v2.ITask, @@ -71,6 +73,7 @@ const createHttpTaskWithToken = async ({ url: taskHandlerUrl, headers: { 'Content-Type': 'application/json', + ...requestHeaders, }, body, ...(serviceAccountEmail @@ -409,7 +412,8 @@ export const enqueueTextToSpeech = async ({ export const enqueueRecommendation = async ( userId: string, pageId: string, - recommendation: Recommendation + recommendation: Recommendation, + authToken: string ): Promise => { const { GOOGLE_CLOUD_PROJECT } = process.env const payload = { @@ -418,15 +422,29 @@ export const enqueueRecommendation = async ( recommendation, } + const headers = { + Authorization: authToken, + } // If there is no Google Cloud Project Id exposed, it means that we are in local environment if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) { - return nanoid() + // Calling the handler function directly. + setTimeout(() => { + axios + .post(env.queue.recommendationTaskHandlerUrl, payload, { + headers, + }) + .catch((error) => { + logger.error(error) + }) + }, 0) + return '' } const createdTasks = await createHttpTaskWithToken({ project: GOOGLE_CLOUD_PROJECT, payload, taskHandlerUrl: env.queue.recommendationTaskHandlerUrl, + requestHeaders: headers, }) if (!createdTasks || !createdTasks[0].name) {