diff --git a/packages/api/src/entity/feed_item.ts b/packages/api/src/entity/feed_item.ts new file mode 100644 index 000000000..541e6e194 --- /dev/null +++ b/packages/api/src/entity/feed_item.ts @@ -0,0 +1,53 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { Feed } from './feed' + +@Entity() +export class FeedItem { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('text') + guid!: string + + @Column('text') + title!: string + + @Column('array') + links!: string[] + + @Column('text') + author?: string | null + + @Column('text') + summary?: string | null + + @Column('array') + categories?: string[] | null + + @Column('text') + content?: string | null + + @Column('text') + previewContent?: string | null + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date + + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date + + @Column('timestamptz') + publishedAt?: Date | null + + @ManyToOne(() => Feed, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'feed_id' }) + feed!: Feed +} diff --git a/packages/api/src/entity/user_feed_item.ts b/packages/api/src/entity/user_feed_item.ts new file mode 100644 index 000000000..46ea8f8bc --- /dev/null +++ b/packages/api/src/entity/user_feed_item.ts @@ -0,0 +1,37 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { FeedItem } from './feed_item' +import { User } from './user' + +@Entity() +export class UserFeedItem { + @PrimaryGeneratedColumn('uuid') + id!: string + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date + + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt!: Date + + @Column('timestamptz') + hiddenAt?: Date | null + + @Column('timestamptz') + savedAt?: Date | null + + @ManyToOne(() => FeedItem, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'feed_item_id' }) + feedItem!: FeedItem + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 3d2524102..8dfbe5a08 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -794,7 +794,7 @@ export enum FeedsErrorCode { export type FeedsInput = { after?: InputMaybe; first?: InputMaybe; - query: Scalars['String']; + query?: InputMaybe; sort?: InputMaybe; }; @@ -837,6 +837,50 @@ export type FiltersSuccess = { filters: Array; }; +export type Following = { + __typename?: 'Following'; + author?: Maybe; + categories?: Maybe>; + content?: Maybe; + createdAt: Scalars['Date']; + feedItemId: Scalars['ID']; + hiddenAt?: Maybe; + id: Scalars['ID']; + isHidden: Scalars['Boolean']; + isSaved: Scalars['Boolean']; + links: Array; + previewContent?: Maybe; + publishedAt?: Maybe; + savedAt?: Maybe; + summary?: Maybe; + title: Scalars['String']; + updatedAt: Scalars['Date']; +}; + +export type FollowingEdge = { + __typename?: 'FollowingEdge'; + cursor: Scalars['String']; + node: Following; +}; + +export type FollowingError = { + __typename?: 'FollowingError'; + errorCodes: Array; +}; + +export enum FollowingErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type FollowingResult = FollowingError | FollowingSuccess; + +export type FollowingSuccess = { + __typename?: 'FollowingSuccess'; + edges: Array; + pageInfo: PageInfo; +}; + export type GenerateApiKeyError = { __typename?: 'GenerateApiKeyError'; errorCodes: Array; @@ -1771,6 +1815,7 @@ export type Query = { deviceTokens: DeviceTokensResult; feeds: FeedsResult; filters: FiltersResult; + following: FollowingResult; getUserPersonalization: GetUserPersonalizationResult; groups: GroupsResult; hello?: Maybe; @@ -1812,6 +1857,14 @@ export type QueryFeedsArgs = { }; +export type QueryFollowingArgs = { + after?: InputMaybe; + first?: InputMaybe; + since?: InputMaybe; + until?: InputMaybe; +}; + + export type QueryRulesArgs = { enabled?: InputMaybe; }; @@ -3528,6 +3581,12 @@ export type ResolversTypes = { FiltersResult: ResolversTypes['FiltersError'] | ResolversTypes['FiltersSuccess']; FiltersSuccess: ResolverTypeWrapper; Float: ResolverTypeWrapper; + Following: ResolverTypeWrapper; + FollowingEdge: ResolverTypeWrapper; + FollowingError: ResolverTypeWrapper; + FollowingErrorCode: FollowingErrorCode; + FollowingResult: ResolversTypes['FollowingError'] | ResolversTypes['FollowingSuccess']; + FollowingSuccess: ResolverTypeWrapper; GenerateApiKeyError: ResolverTypeWrapper; GenerateApiKeyErrorCode: GenerateApiKeyErrorCode; GenerateApiKeyInput: GenerateApiKeyInput; @@ -4012,6 +4071,11 @@ export type ResolversParentTypes = { FiltersResult: ResolversParentTypes['FiltersError'] | ResolversParentTypes['FiltersSuccess']; FiltersSuccess: FiltersSuccess; Float: Scalars['Float']; + Following: Following; + FollowingEdge: FollowingEdge; + FollowingError: FollowingError; + FollowingResult: ResolversParentTypes['FollowingError'] | ResolversParentTypes['FollowingSuccess']; + FollowingSuccess: FollowingSuccess; GenerateApiKeyError: GenerateApiKeyError; GenerateApiKeyInput: GenerateApiKeyInput; GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess']; @@ -4894,6 +4958,47 @@ export type FiltersSuccessResolvers; }; +export type FollowingResolvers = { + author?: Resolver, ParentType, ContextType>; + categories?: Resolver>, ParentType, ContextType>; + content?: Resolver, ParentType, ContextType>; + createdAt?: Resolver; + feedItemId?: Resolver; + hiddenAt?: Resolver, ParentType, ContextType>; + id?: Resolver; + isHidden?: Resolver; + isSaved?: Resolver; + links?: Resolver, ParentType, ContextType>; + previewContent?: Resolver, ParentType, ContextType>; + publishedAt?: Resolver, ParentType, ContextType>; + savedAt?: Resolver, ParentType, ContextType>; + summary?: Resolver, ParentType, ContextType>; + title?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type FollowingEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type FollowingErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type FollowingResultResolvers = { + __resolveType: TypeResolveFn<'FollowingError' | 'FollowingSuccess', ParentType, ContextType>; +}; + +export type FollowingSuccessResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type GenerateApiKeyErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5365,6 +5470,7 @@ export type QueryResolvers; feeds?: Resolver>; filters?: Resolver; + following?: Resolver>; getUserPersonalization?: Resolver; groups?: Resolver; hello?: Resolver, ParentType, ContextType>; @@ -6399,6 +6505,11 @@ export type Resolvers = { FiltersError?: FiltersErrorResolvers; FiltersResult?: FiltersResultResolvers; FiltersSuccess?: FiltersSuccessResolvers; + Following?: FollowingResolvers; + FollowingEdge?: FollowingEdgeResolvers; + FollowingError?: FollowingErrorResolvers; + FollowingResult?: FollowingResultResolvers; + FollowingSuccess?: FollowingSuccessResolvers; GenerateApiKeyError?: GenerateApiKeyErrorResolvers; GenerateApiKeyResult?: GenerateApiKeyResultResolvers; GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 719c59e02..b071c75df 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -703,7 +703,7 @@ enum FeedsErrorCode { input FeedsInput { after: String first: Int - query: String! + query: String sort: SortParams } @@ -742,6 +742,46 @@ type FiltersSuccess { filters: [Filter!]! } +type Following { + author: String + categories: [String!] + content: String + createdAt: Date! + feedItemId: ID! + hiddenAt: Date + id: ID! + isHidden: Boolean! + isSaved: Boolean! + links: [String!]! + previewContent: String + publishedAt: Date + savedAt: Date + summary: String + title: String! + updatedAt: Date! +} + +type FollowingEdge { + cursor: String! + node: Following! +} + +type FollowingError { + errorCodes: [FollowingErrorCode!]! +} + +enum FollowingErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union FollowingResult = FollowingError | FollowingSuccess + +type FollowingSuccess { + edges: [FollowingEdge!]! + pageInfo: PageInfo! +} + type GenerateApiKeyError { errorCodes: [GenerateApiKeyErrorCode!]! } @@ -1336,6 +1376,7 @@ type Query { deviceTokens: DeviceTokensResult! feeds(input: FeedsInput!): FeedsResult! filters: FiltersResult! + following(after: String, first: Int, since: Date, until: Date): FollowingResult! getUserPersonalization: GetUserPersonalizationResult! groups: GroupsResult! hello: String diff --git a/packages/api/src/repository/feed.ts b/packages/api/src/repository/feed.ts index 99d242de8..72f337d16 100644 --- a/packages/api/src/repository/feed.ts +++ b/packages/api/src/repository/feed.ts @@ -4,14 +4,18 @@ import { Feed } from '../entity/feed' export const feedRepository = appDataSource.getRepository(Feed).extend({ async searchFeeds( - query: string, + query = '', take = 10, skip = 0, - orderBy = 'createdAt', - order = 'DESC' + orderBy = 'title', + order = 'ASC' ) { - query = `%${query}%` - const where = [{ title: ILike(query) }, { url: ILike(query) }] + const where = [] + if (query !== '') { + query = `%${query}%` + where.push({ title: ILike(query) }, { url: ILike(query) }) + } + const feeds = await this.find({ where, order: { [orderBy]: order }, diff --git a/packages/api/src/repository/user_feed_item.ts b/packages/api/src/repository/user_feed_item.ts new file mode 100644 index 000000000..dcbf488b8 --- /dev/null +++ b/packages/api/src/repository/user_feed_item.ts @@ -0,0 +1,36 @@ +import { Between, FindOptionsWhere } from 'typeorm' +import { appDataSource } from '../data_source' +import { UserFeedItem } from '../entity/user_feed_item' + +export const userFeedItemRepository = appDataSource + .getRepository(UserFeedItem) + .extend({ + async searchUserFeedItems( + take = 10, + skip = 0, + since = new Date(0), + until = new Date() + ) { + const where: FindOptionsWhere = { + updatedAt: Between(since, until), + } + + const userFeedItems = await this.find({ + where, + order: { updatedAt: 'DESC' }, + take, + skip, + relations: { + user: true, + feedItem: true, + }, + }) + + const count = await this.countBy(where) + + return { + userFeedItems, + count, + } + }, + }) diff --git a/packages/api/src/resolvers/feeds/index.ts b/packages/api/src/resolvers/feeds/index.ts index f3ef53b51..20c503464 100644 --- a/packages/api/src/resolvers/feeds/index.ts +++ b/packages/api/src/resolvers/feeds/index.ts @@ -3,9 +3,15 @@ import { FeedsError, FeedsErrorCode, FeedsSuccess, + FollowingEdge, + FollowingError, + FollowingErrorCode, + FollowingSuccess, QueryFeedsArgs, + QueryFollowingArgs, } from '../../generated/graphql' import { feedRepository } from '../../repository/feed' +import { userFeedItemRepository } from '../../repository/user_feed_item' import { authorized } from '../../utils/helpers' export const feedsResolve = authorized< @@ -17,10 +23,10 @@ export const feedsResolve = authorized< const startCursor = input.after || '' const start = startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 - const first = input.first || 10 + const first = Math.min(input.first || 10, 100) // cap at 100 const { feeds, count } = await feedRepository.searchFeeds( - input.query, + input.query || '', first + 1, // fetch one extra to check if there is a next page start, input.sort?.by, @@ -59,3 +65,66 @@ export const feedsResolve = authorized< } } }) + +export const followingResolver = authorized< + FollowingSuccess, + FollowingError, + QueryFollowingArgs +>(async (_, args, { authTrx, log }) => { + try { + const startCursor = args.after || '' + const start = + startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0 + const first = Math.min(args.first || 10, 100) // cap at 100 + const since = args.since ? new Date(args.since) : undefined + const until = args.until ? new Date(args.until) : undefined + + const { userFeedItems, count } = await authTrx((tx) => + tx.withRepository(userFeedItemRepository).searchUserFeedItems( + first + 1, // fetch one extra to check if there is a next page + start, + since, + until + ) + ) + + const hasNextPage = userFeedItems.length > first + const endCursor = String( + start + userFeedItems.length - (hasNextPage ? 1 : 0) + ) + + if (hasNextPage) { + // remove an extra if exists + userFeedItems.pop() + } + + const edges: FollowingEdge[] = userFeedItems.map((item) => ({ + node: { + ...item.feedItem, + ...item, + isHidden: !!item.hiddenAt, + isSaved: !!item.savedAt, + feedItemId: item.feedItem.id, + }, + cursor: endCursor, + })) + + return { + __typename: 'FollowingSuccess', + edges, + pageInfo: { + hasPreviousPage: start > 0, + hasNextPage, + startCursor, + endCursor, + totalCount: count, + }, + } + } catch (error) { + log.error('Error fetching following', error) + + return { + errorCodes: [FollowingErrorCode.Unauthorized], + } + } +}) diff --git a/packages/api/src/routers/feed_router.ts b/packages/api/src/routers/feed_router.ts new file mode 100644 index 000000000..9480a29da --- /dev/null +++ b/packages/api/src/routers/feed_router.ts @@ -0,0 +1,7 @@ +import express from "express"; + +export function feedRouter() { + const router = express.Router() + + router.post('/') +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index bd1b2df8f..fd961d197 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2607,8 +2607,8 @@ const schema = gql` input FeedsInput { after: String - first: Int @sanitize(minLength: 1, maxLength: 100) - query: String! @sanitize(maxLength: 255) + first: Int + query: String @sanitize(maxLength: 255) sort: SortParams } @@ -2645,6 +2645,46 @@ const schema = gql` author: String } + union FollowingResult = FollowingSuccess | FollowingError + + type FollowingSuccess { + edges: [FollowingEdge!]! + pageInfo: PageInfo! + } + + type FollowingEdge { + cursor: String! + node: Following! + } + + type FollowingError { + errorCodes: [FollowingErrorCode!]! + } + + enum FollowingErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + type Following { + id: ID! + feedItemId: ID! + title: String! + links: [String!]! + author: String + summary: String + categories: [String!] + content: String + previewContent: String + createdAt: Date! + updatedAt: Date! + savedAt: Date + hiddenAt: Date + publishedAt: Date + isHidden: Boolean! + isSaved: Boolean! + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2807,6 +2847,12 @@ const schema = gql` groups: GroupsResult! recentEmails: RecentEmailsResult! feeds(input: FeedsInput!): FeedsResult! + following( + after: String + first: Int + since: Date + until: Date + ): FollowingResult! } ` diff --git a/packages/api/src/services/feeds.ts b/packages/api/src/services/feeds.ts index e69de29bb..725d59139 100644 --- a/packages/api/src/services/feeds.ts +++ b/packages/api/src/services/feeds.ts @@ -0,0 +1,9 @@ +export const searchFollowing = async ( + userId: string, + first = 10, + after = '', + since?: Date, + until?: Date +) => { + +}