add following graphql api to fetch user following

This commit is contained in:
Hongbo Wu 2023-10-31 20:34:50 +08:00
parent fc783f01ec
commit da0d1705b5
10 changed files with 424 additions and 11 deletions

View file

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

View file

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

View file

@ -794,7 +794,7 @@ export enum FeedsErrorCode {
export type FeedsInput = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
query: Scalars['String'];
query?: InputMaybe<Scalars['String']>;
sort?: InputMaybe<SortParams>;
};
@ -837,6 +837,50 @@ export type FiltersSuccess = {
filters: Array<Filter>;
};
export type Following = {
__typename?: 'Following';
author?: Maybe<Scalars['String']>;
categories?: Maybe<Array<Scalars['String']>>;
content?: Maybe<Scalars['String']>;
createdAt: Scalars['Date'];
feedItemId: Scalars['ID'];
hiddenAt?: Maybe<Scalars['Date']>;
id: Scalars['ID'];
isHidden: Scalars['Boolean'];
isSaved: Scalars['Boolean'];
links: Array<Scalars['String']>;
previewContent?: Maybe<Scalars['String']>;
publishedAt?: Maybe<Scalars['Date']>;
savedAt?: Maybe<Scalars['Date']>;
summary?: Maybe<Scalars['String']>;
title: Scalars['String'];
updatedAt: Scalars['Date'];
};
export type FollowingEdge = {
__typename?: 'FollowingEdge';
cursor: Scalars['String'];
node: Following;
};
export type FollowingError = {
__typename?: 'FollowingError';
errorCodes: Array<FollowingErrorCode>;
};
export enum FollowingErrorCode {
BadRequest = 'BAD_REQUEST',
Unauthorized = 'UNAUTHORIZED'
}
export type FollowingResult = FollowingError | FollowingSuccess;
export type FollowingSuccess = {
__typename?: 'FollowingSuccess';
edges: Array<FollowingEdge>;
pageInfo: PageInfo;
};
export type GenerateApiKeyError = {
__typename?: 'GenerateApiKeyError';
errorCodes: Array<GenerateApiKeyErrorCode>;
@ -1771,6 +1815,7 @@ export type Query = {
deviceTokens: DeviceTokensResult;
feeds: FeedsResult;
filters: FiltersResult;
following: FollowingResult;
getUserPersonalization: GetUserPersonalizationResult;
groups: GroupsResult;
hello?: Maybe<Scalars['String']>;
@ -1812,6 +1857,14 @@ export type QueryFeedsArgs = {
};
export type QueryFollowingArgs = {
after?: InputMaybe<Scalars['String']>;
first?: InputMaybe<Scalars['Int']>;
since?: InputMaybe<Scalars['Date']>;
until?: InputMaybe<Scalars['Date']>;
};
export type QueryRulesArgs = {
enabled?: InputMaybe<Scalars['Boolean']>;
};
@ -3528,6 +3581,12 @@ export type ResolversTypes = {
FiltersResult: ResolversTypes['FiltersError'] | ResolversTypes['FiltersSuccess'];
FiltersSuccess: ResolverTypeWrapper<FiltersSuccess>;
Float: ResolverTypeWrapper<Scalars['Float']>;
Following: ResolverTypeWrapper<Following>;
FollowingEdge: ResolverTypeWrapper<FollowingEdge>;
FollowingError: ResolverTypeWrapper<FollowingError>;
FollowingErrorCode: FollowingErrorCode;
FollowingResult: ResolversTypes['FollowingError'] | ResolversTypes['FollowingSuccess'];
FollowingSuccess: ResolverTypeWrapper<FollowingSuccess>;
GenerateApiKeyError: ResolverTypeWrapper<GenerateApiKeyError>;
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<ContextType = ResolverContext, ParentType ex
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FollowingResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Following'] = ResolversParentTypes['Following']> = {
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
categories?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
content?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
feedItemId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
hiddenAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isHidden?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
isSaved?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
links?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
previewContent?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
publishedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
savedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
summary?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FollowingEdgeResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FollowingEdge'] = ResolversParentTypes['FollowingEdge']> = {
cursor?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
node?: Resolver<ResolversTypes['Following'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FollowingErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FollowingError'] = ResolversParentTypes['FollowingError']> = {
errorCodes?: Resolver<Array<ResolversTypes['FollowingErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FollowingResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FollowingResult'] = ResolversParentTypes['FollowingResult']> = {
__resolveType: TypeResolveFn<'FollowingError' | 'FollowingSuccess', ParentType, ContextType>;
};
export type FollowingSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FollowingSuccess'] = ResolversParentTypes['FollowingSuccess']> = {
edges?: Resolver<Array<ResolversTypes['FollowingEdge']>, ParentType, ContextType>;
pageInfo?: Resolver<ResolversTypes['PageInfo'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type GenerateApiKeyErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeyError'] = ResolversParentTypes['GenerateApiKeyError']> = {
errorCodes?: Resolver<Array<ResolversTypes['GenerateApiKeyErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5365,6 +5470,7 @@ export type QueryResolvers<ContextType = ResolverContext, ParentType extends Res
deviceTokens?: Resolver<ResolversTypes['DeviceTokensResult'], ParentType, ContextType>;
feeds?: Resolver<ResolversTypes['FeedsResult'], ParentType, ContextType, RequireFields<QueryFeedsArgs, 'input'>>;
filters?: Resolver<ResolversTypes['FiltersResult'], ParentType, ContextType>;
following?: Resolver<ResolversTypes['FollowingResult'], ParentType, ContextType, Partial<QueryFollowingArgs>>;
getUserPersonalization?: Resolver<ResolversTypes['GetUserPersonalizationResult'], ParentType, ContextType>;
groups?: Resolver<ResolversTypes['GroupsResult'], ParentType, ContextType>;
hello?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6399,6 +6505,11 @@ export type Resolvers<ContextType = ResolverContext> = {
FiltersError?: FiltersErrorResolvers<ContextType>;
FiltersResult?: FiltersResultResolvers<ContextType>;
FiltersSuccess?: FiltersSuccessResolvers<ContextType>;
Following?: FollowingResolvers<ContextType>;
FollowingEdge?: FollowingEdgeResolvers<ContextType>;
FollowingError?: FollowingErrorResolvers<ContextType>;
FollowingResult?: FollowingResultResolvers<ContextType>;
FollowingSuccess?: FollowingSuccessResolvers<ContextType>;
GenerateApiKeyError?: GenerateApiKeyErrorResolvers<ContextType>;
GenerateApiKeyResult?: GenerateApiKeyResultResolvers<ContextType>;
GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers<ContextType>;

View file

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

View file

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

View file

@ -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<UserFeedItem> = {
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,
}
},
})

View file

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

View file

@ -0,0 +1,7 @@
import express from "express";
export function feedRouter() {
const router = express.Router()
router.post('/')
}

View file

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

View file

@ -0,0 +1,9 @@
export const searchFollowing = async (
userId: string,
first = 10,
after = '',
since?: Date,
until?: Date
) => {
}