diff --git a/packages/api/src/elastic/recommendation.ts b/packages/api/src/elastic/recommendation.ts new file mode 100644 index 000000000..511b3fae1 --- /dev/null +++ b/packages/api/src/elastic/recommendation.ts @@ -0,0 +1,53 @@ +import { Page, PageContext, Recommendation } from './types' +import { createPage, getPageByParam, updatePage } from './pages' + +export const addRecommendation = async ( + ctx: PageContext, + page: Page, + recommendation: Recommendation +): Promise => { + try { + // check if the page is already recommended to the group + const existingPage = await getPageByParam({ + 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 + ) + + await updatePage( + existingPage.id, + { + recommendedBy, + }, + ctx + ) + return existingPage.id + } + + // create a new page + const newPage: Page = { + ...page, + id: '', + recommendedBy: [recommendation], + userId: ctx.uid, + readingProgressPercent: 0, + readingProgressAnchorIndex: 0, + sharedAt: new Date(), + highlights: [], + readAt: undefined, + labels: [], + } + + return createPage(newPage, ctx) + } catch (err) { + console.error(err) + } +} diff --git a/packages/api/src/elastic/types.ts b/packages/api/src/elastic/types.ts index 46b9722f7..c838750c6 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 Recommendation { + 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?: Recommendation[] } export interface SearchItem { @@ -256,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..9e92501e6 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,11 @@ 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' +import { env } from '../../env' export const createGroupResolver = authorized< CreateGroupSuccess, @@ -116,3 +125,85 @@ export const groupsResolver = authorized( } } ) + +export const recommendResolver = authorized< + RecommendSuccess, + RecommendError, + MutationRecommendArgs +>(async (_, { input }, { claims: { uid }, log, signToken }) => { + 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) }, + relations: ['members', 'members.user'], + }) + if (groups.length === 0) { + return { + errorCodes: [RecommendErrorCode.NotFound], + } + } + + const page = await getPageByParam({ _id: input.pageId, userId: uid }) + if (!page) { + return { + errorCodes: [RecommendErrorCode.NotFound], + } + } + + 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.user.id !== uid) + .map((member) => + enqueueRecommendation( + member.user.id, + page.id, + { + ...group, + recommendedAt: new Date(), + }, + auth + ) + ) + ) + .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 39ae6295a..da74925fc 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 { Recommendation } 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, pageId, recommendation } = req.body as { + userId: string + pageId: string + recommendation: Recommendation + } + if (!userId || !pageId || !recommendation) { + return res.status(400).send({ errorCode: 'BAD_DATA' }) + } + + const ctx = { + uid: userId, + 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, + recommendation + ) + 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/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/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..66ade68e5 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 { Recommendation } from '../elastic/types' import View = google.cloud.tasks.v2.Task.View const logger = buildLogger('app.dispatch') @@ -26,6 +27,7 @@ const createHttpTaskWithToken = async ({ payload, priority = 'high', scheduleTime, + requestHeaders, }: { project: string queue?: string @@ -35,6 +37,7 @@ const createHttpTaskWithToken = async ({ payload: unknown priority?: 'low' | 'high' scheduleTime?: number + requestHeaders?: Record }): Promise< [ protos.google.cloud.tasks.v2.ITask, @@ -70,6 +73,7 @@ const createHttpTaskWithToken = async ({ url: taskHandlerUrl, headers: { 'Content-Type': 'application/json', + ...requestHeaders, }, body, ...(serviceAccountEmail @@ -95,7 +99,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 +282,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 +409,52 @@ export const enqueueTextToSpeech = async ({ return createdTasks[0].name } +export const enqueueRecommendation = async ( + userId: string, + pageId: string, + recommendation: Recommendation, + authToken: string +): Promise => { + const { GOOGLE_CLOUD_PROJECT } = process.env + const payload = { + userId, + pageId, + 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) { + // 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) { + 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 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" + } + } } } }