mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1294 from omnivore-app/feature/saved-search
Recent searches feature
This commit is contained in:
commit
624b7b4624
12 changed files with 347 additions and 0 deletions
27
packages/api/src/entity/search_history.ts
Normal file
27
packages/api/src/entity/search_history.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
36
packages/api/src/resolvers/recent_searches/index.ts
Normal file
36
packages/api/src/resolvers/recent_searches/index.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
43
packages/api/src/services/search_history.ts
Normal file
43
packages/api/src/services/search_history.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
74
packages/api/test/resolvers/recent_searches.test.ts
Normal file
74
packages/api/test/resolvers/recent_searches.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
17
packages/db/migrations/0097.do.search_history.sql
Executable file
17
packages/db/migrations/0097.do.search_history.sql
Executable 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;
|
||||
9
packages/db/migrations/0097.undo.search_history.sql
Executable file
9
packages/db/migrations/0097.undo.search_history.sql
Executable 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;
|
||||
Loading…
Reference in a new issue