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