Merge pull request #2332 from omnivore-app/feature/favorites

feature/favorites
This commit is contained in:
Hongbo Wu
2023-06-09 19:00:48 +08:00
committed by GitHub
13 changed files with 322 additions and 57 deletions

View File

@ -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 &&

View File

@ -31,4 +31,7 @@ export class Label {
@Column('integer', { default: 0 })
position!: number
@Column('boolean', { default: false })
internal!: boolean
}

View File

@ -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<Scalars['Date']>;
description?: Maybe<Scalars['String']>;
id: Scalars['ID'];
internal?: Maybe<Scalars['Boolean']>;
name: Scalars['String'];
position?: Maybe<Scalars['Int']>;
};
@ -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<SetFavoriteArticleErrorCode>;
};
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<SetFollowErrorCode>;
@ -3598,6 +3625,10 @@ export type ResolversTypes = {
SetDeviceTokenInput: SetDeviceTokenInput;
SetDeviceTokenResult: ResolversTypes['SetDeviceTokenError'] | ResolversTypes['SetDeviceTokenSuccess'];
SetDeviceTokenSuccess: ResolverTypeWrapper<SetDeviceTokenSuccess>;
SetFavoriteArticleError: ResolverTypeWrapper<SetFavoriteArticleError>;
SetFavoriteArticleErrorCode: SetFavoriteArticleErrorCode;
SetFavoriteArticleResult: ResolversTypes['SetFavoriteArticleError'] | ResolversTypes['SetFavoriteArticleSuccess'];
SetFavoriteArticleSuccess: ResolverTypeWrapper<SetFavoriteArticleSuccess>;
SetFollowError: ResolverTypeWrapper<SetFollowError>;
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<ContextType = ResolverContext, ParentType extends Res
createdAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
internal?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
position?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5062,6 +5097,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
saveUrl?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSaveUrlArgs, 'input'>>;
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFavoriteArticle?: Resolver<ResolversTypes['SetFavoriteArticleResult'], ParentType, ContextType, RequireFields<MutationSetFavoriteArticleArgs, 'id'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setIntegration?: Resolver<ResolversTypes['SetIntegrationResult'], ParentType, ContextType, RequireFields<MutationSetIntegrationArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
@ -5539,6 +5575,20 @@ export type SetDeviceTokenSuccessResolvers<ContextType = ResolverContext, Parent
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetFavoriteArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFavoriteArticleError'] = ResolversParentTypes['SetFavoriteArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetFavoriteArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetFavoriteArticleResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFavoriteArticleResult'] = ResolversParentTypes['SetFavoriteArticleResult']> = {
__resolveType: TypeResolveFn<'SetFavoriteArticleError' | 'SetFavoriteArticleSuccess', ParentType, ContextType>;
};
export type SetFavoriteArticleSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFavoriteArticleSuccess'] = ResolversParentTypes['SetFavoriteArticleSuccess']> = {
favoriteArticle?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetFollowErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetFollowError'] = ResolversParentTypes['SetFollowError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetFollowErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -6260,6 +6310,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SetDeviceTokenError?: SetDeviceTokenErrorResolvers<ContextType>;
SetDeviceTokenResult?: SetDeviceTokenResultResolvers<ContextType>;
SetDeviceTokenSuccess?: SetDeviceTokenSuccessResolvers<ContextType>;
SetFavoriteArticleError?: SetFavoriteArticleErrorResolvers<ContextType>;
SetFavoriteArticleResult?: SetFavoriteArticleResultResolvers<ContextType>;
SetFavoriteArticleSuccess?: SetFavoriteArticleSuccessResolvers<ContextType>;
SetFollowError?: SetFollowErrorResolvers<ContextType>;
SetFollowResult?: SetFollowResultResolvers<ContextType>;
SetFollowSuccess?: SetFollowSuccessResolvers<ContextType>;

View File

@ -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!]!
}

View File

@ -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

View File

@ -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'),
}

View File

@ -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<LabelsSuccess, LabelsError>(
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',

View File

@ -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

View File

@ -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, {

View File

@ -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<Label[][]> => {
@ -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<Label | null> => {
return getRepository(Label)
.createQueryBuilder()
.where({ user: { id: userId } })
.andWhere('LOWER(name) = LOWER(:name)', { name })
.getOne()
}
export const createLabel = async (
userId: string,
label: {
name: string
color?: string
description?: string
color?: string | null
description?: string | null
}
): Promise<Label> => {
const existingLabel = await getRepository(Label)
.createQueryBuilder()
.where({ user: { id: userId } })
.andWhere('LOWER(name) = LOWER(:name)', { name: label.name })
.getOne()
if (existingLabel) {
return existingLabel
}
// create a new label and assign a random color if not provided
label.color = label.color || generateRandomColor()
return getRepository(Label).save({
...label,
user: { id: userId },
name: label.name,
color: label.color || generateRandomColor(), // assign a random color if not provided
description: label.description,
internal: isLabelInternal(label.name),
})
}
@ -141,6 +141,7 @@ export const createLabels = async (
name: l.name,
description: l.description,
color: l.color || generateRandomColor(),
internal: isLabelInternal(l.name),
user,
}))
)

View File

@ -1293,4 +1293,67 @@ describe('Article API', () => {
})
})
})
describe('SetFavoriteArticle API', () => {
const setFavoriteArticleQuery = (articleId: string) => `
mutation {
setFavoriteArticle(id: "${articleId}") {
... on SetFavoriteArticleSuccess {
favoriteArticle {
id
labels {
name
}
}
}
... on SetFavoriteArticleError {
errorCodes
}
}
}`
let articleId = ''
before(async () => {
const page: Page = {
id: '',
hash: '',
userId: user.id,
pageType: PageType.Article,
title: 'test setFavoriteArticle',
content: '',
slug: '',
createdAt: new Date(),
updatedAt: new Date(),
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
url: 'https://test.omnivore.app/setFavoriteArticle',
savedAt: new Date(),
state: ArticleSavingRequestStatus.Succeeded,
}
articleId = (await createPage(page, ctx))!
})
after(async () => {
// Delete the page
await deletePagesByParam({ userId: user.id }, ctx)
})
it('favorites the article', async () => {
const res = await graphqlRequest(
setFavoriteArticleQuery(articleId),
authToken
).expect(200)
console.log(res.body.data.setFavoriteArticle.favoriteArticle)
expect(res.body.data.setFavoriteArticle.favoriteArticle.id).to.eq(
articleId
)
expect(
res.body.data.setFavoriteArticle.favoriteArticle.labels[0].name
).to.eq('Favorites')
const page = await getPageById(articleId)
expect(page?.labels?.map((l) => l.name)).to.eql(['Favorites'])
})
})
})

View File

@ -0,0 +1,11 @@
-- Type: DO
-- Name: add_internal_field_in_labels
-- Description: Add a new boolean field internal in labels table and set it to true for newsletters and favorites
BEGIN;
ALTER TABLE omnivore.labels ADD COLUMN internal boolean NOT NULL DEFAULT false;
UPDATE omnivore.labels SET internal = true WHERE LOWER(name) = 'newsletters' OR LOWER(name) = 'favorites';
COMMIT;

View File

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: add_internal_field_in_labels
-- Description: Add a new boolean field internal in labels table
BEGIN;
ALTER TABLE omnivore.labels DROP COLUMN IF EXISTS internal;
COMMIT;