diff --git a/packages/api/src/elastic/labels.ts b/packages/api/src/elastic/labels.ts index 5fcb0ba49..a0bcf1b30 100644 --- a/packages/api/src/elastic/labels.ts +++ b/packages/api/src/elastic/labels.ts @@ -254,6 +254,8 @@ export const updateLabel = async ( }, refresh: ctx.refresh, conflicts: 'proceed', // ignore conflicts + requests_per_second: 500, // throttle the requests + slices: 'auto', // parallelize the requests }) body.updated > 0 && diff --git a/packages/api/src/entity/label.ts b/packages/api/src/entity/label.ts index 0088e3319..dd0c65079 100644 --- a/packages/api/src/entity/label.ts +++ b/packages/api/src/entity/label.ts @@ -31,4 +31,7 @@ export class Label { @Column('integer', { default: 0 }) position!: number + + @Column('boolean', { default: false }) + internal!: boolean } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 343d03c58..2c2af3081 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -586,6 +586,7 @@ export type DeleteLabelError = { export enum DeleteLabelErrorCode { BadRequest = 'BAD_REQUEST', + Forbidden = 'FORBIDDEN', NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } @@ -1021,6 +1022,7 @@ export type Label = { createdAt?: Maybe; description?: Maybe; id: Scalars['ID']; + internal?: Maybe; name: Scalars['String']; position?: Maybe; }; @@ -1269,6 +1271,7 @@ export type Mutation = { saveUrl: SaveResult; setBookmarkArticle: SetBookmarkArticleResult; setDeviceToken: SetDeviceTokenResult; + setFavoriteArticle: SetFavoriteArticleResult; setFollow: SetFollowResult; setIntegration: SetIntegrationResult; setLabels: SetLabelsResult; @@ -1514,6 +1517,11 @@ export type MutationSetDeviceTokenArgs = { }; +export type MutationSetFavoriteArticleArgs = { + id: Scalars['ID']; +}; + + export type MutationSetFollowArgs = { input: SetFollowInput; }; @@ -2391,6 +2399,25 @@ export type SetDeviceTokenSuccess = { deviceToken: DeviceToken; }; +export type SetFavoriteArticleError = { + __typename?: 'SetFavoriteArticleError'; + errorCodes: Array; +}; + +export enum SetFavoriteArticleErrorCode { + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArticleSuccess; + +export type SetFavoriteArticleSuccess = { + __typename?: 'SetFavoriteArticleSuccess'; + favoriteArticle: Article; +}; + export type SetFollowError = { __typename?: 'SetFollowError'; errorCodes: Array; @@ -3598,6 +3625,10 @@ export type ResolversTypes = { SetDeviceTokenInput: SetDeviceTokenInput; SetDeviceTokenResult: ResolversTypes['SetDeviceTokenError'] | ResolversTypes['SetDeviceTokenSuccess']; SetDeviceTokenSuccess: ResolverTypeWrapper; + SetFavoriteArticleError: ResolverTypeWrapper; + SetFavoriteArticleErrorCode: SetFavoriteArticleErrorCode; + SetFavoriteArticleResult: ResolversTypes['SetFavoriteArticleError'] | ResolversTypes['SetFavoriteArticleSuccess']; + SetFavoriteArticleSuccess: ResolverTypeWrapper; SetFollowError: ResolverTypeWrapper; SetFollowErrorCode: SetFollowErrorCode; SetFollowInput: SetFollowInput; @@ -4014,6 +4045,9 @@ export type ResolversParentTypes = { SetDeviceTokenInput: SetDeviceTokenInput; SetDeviceTokenResult: ResolversParentTypes['SetDeviceTokenError'] | ResolversParentTypes['SetDeviceTokenSuccess']; SetDeviceTokenSuccess: SetDeviceTokenSuccess; + SetFavoriteArticleError: SetFavoriteArticleError; + SetFavoriteArticleResult: ResolversParentTypes['SetFavoriteArticleError'] | ResolversParentTypes['SetFavoriteArticleSuccess']; + SetFavoriteArticleSuccess: SetFavoriteArticleSuccess; SetFollowError: SetFollowError; SetFollowInput: SetFollowInput; SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess']; @@ -4874,6 +4908,7 @@ export type LabelResolvers, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; id?: Resolver; + internal?: Resolver, ParentType, ContextType>; name?: Resolver; position?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5062,6 +5097,7 @@ export type MutationResolvers>; setBookmarkArticle?: Resolver>; setDeviceToken?: Resolver>; + setFavoriteArticle?: Resolver>; setFollow?: Resolver>; setIntegration?: Resolver>; setLabels?: Resolver>; @@ -5539,6 +5575,20 @@ export type SetDeviceTokenSuccessResolvers; }; +export type SetFavoriteArticleErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SetFavoriteArticleResultResolvers = { + __resolveType: TypeResolveFn<'SetFavoriteArticleError' | 'SetFavoriteArticleSuccess', ParentType, ContextType>; +}; + +export type SetFavoriteArticleSuccessResolvers = { + favoriteArticle?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SetFollowErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6260,6 +6310,9 @@ export type Resolvers = { SetDeviceTokenError?: SetDeviceTokenErrorResolvers; SetDeviceTokenResult?: SetDeviceTokenResultResolvers; SetDeviceTokenSuccess?: SetDeviceTokenSuccessResolvers; + SetFavoriteArticleError?: SetFavoriteArticleErrorResolvers; + SetFavoriteArticleResult?: SetFavoriteArticleResultResolvers; + SetFavoriteArticleSuccess?: SetFavoriteArticleSuccessResolvers; SetFollowError?: SetFollowErrorResolvers; SetFollowResult?: SetFollowResultResolvers; SetFollowSuccess?: SetFollowSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index f7ff74334..7919e1273 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -518,6 +518,7 @@ type DeleteLabelError { enum DeleteLabelErrorCode { BAD_REQUEST + FORBIDDEN NOT_FOUND UNAUTHORIZED } @@ -908,6 +909,7 @@ type Label { createdAt: Date description: String id: ID! + internal: Boolean name: String! position: Int } @@ -1137,6 +1139,7 @@ type Mutation { saveUrl(input: SaveUrlInput!): SaveResult! setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult! setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult! + setFavoriteArticle(id: ID!): SetFavoriteArticleResult! setFollow(input: SetFollowInput!): SetFollowResult! setIntegration(input: SetIntegrationInput!): SetIntegrationResult! setLabels(input: SetLabelsInput!): SetLabelsResult! @@ -1772,6 +1775,23 @@ type SetDeviceTokenSuccess { deviceToken: DeviceToken! } +type SetFavoriteArticleError { + errorCodes: [SetFavoriteArticleErrorCode!]! +} + +enum SetFavoriteArticleErrorCode { + ALREADY_EXISTS + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArticleSuccess + +type SetFavoriteArticleSuccess { + favoriteArticle: Article! +} + type SetFollowError { errorCodes: [SetFollowErrorCode!]! } diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index abcdc55fd..c78cc6c2e 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -42,6 +42,7 @@ import { MutationCreateArticleArgs, MutationSaveArticleReadingProgressArgs, MutationSetBookmarkArticleArgs, + MutationSetFavoriteArticleArgs, MutationSetShareArticleArgs, PageInfo, QueryArticleArgs, @@ -60,6 +61,9 @@ import { SetBookmarkArticleError, SetBookmarkArticleErrorCode, SetBookmarkArticleSuccess, + SetFavoriteArticleError, + SetFavoriteArticleErrorCode, + SetFavoriteArticleSuccess, SetShareArticleError, SetShareArticleErrorCode, SetShareArticleSuccess, @@ -74,7 +78,11 @@ import { UpdatesSinceSuccess, } from '../../generated/graphql' import { createPageSaveRequest } from '../../services/create_page_save_request' -import { createLabels, getLabelsByIds } from '../../services/labels' +import { + addLabelToPage, + createLabels, + getLabelsByIds, +} from '../../services/labels' import { parsedContentToPage } from '../../services/save_page' import { traceAs } from '../../tracing' import { Merge } from '../../util' @@ -1152,6 +1160,70 @@ export const bulkActionResolver = authorized< } ) +export type SetFavoriteArticleSuccessPartial = Merge< + SetFavoriteArticleSuccess, + { favoriteArticle: PartialArticle } +> +export const setFavoriteArticleResolver = authorized< + SetFavoriteArticleSuccessPartial, + SetFavoriteArticleError, + MutationSetFavoriteArticleArgs +>(async (_, { id }, { claims: { uid }, log, pubsub }) => { + log.info('setFavoriteArticleResolver', { id }) + + if (!uid) { + return { errorCodes: [SetFavoriteArticleErrorCode.Unauthorized] } + } + + try { + analytics.track({ + userId: uid, + event: 'setFavoriteArticle', + properties: { + env: env.server.apiEnv, + id, + }, + }) + + const page = await getPageByParam({ userId: uid, _id: id }) + if (!page) { + return { errorCodes: [SetFavoriteArticleErrorCode.NotFound] } + } + + const label = { + id: '', + name: 'Favorites', + color: '#FFD700', // gold + } + + // adds Favorites label to page + const result = await addLabelToPage( + { + uid, + pubsub, + }, + page.id, + label + ) + if (!result) { + return { errorCodes: [SetFavoriteArticleErrorCode.AlreadyExists] } + } + + log.debug('Favorites label added:', result) + + return { + favoriteArticle: { + ...page, + labels: page.labels ? [...page.labels, label] : [label], + isArchived: !!page.archivedAt, + }, + } + } catch (error) { + log.info('Error adding Favorites label:', error) + return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] } + } +}) + const getUpdateReason = (page: Page, since: Date) => { if (page.state === ArticleSavingRequestStatus.Deleted) { return UpdateReason.Deleted diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 0000cf4cf..49e9b6c2e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -87,6 +87,7 @@ import { sendInstallInstructionsResolver, setBookmarkArticleResolver, setDeviceTokenResolver, + setFavoriteArticleResolver, setFollowResolver, setIntegrationResolver, setLabelsForHighlightResolver, @@ -204,6 +205,7 @@ export const functionResolvers = { markEmailAsItem: markEmailAsItemResolver, bulkAction: bulkActionResolver, importFromIntegration: importFromIntegrationResolver, + setFavoriteArticle: setFavoriteArticleResolver, }, Query: { me: getMeUserResolver, @@ -659,4 +661,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('MarkEmailAsItem'), ...resultResolveTypeResolver('BulkAction'), ...resultResolveTypeResolver('ImportFromIntegration'), + ...resultResolveTypeResolver('SetFavoriteArticle'), } diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index b39f358c8..514cb6dce 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -1,4 +1,4 @@ -import { Between, ILike } from 'typeorm' +import { Between } from 'typeorm' import { createPubSubClient } from '../../datalayer/pubsub' import { getHighlightById } from '../../elastic/highlights' import { @@ -39,9 +39,13 @@ import { UpdateLabelSuccess, } from '../../generated/graphql' import { AppDataSource } from '../../server' -import { getLabelsByIds } from '../../services/labels' +import { + createLabel, + getLabelByName, + getLabelsByIds, +} from '../../services/labels' import { analytics } from '../../utils/analytics' -import { authorized, generateRandomColor } from '../../utils/helpers' +import { authorized } from '../../utils/helpers' export const labelsResolver = authorized( async (_obj, _params, { claims: { uid }, log }) => { @@ -90,8 +94,6 @@ export const createLabelResolver = authorized< >(async (_, { input }, { claims: { uid }, log }) => { log.info('createLabelResolver') - const { name, color, description } = input - try { const user = await getRepository(User).findOneBy({ id: uid }) if (!user) { @@ -101,30 +103,20 @@ export const createLabelResolver = authorized< } // Check if label already exists ignoring case of name - const existingLabel = await getRepository(Label).findOneBy({ - user: { id: user.id }, - name: ILike(name), - }) + const existingLabel = await getLabelByName(uid, input.name) if (existingLabel) { return { errorCodes: [CreateLabelErrorCode.LabelAlreadyExists], } } - const label = await getRepository(Label).save({ - user, - name, - color: color || generateRandomColor(), - description: description || '', - }) + const label = await createLabel(uid, input) analytics.track({ userId: uid, event: 'label_created', properties: { - name, - color, - description, + ...input, env: env.server.apiEnv, }, }) @@ -156,7 +148,7 @@ export const deleteLabelResolver = authorized< } const label = await getRepository(Label).findOne({ - where: { id: labelId }, + where: { id: labelId, user: { id: uid } }, relations: ['user'], }) if (!label) { @@ -165,9 +157,11 @@ export const deleteLabelResolver = authorized< } } - if (label.user.id !== uid) { + // internal labels cannot be deleted + if (label.internal) { + log.info('internal labels cannot be deleted') return { - errorCodes: [DeleteLabelErrorCode.Unauthorized], + errorCodes: [DeleteLabelErrorCode.Forbidden], } } @@ -310,6 +304,15 @@ export const updateLabelResolver = authorized< } } + // internal labels cannot be updated + if (label.internal && label.name.toLowerCase() !== name.toLowerCase()) { + log.info('internal labels cannot be updated') + + return { + errorCodes: [UpdateLabelErrorCode.Forbidden], + } + } + log.info('Updating a label', { labels: { source: 'resolver', diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index f81e8aa27..9a7ec8071 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1418,6 +1418,7 @@ const schema = gql` description: String createdAt: Date position: Int + internal: Boolean } type LabelsSuccess { @@ -1467,6 +1468,7 @@ const schema = gql` UNAUTHORIZED BAD_REQUEST NOT_FOUND + FORBIDDEN } type DeleteLabelError { @@ -2452,6 +2454,25 @@ const schema = gql` BAD_REQUEST } + union SetFavoriteArticleResult = + SetFavoriteArticleSuccess + | SetFavoriteArticleError + + type SetFavoriteArticleSuccess { + favoriteArticle: Article! + } + + type SetFavoriteArticleError { + errorCodes: [SetFavoriteArticleErrorCode!]! + } + + enum SetFavoriteArticleErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + ALREADY_EXISTS + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2549,6 +2570,7 @@ const schema = gql` async: Boolean # if true, return immediately and process in the background ): BulkActionResult! importFromIntegration(integrationId: ID!): ImportFromIntegrationResult! + setFavoriteArticle(id: ID!): SetFavoriteArticleResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/groups.ts b/packages/api/src/services/groups.ts index dd0fb8002..3f2979182 100644 --- a/packages/api/src/services/groups.ts +++ b/packages/api/src/services/groups.ts @@ -1,16 +1,16 @@ -import { User } from '../entity/user' -import { Group } from '../entity/groups/group' -import { Invite } from '../entity/groups/invite' -import { GroupMembership } from '../entity/groups/group_membership' import { nanoid } from 'nanoid' -import { AppDataSource } from '../server' -import { RecommendationGroup, User as GraphqlUser } from '../generated/graphql' +import { Group } from '../entity/groups/group' +import { GroupMembership } from '../entity/groups/group_membership' +import { Invite } from '../entity/groups/invite' +import { RuleActionType } from '../entity/rule' +import { User } from '../entity/user' import { getRepository } from '../entity/utils' import { homePageURL } from '../env' +import { RecommendationGroup, User as GraphqlUser } from '../generated/graphql' +import { AppDataSource } from '../server' import { userDataToUser } from '../utils/helpers' -import { createLabel } from './labels' +import { createLabel, getLabelByName } from './labels' import { createRule } from './rules' -import { RuleActionType } from '../entity/rule' export const createGroup = async (input: { admin: User @@ -228,8 +228,11 @@ export const createLabelAndRuleForGroup = async ( userId: string, groupName: string ) => { - // create a new label for the group - const label = await createLabel(userId, { name: groupName }) + let label = await getLabelByName(userId, groupName) + if (!label) { + // create a new label for the group + label = await createLabel(userId, { name: groupName }) + } // create a rule to add the label to all pages in the group const addLabelPromise = createRule(userId, { diff --git a/packages/api/src/services/labels.ts b/packages/api/src/services/labels.ts index b2251b57a..ebb02c92c 100644 --- a/packages/api/src/services/labels.ts +++ b/packages/api/src/services/labels.ts @@ -9,6 +9,12 @@ import { getRepository } from '../entity/utils' import { CreateLabelInput } from '../generated/graphql' import { generateRandomColor } from '../utils/helpers' +const INTERNAL_LABELS_IN_LOWERCASE = ['newsletters', 'favorites'] + +const isLabelInternal = (name: string): boolean => { + return INTERNAL_LABELS_IN_LOWERCASE.includes(name.toLowerCase()) +} + const batchGetLabelsFromLinkIds = async ( linkIds: readonly string[] ): Promise => { @@ -40,19 +46,12 @@ export const addLabelToPage = async ( return false } - let labelEntity = await getRepository(Label) - .createQueryBuilder() - .where({ user: { id: user.id } }) - .andWhere('LOWER(name) = LOWER(:name)', { name: label.name }) - .getOne() + let labelEntity = await getLabelByName(user.id, label.name) if (!labelEntity) { console.log('creating new label', label.name) - labelEntity = await getRepository(Label).save({ - ...label, - user, - }) + labelEntity = await createLabel(user.id, label) } console.log('adding label to page', label.name, pageId) @@ -80,30 +79,31 @@ export const getLabelsByIds = async ( }) } +export const getLabelByName = async ( + userId: string, + name: string +): Promise