Merge pull request #1294 from omnivore-app/feature/saved-search

Recent searches feature
This commit is contained in:
Hongbo Wu 2022-10-12 22:11:57 +08:00 committed by GitHub
commit 624b7b4624
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 347 additions and 0 deletions

View file

@ -0,0 +1,27 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'search_history' })
@Unique('search_history_user_id_term_key', ['user', 'term'])
export class SearchHistory {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('varchar', { length: 255 })
term!: string
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
}

View file

@ -1351,6 +1351,7 @@ export type Query = {
labels: LabelsResult;
me?: Maybe<User>;
newsletterEmails: NewsletterEmailsResult;
recentSearches: RecentSearchesResult;
reminder: ReminderResult;
search: SearchResult;
sendInstallInstructions: SendInstallInstructionsResult;
@ -1483,6 +1484,30 @@ export type ReadState = {
readingTime?: Maybe<Scalars['Int']>;
};
export type RecentSearch = {
__typename?: 'RecentSearch';
createdAt: Scalars['Date'];
id: Scalars['ID'];
term: Scalars['String'];
};
export type RecentSearchesError = {
__typename?: 'RecentSearchesError';
errorCodes: Array<RecentSearchesErrorCode>;
};
export enum RecentSearchesErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type RecentSearchesResult = RecentSearchesError | RecentSearchesSuccess;
export type RecentSearchesSuccess = {
__typename?: 'RecentSearchesSuccess';
searches: Array<RecentSearch>;
};
export type Reminder = {
__typename?: 'Reminder';
archiveUntil: Scalars['Boolean'];
@ -2734,6 +2759,11 @@ export type ResolversTypes = {
Reaction: ResolverTypeWrapper<Reaction>;
ReactionType: ReactionType;
ReadState: ResolverTypeWrapper<ReadState>;
RecentSearch: ResolverTypeWrapper<RecentSearch>;
RecentSearchesError: ResolverTypeWrapper<RecentSearchesError>;
RecentSearchesErrorCode: RecentSearchesErrorCode;
RecentSearchesResult: ResolversTypes['RecentSearchesError'] | ResolversTypes['RecentSearchesSuccess'];
RecentSearchesSuccess: ResolverTypeWrapper<RecentSearchesSuccess>;
Reminder: ResolverTypeWrapper<Reminder>;
ReminderError: ResolverTypeWrapper<ReminderError>;
ReminderErrorCode: ReminderErrorCode;
@ -3075,6 +3105,10 @@ export type ResolversParentTypes = {
Query: {};
Reaction: Reaction;
ReadState: ReadState;
RecentSearch: RecentSearch;
RecentSearchesError: RecentSearchesError;
RecentSearchesResult: ResolversParentTypes['RecentSearchesError'] | ResolversParentTypes['RecentSearchesSuccess'];
RecentSearchesSuccess: RecentSearchesSuccess;
Reminder: Reminder;
ReminderError: ReminderError;
ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess'];
@ -4031,6 +4065,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
labels?: Resolver<ResolversTypes['LabelsResult'], ParentType, ContextType>;
me?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType>;
newsletterEmails?: Resolver<ResolversTypes['NewsletterEmailsResult'], ParentType, ContextType>;
recentSearches?: Resolver<ResolversTypes['RecentSearchesResult'], ParentType, ContextType>;
reminder?: Resolver<ResolversTypes['ReminderResult'], ParentType, ContextType, RequireFields<QueryReminderArgs, 'linkId'>>;
search?: Resolver<ResolversTypes['SearchResult'], ParentType, ContextType, Partial<QuerySearchArgs>>;
sendInstallInstructions?: Resolver<ResolversTypes['SendInstallInstructionsResult'], ParentType, ContextType>;
@ -4062,6 +4097,27 @@ export type ReadStateResolvers<ContextType = ResolverContext, ParentType extends
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RecentSearchResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecentSearch'] = ResolversParentTypes['RecentSearch']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
term?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RecentSearchesErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecentSearchesError'] = ResolversParentTypes['RecentSearchesError']> = {
errorCodes?: Resolver<Array<ResolversTypes['RecentSearchesErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type RecentSearchesResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecentSearchesResult'] = ResolversParentTypes['RecentSearchesResult']> = {
__resolveType: TypeResolveFn<'RecentSearchesError' | 'RecentSearchesSuccess', ParentType, ContextType>;
};
export type RecentSearchesSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RecentSearchesSuccess'] = ResolversParentTypes['RecentSearchesSuccess']> = {
searches?: Resolver<Array<ResolversTypes['RecentSearch']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ReminderResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Reminder'] = ResolversParentTypes['Reminder']> = {
archiveUntil?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@ -4827,6 +4883,10 @@ export type Resolvers<ContextType = ResolverContext> = {
Query?: QueryResolvers<ContextType>;
Reaction?: ReactionResolvers<ContextType>;
ReadState?: ReadStateResolvers<ContextType>;
RecentSearch?: RecentSearchResolvers<ContextType>;
RecentSearchesError?: RecentSearchesErrorResolvers<ContextType>;
RecentSearchesResult?: RecentSearchesResultResolvers<ContextType>;
RecentSearchesSuccess?: RecentSearchesSuccessResolvers<ContextType>;
Reminder?: ReminderResolvers<ContextType>;
ReminderError?: ReminderErrorResolvers<ContextType>;
ReminderResult?: ReminderResultResolvers<ContextType>;

View file

@ -983,6 +983,7 @@ type Query {
labels: LabelsResult!
me: User
newsletterEmails: NewsletterEmailsResult!
recentSearches: RecentSearchesResult!
reminder(linkId: ID!): ReminderResult!
search(after: String, first: Int, query: String): SearchResult!
sendInstallInstructions: SendInstallInstructionsResult!
@ -1021,6 +1022,27 @@ type ReadState {
readingTime: Int
}
type RecentSearch {
createdAt: Date!
id: ID!
term: String!
}
type RecentSearchesError {
errorCodes: [RecentSearchesErrorCode!]!
}
enum RecentSearchesErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union RecentSearchesResult = RecentSearchesError | RecentSearchesSuccess
type RecentSearchesSuccess {
searches: [RecentSearch!]!
}
type Reminder {
archiveUntil: Boolean!
id: ID!

View file

@ -95,6 +95,7 @@ import {
updatePage,
} from '../../elastic/pages'
import { searchHighlights } from '../../elastic/highlights'
import { saveSearchHistory } from '../../services/search_history'
export type PartialArticle = Omit<
Article,
@ -905,6 +906,11 @@ export const searchResolver = authorized<
}
})
// save query, including advanced search terms, in search history
if (params.query) {
await saveSearchHistory(claims.uid, params.query)
}
return {
edges,
pageInfo: {

View file

@ -100,6 +100,7 @@ import {
generateUploadFilePathName,
} from '../utils/uploads'
import { getPageByParam } from '../elastic/pages'
import { recentSearchesResolver } from './recent_searches'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -196,6 +197,7 @@ export const functionResolvers = {
typeaheadSearch: typeaheadSearchResolver,
updatesSince: updatesSinceResolver,
integrations: integrationsResolver,
recentSearches: recentSearchesResolver,
},
User: {
async sharedArticles(
@ -604,4 +606,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('SetIntegration'),
...resultResolveTypeResolver('Integrations'),
...resultResolveTypeResolver('DeleteIntegration'),
...resultResolveTypeResolver('RecentSearches'),
}

View file

@ -0,0 +1,36 @@
import { authorized } from '../../utils/helpers'
import {
RecentSearchesError,
RecentSearchesErrorCode,
RecentSearchesSuccess,
} from '../../generated/graphql'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
import { getRecentSearches } from '../../services/search_history'
export const recentSearchesResolver = authorized<
RecentSearchesSuccess,
RecentSearchesError
>(async (_obj, _params, { claims: { uid }, log }) => {
log.info('recentSearches')
analytics.track({
userId: uid,
event: 'recentSearches',
properties: {
env: env.server.apiEnv,
},
})
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return { errorCodes: [RecentSearchesErrorCode.Unauthorized] }
}
const searches = await getRecentSearches(uid)
return {
searches,
}
})

View file

@ -1885,6 +1885,27 @@ const schema = gql`
NOT_FOUND
}
union RecentSearchesResult = RecentSearchesSuccess | RecentSearchesError
type RecentSearchesSuccess {
searches: [RecentSearch!]!
}
type RecentSearch {
id: ID!
term: String!
createdAt: Date!
}
type RecentSearchesError {
errorCodes: [RecentSearchesErrorCode!]!
}
enum RecentSearchesErrorCode {
UNAUTHORIZED
BAD_REQUEST
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2002,6 +2023,7 @@ const schema = gql`
typeaheadSearch(query: String!, first: Int): TypeaheadSearchResult!
updatesSince(after: String, first: Int, since: Date!): UpdatesSinceResult!
integrations: IntegrationsResult!
recentSearches: RecentSearchesResult!
}
`

View file

@ -0,0 +1,43 @@
import { SearchHistory } from '../entity/search_history'
import { getRepository } from '../entity/utils'
export const getRecentSearches = async (
userId: string
): Promise<SearchHistory[]> => {
// get top 10 recent searches
return getRepository(SearchHistory).find({
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
take: 10,
})
}
export const saveSearchHistory = async (
userId: string,
term: string
): Promise<void> => {
await getRepository(SearchHistory).upsert(
{
user: { id: userId },
term,
createdAt: new Date(),
},
{
conflictPaths: ['user', 'term'],
}
)
}
export const deleteSearchHistory = async (userId: string): Promise<void> => {
await getRepository(SearchHistory).delete({ user: { id: userId } })
}
export const deleteSearchHistoryById = async (
userId: string,
searchHistoryId: string
): Promise<void> => {
await getRepository(SearchHistory).delete({
user: { id: userId },
id: searchHistoryId,
})
}

View file

@ -34,6 +34,7 @@ import {
} from '../../src/elastic/pages'
import { addHighlightToPage } from '../../src/elastic/highlights'
import { refreshIndex } from '../../src/elastic'
import { SearchHistory } from '../../src/entity/search_history'
chai.use(chaiString)
@ -842,6 +843,33 @@ describe('Article API', () => {
after(async () => {
await deletePagesByParam({ userId: user.id }, ctx)
await getRepository(SearchHistory).delete({ user: { id: user.id } })
})
context('when we search for a keyword', () => {
before(() => {
keyword = 'search'
})
it('saves the term in search history', async () => {
await graphqlRequest(query, authToken).expect(200)
const searchHistories = await getRepository(SearchHistory).findBy({
user: { id: user.id },
})
expect(searchHistories.length).to.eq(1)
expect(searchHistories[0].term).to.eq(keyword)
const searchHistory = searchHistories[0]
// Check that the search history is updated
await graphqlRequest(query, authToken).expect(200)
const newSearchHistories = await getRepository(SearchHistory).findBy({
user: { id: user.id },
})
expect(newSearchHistories.length).to.eq(1)
expect(newSearchHistories[0].createdAt).to.be.greaterThan(
searchHistory.createdAt
)
})
})
context('when type:highlights is not in the query', () => {

View file

@ -0,0 +1,74 @@
import 'mocha'
import { expect } from 'chai'
import { User } from '../../src/entity/user'
import { PageContext } from '../../src/elastic/types'
import { createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import { getRepository } from '../../src/entity/utils'
import { SearchHistory } from '../../src/entity/search_history'
describe('recent_searches resolver', () => {
let user: User
let authToken: string
let ctx: PageContext
before(async () => {
// create fake user and login
user = await createTestUser('fakeUser')
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
authToken = res.body.authToken
ctx = {
pubsub: createPubSubClient(),
refresh: true,
uid: user.id,
}
})
after(async () => {
// clean up
await deleteTestUser(user.name)
})
describe('recentSearches API', () => {
const recentSearchesQuery = `
query {
recentSearches {
... on RecentSearchesSuccess {
searches {
term
}
}
}
}
`
before(async () => {
// create fake recent searches
await getRepository(SearchHistory).save([
{
user: { id: user.id },
term: 'test1',
},
{
user: { id: user.id },
term: 'test2',
},
])
})
after(async () => {
await getRepository(SearchHistory).delete({ user: { id: user.id } })
})
it('returns recent searches', async () => {
const response = await graphqlRequest(
recentSearchesQuery,
authToken
).expect(200)
expect(response.body.data.recentSearches.searches).to.be.lengthOf(2)
})
})
})

View file

@ -0,0 +1,17 @@
-- Type: DO
-- Name: search_history
-- Description: Create search_history table which contains searched keyword and timestamp
BEGIN;
CREATE TABLE omnivore.search_history (
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE,
term VARCHAR(255) NOT NULL,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
unique (user_id, term)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.search_history TO omnivore_user;
COMMIT;

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: search_history
-- Description: Create search_history table which contains searched keyword and timestamp
BEGIN;
DROP TABLE IF EXISTS omnivore.search_history;
COMMIT;