From c02f2d5036a65cde3bfee5bfdcdd6cf89eee3acb Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 15 Jun 2024 20:17:45 +0800 Subject: [PATCH 01/14] create post table and entity --- packages/api/src/entity/post.ts | 45 +++++++++++++++++++++++ packages/db/migrations/0183.do.post.sql | 24 ++++++++++++ packages/db/migrations/0183.undo.post.sql | 11 ++++++ 3 files changed, 80 insertions(+) create mode 100644 packages/api/src/entity/post.ts create mode 100755 packages/db/migrations/0183.do.post.sql create mode 100755 packages/db/migrations/0183.undo.post.sql diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts new file mode 100644 index 000000000..2451c7817 --- /dev/null +++ b/packages/api/src/entity/post.ts @@ -0,0 +1,45 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { User } from './user' + +@Entity({ name: 'post' }) +export class Post { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('uuid') + userId!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('uuid', { array: true }) + libraryItemIds!: string[] + + @Column('uuid', { array: true }) + highlightIds!: string[] + + @Column('text') + title!: string + + @Column('text') + content!: string + + @Column('text', { nullable: true }) + thumbnail?: string + + @Column('text', { nullable: true }) + thought?: string + + @Column('timestamptz') + createdAt!: Date + + @Column('timestamptz') + updatedAt!: Date +} diff --git a/packages/db/migrations/0183.do.post.sql b/packages/db/migrations/0183.do.post.sql new file mode 100755 index 000000000..26f1d5e31 --- /dev/null +++ b/packages/db/migrations/0183.do.post.sql @@ -0,0 +1,24 @@ +-- Type: DO +-- Name: post +-- Description: Create a post table + +BEGIN; + +CREATE TABLE omnivore.post ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id UUID NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE, + library_item_ids UUID[], + highlight_ids UUID[], + title TEXT NOT NULL, + content TEXT NOT NULL, -- generated from template + thumbnail TEXT, + thought TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX post_user_id_idx ON omnivore.post(user_id); + +ALTER TABLE omnivore.user_profile ALTER COLUMN private SET DEFAULT true; + +COMMIT; diff --git a/packages/db/migrations/0183.undo.post.sql b/packages/db/migrations/0183.undo.post.sql new file mode 100755 index 000000000..aa74c9420 --- /dev/null +++ b/packages/db/migrations/0183.undo.post.sql @@ -0,0 +1,11 @@ +-- Type: UNDO +-- Name: post +-- Description: Create a post table + +BEGIN; + +ALTER TABLE omnivore.user_profile ALTER COLUMN private SET DEFAULT false; + +DROP TABLE omnivore.post; + +COMMIT; From 3fb146e864088aa671c002315cb2f0423ba602c5 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 15 Jun 2024 22:07:32 +0800 Subject: [PATCH 02/14] list posts api --- packages/api/src/entity/post.ts | 8 +- packages/api/src/generated/graphql.ts | 315 ++++++++++++++++++ packages/api/src/generated/schema.graphql | 119 +++++++ .../api/src/resolvers/function_resolvers.ts | 20 ++ packages/api/src/resolvers/posts/index.ts | 63 ++++ packages/api/src/schema.ts | 148 ++++++-- packages/api/src/services/post.ts | 26 ++ packages/db/migrations/0183.do.post.sql | 20 +- packages/db/migrations/0183.undo.post.sql | 2 - 9 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 packages/api/src/resolvers/posts/index.ts create mode 100644 packages/api/src/services/post.ts diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts index 2451c7817..d689d9202 100644 --- a/packages/api/src/entity/post.ts +++ b/packages/api/src/entity/post.ts @@ -19,11 +19,11 @@ export class Post { @JoinColumn({ name: 'user_id' }) user!: User - @Column('uuid', { array: true }) - libraryItemIds!: string[] + @Column('uuid', { array: true, nullable: true }) + libraryItemIds?: string[] - @Column('uuid', { array: true }) - highlightIds!: string[] + @Column('uuid', { array: true, nullable: true }) + highlightIds?: string[] @Column('text') title!: string diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index a57b12e5b..9b6bfbb19 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -509,6 +509,32 @@ export type CreateNewsletterEmailSuccess = { newsletterEmail: NewsletterEmail; }; +export type CreatePostError = { + __typename?: 'CreatePostError'; + errorCodes: Array; +}; + +export enum CreatePostErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type CreatePostInput = { + content: Scalars['String']; + highlightIds?: InputMaybe>; + libraryItemIds?: InputMaybe>; + thought?: InputMaybe; + thumbnail?: InputMaybe; + title: Scalars['String']; +}; + +export type CreatePostResult = CreatePostError | CreatePostSuccess; + +export type CreatePostSuccess = { + __typename?: 'CreatePostSuccess'; + post: Post; +}; + export type CreateReactionError = { __typename?: 'CreateReactionError'; errorCodes: Array; @@ -749,6 +775,23 @@ export type DeleteNewsletterEmailSuccess = { newsletterEmail: NewsletterEmail; }; +export type DeletePostError = { + __typename?: 'DeletePostError'; + errorCodes: Array; +}; + +export enum DeletePostErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type DeletePostResult = DeletePostError | DeletePostSuccess; + +export type DeletePostSuccess = { + __typename?: 'DeletePostSuccess'; + success: Scalars['Boolean']; +}; + export type DeleteReactionError = { __typename?: 'DeleteReactionError'; errorCodes: Array; @@ -1800,6 +1843,7 @@ export type Mutation = { createHighlight: CreateHighlightResult; createLabel: CreateLabelResult; createNewsletterEmail: CreateNewsletterEmailResult; + createPost: CreatePostResult; deleteAccount: DeleteAccountResult; deleteDiscoverArticle: DeleteDiscoverArticleResult; deleteDiscoverFeed: DeleteDiscoverFeedResult; @@ -1809,6 +1853,7 @@ export type Mutation = { deleteIntegration: DeleteIntegrationResult; deleteLabel: DeleteLabelResult; deleteNewsletterEmail: DeleteNewsletterEmailResult; + deletePost: DeletePostResult; deleteRule: DeleteRuleResult; deleteWebhook: DeleteWebhookResult; editDiscoverFeed: EditDiscoverFeedResult; @@ -1859,6 +1904,7 @@ export type Mutation = { updateLabel: UpdateLabelResult; updateNewsletterEmail: UpdateNewsletterEmailResult; updatePage: UpdatePageResult; + updatePost: UpdatePostResult; updateSubscription: UpdateSubscriptionResult; updateUser: UpdateUserResult; updateUserProfile: UpdateUserProfileResult; @@ -1922,6 +1968,11 @@ export type MutationCreateNewsletterEmailArgs = { }; +export type MutationCreatePostArgs = { + input: CreatePostInput; +}; + + export type MutationDeleteAccountArgs = { userID: Scalars['ID']; }; @@ -1967,6 +2018,11 @@ export type MutationDeleteNewsletterEmailArgs = { }; +export type MutationDeletePostArgs = { + id: Scalars['ID']; +}; + + export type MutationDeleteRuleArgs = { id: Scalars['ID']; }; @@ -2205,6 +2261,11 @@ export type MutationUpdatePageArgs = { }; +export type MutationUpdatePostArgs = { + input: UpdatePostInput; +}; + + export type MutationUpdateSubscriptionArgs = { input: UpdateSubscriptionInput; }; @@ -2346,6 +2407,62 @@ export type ParseResult = { title: Scalars['String']; }; +export type Post = { + __typename?: 'Post'; + author: User; + content: Scalars['String']; + createdAt: Scalars['Date']; + highlights?: Maybe>; + id: Scalars['ID']; + libraryItems?: Maybe>; + ownedByViewer: Scalars['Boolean']; + thought?: Maybe; + thumbnail?: Maybe; + title: Scalars['String']; + updatedAt: Scalars['Date']; +}; + +export type PostEdge = { + __typename?: 'PostEdge'; + cursor: Scalars['String']; + node: Post; +}; + +export type PostError = { + __typename?: 'PostError'; + errorCodes: Array; +}; + +export enum PostErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type PostResult = PostError | PostSuccess; + +export type PostSuccess = { + __typename?: 'PostSuccess'; + post: Post; +}; + +export type PostsError = { + __typename?: 'PostsError'; + errorCodes: Array; +}; + +export enum PostsErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type PostsResult = PostsError | PostsSuccess; + +export type PostsSuccess = { + __typename?: 'PostsSuccess'; + edges: Array; + pageInfo: PageInfo; +}; + export type PreparedDocumentInput = { document: Scalars['String']; pageInfo: PageInfoInput; @@ -2383,6 +2500,8 @@ export type Query = { labels: LabelsResult; me?: Maybe; newsletterEmails: NewsletterEmailsResult; + post: PostResult; + posts: PostsResult; recentEmails: RecentEmailsResult; recentSearches: RecentSearchesResult; rules: RulesResult; @@ -2445,6 +2564,18 @@ export type QueryIntegrationArgs = { }; +export type QueryPostArgs = { + id: Scalars['ID']; +}; + + +export type QueryPostsArgs = { + after?: InputMaybe; + first?: InputMaybe; + userId: Scalars['ID']; +}; + + export type QueryRulesArgs = { enabled?: InputMaybe; }; @@ -3824,6 +3955,33 @@ export type UpdatePageSuccess = { updatedPage: Article; }; +export type UpdatePostError = { + __typename?: 'UpdatePostError'; + errorCodes: Array; +}; + +export enum UpdatePostErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type UpdatePostInput = { + content?: InputMaybe; + highlightIds?: InputMaybe>; + id: Scalars['ID']; + libraryItemIds?: InputMaybe>; + thought?: InputMaybe; + thumbnail?: InputMaybe; + title?: InputMaybe; +}; + +export type UpdatePostResult = UpdatePostError | UpdatePostSuccess; + +export type UpdatePostSuccess = { + __typename?: 'UpdatePostSuccess'; + post: Post; +}; + export enum UpdateReason { Created = 'CREATED', Deleted = 'DELETED', @@ -4324,6 +4482,11 @@ export type ResolversTypes = { CreateNewsletterEmailInput: CreateNewsletterEmailInput; CreateNewsletterEmailResult: ResolversTypes['CreateNewsletterEmailError'] | ResolversTypes['CreateNewsletterEmailSuccess']; CreateNewsletterEmailSuccess: ResolverTypeWrapper; + CreatePostError: ResolverTypeWrapper; + CreatePostErrorCode: CreatePostErrorCode; + CreatePostInput: CreatePostInput; + CreatePostResult: ResolversTypes['CreatePostError'] | ResolversTypes['CreatePostSuccess']; + CreatePostSuccess: ResolverTypeWrapper; CreateReactionError: ResolverTypeWrapper; CreateReactionErrorCode: CreateReactionErrorCode; CreateReactionInput: CreateReactionInput; @@ -4377,6 +4540,10 @@ export type ResolversTypes = { DeleteNewsletterEmailErrorCode: DeleteNewsletterEmailErrorCode; DeleteNewsletterEmailResult: ResolversTypes['DeleteNewsletterEmailError'] | ResolversTypes['DeleteNewsletterEmailSuccess']; DeleteNewsletterEmailSuccess: ResolverTypeWrapper; + DeletePostError: ResolverTypeWrapper; + DeletePostErrorCode: DeletePostErrorCode; + DeletePostResult: ResolversTypes['DeletePostError'] | ResolversTypes['DeletePostSuccess']; + DeletePostSuccess: ResolverTypeWrapper; DeleteReactionError: ResolverTypeWrapper; DeleteReactionErrorCode: DeleteReactionErrorCode; DeleteReactionResult: ResolversTypes['DeleteReactionError'] | ResolversTypes['DeleteReactionSuccess']; @@ -4589,6 +4756,16 @@ export type ResolversTypes = { PageInfoInput: PageInfoInput; PageType: PageType; ParseResult: ParseResult; + Post: ResolverTypeWrapper; + PostEdge: ResolverTypeWrapper; + PostError: ResolverTypeWrapper; + PostErrorCode: PostErrorCode; + PostResult: ResolversTypes['PostError'] | ResolversTypes['PostSuccess']; + PostSuccess: ResolverTypeWrapper; + PostsError: ResolverTypeWrapper; + PostsErrorCode: PostsErrorCode; + PostsResult: ResolversTypes['PostsError'] | ResolversTypes['PostsSuccess']; + PostsSuccess: ResolverTypeWrapper; PreparedDocumentInput: PreparedDocumentInput; Profile: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; @@ -4823,6 +5000,11 @@ export type ResolversTypes = { UpdatePageInput: UpdatePageInput; UpdatePageResult: ResolversTypes['UpdatePageError'] | ResolversTypes['UpdatePageSuccess']; UpdatePageSuccess: ResolverTypeWrapper; + UpdatePostError: ResolverTypeWrapper; + UpdatePostErrorCode: UpdatePostErrorCode; + UpdatePostInput: UpdatePostInput; + UpdatePostResult: ResolversTypes['UpdatePostError'] | ResolversTypes['UpdatePostSuccess']; + UpdatePostSuccess: ResolverTypeWrapper; UpdateReason: UpdateReason; UpdateReminderError: ResolverTypeWrapper; UpdateReminderErrorCode: UpdateReminderErrorCode; @@ -4952,6 +5134,10 @@ export type ResolversParentTypes = { CreateNewsletterEmailInput: CreateNewsletterEmailInput; CreateNewsletterEmailResult: ResolversParentTypes['CreateNewsletterEmailError'] | ResolversParentTypes['CreateNewsletterEmailSuccess']; CreateNewsletterEmailSuccess: CreateNewsletterEmailSuccess; + CreatePostError: CreatePostError; + CreatePostInput: CreatePostInput; + CreatePostResult: ResolversParentTypes['CreatePostError'] | ResolversParentTypes['CreatePostSuccess']; + CreatePostSuccess: CreatePostSuccess; CreateReactionError: CreateReactionError; CreateReactionInput: CreateReactionInput; CreateReactionResult: ResolversParentTypes['CreateReactionError'] | ResolversParentTypes['CreateReactionSuccess']; @@ -4993,6 +5179,9 @@ export type ResolversParentTypes = { DeleteNewsletterEmailError: DeleteNewsletterEmailError; DeleteNewsletterEmailResult: ResolversParentTypes['DeleteNewsletterEmailError'] | ResolversParentTypes['DeleteNewsletterEmailSuccess']; DeleteNewsletterEmailSuccess: DeleteNewsletterEmailSuccess; + DeletePostError: DeletePostError; + DeletePostResult: ResolversParentTypes['DeletePostError'] | ResolversParentTypes['DeletePostSuccess']; + DeletePostSuccess: DeletePostSuccess; DeleteReactionError: DeleteReactionError; DeleteReactionResult: ResolversParentTypes['DeleteReactionError'] | ResolversParentTypes['DeleteReactionSuccess']; DeleteReactionSuccess: DeleteReactionSuccess; @@ -5157,6 +5346,14 @@ export type ResolversParentTypes = { PageInfo: PageInfo; PageInfoInput: PageInfoInput; ParseResult: ParseResult; + Post: Post; + PostEdge: PostEdge; + PostError: PostError; + PostResult: ResolversParentTypes['PostError'] | ResolversParentTypes['PostSuccess']; + PostSuccess: PostSuccess; + PostsError: PostsError; + PostsResult: ResolversParentTypes['PostsError'] | ResolversParentTypes['PostsSuccess']; + PostsSuccess: PostsSuccess; PreparedDocumentInput: PreparedDocumentInput; Profile: Profile; Query: {}; @@ -5339,6 +5536,10 @@ export type ResolversParentTypes = { UpdatePageInput: UpdatePageInput; UpdatePageResult: ResolversParentTypes['UpdatePageError'] | ResolversParentTypes['UpdatePageSuccess']; UpdatePageSuccess: UpdatePageSuccess; + UpdatePostError: UpdatePostError; + UpdatePostInput: UpdatePostInput; + UpdatePostResult: ResolversParentTypes['UpdatePostError'] | ResolversParentTypes['UpdatePostSuccess']; + UpdatePostSuccess: UpdatePostSuccess; UpdateReminderError: UpdateReminderError; UpdateReminderInput: UpdateReminderInput; UpdateReminderResult: ResolversParentTypes['UpdateReminderError'] | ResolversParentTypes['UpdateReminderSuccess']; @@ -5702,6 +5903,20 @@ export type CreateNewsletterEmailSuccessResolvers; }; +export type CreatePostErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type CreatePostResultResolvers = { + __resolveType: TypeResolveFn<'CreatePostError' | 'CreatePostSuccess', ParentType, ContextType>; +}; + +export type CreatePostSuccessResolvers = { + post?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateReactionErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5874,6 +6089,20 @@ export type DeleteNewsletterEmailSuccessResolvers; }; +export type DeletePostErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type DeletePostResultResolvers = { + __resolveType: TypeResolveFn<'DeletePostError' | 'DeletePostSuccess', ParentType, ContextType>; +}; + +export type DeletePostSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeleteReactionErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6693,6 +6922,7 @@ export type MutationResolvers>; createLabel?: Resolver>; createNewsletterEmail?: Resolver>; + createPost?: Resolver>; deleteAccount?: Resolver>; deleteDiscoverArticle?: Resolver>; deleteDiscoverFeed?: Resolver>; @@ -6702,6 +6932,7 @@ export type MutationResolvers>; deleteLabel?: Resolver>; deleteNewsletterEmail?: Resolver>; + deletePost?: Resolver>; deleteRule?: Resolver>; deleteWebhook?: Resolver>; editDiscoverFeed?: Resolver>; @@ -6752,6 +6983,7 @@ export type MutationResolvers>; updateNewsletterEmail?: Resolver>; updatePage?: Resolver>; + updatePost?: Resolver>; updateSubscription?: Resolver>; updateUser?: Resolver>; updateUserProfile?: Resolver>; @@ -6826,6 +7058,56 @@ export type PageInfoResolvers; }; +export type PostResolvers = { + author?: Resolver; + content?: Resolver; + createdAt?: Resolver; + highlights?: Resolver>, ParentType, ContextType>; + id?: Resolver; + libraryItems?: Resolver>, ParentType, ContextType>; + ownedByViewer?: Resolver; + thought?: Resolver, ParentType, ContextType>; + thumbnail?: Resolver, ParentType, ContextType>; + title?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type PostEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type PostErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type PostResultResolvers = { + __resolveType: TypeResolveFn<'PostError' | 'PostSuccess', ParentType, ContextType>; +}; + +export type PostSuccessResolvers = { + post?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type PostsErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type PostsResultResolvers = { + __resolveType: TypeResolveFn<'PostsError' | 'PostsSuccess', ParentType, ContextType>; +}; + +export type PostsSuccessResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ProfileResolvers = { bio?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -6857,6 +7139,8 @@ export type QueryResolvers; me?: Resolver, ParentType, ContextType>; newsletterEmails?: Resolver; + post?: Resolver>; + posts?: Resolver>; recentEmails?: Resolver; recentSearches?: Resolver; rules?: Resolver>; @@ -7695,6 +7979,20 @@ export type UpdatePageSuccessResolvers; }; +export type UpdatePostErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdatePostResultResolvers = { + __resolveType: TypeResolveFn<'UpdatePostError' | 'UpdatePostSuccess', ParentType, ContextType>; +}; + +export type UpdatePostSuccessResolvers = { + post?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateReminderErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -7972,6 +8270,9 @@ export type Resolvers = { CreateNewsletterEmailError?: CreateNewsletterEmailErrorResolvers; CreateNewsletterEmailResult?: CreateNewsletterEmailResultResolvers; CreateNewsletterEmailSuccess?: CreateNewsletterEmailSuccessResolvers; + CreatePostError?: CreatePostErrorResolvers; + CreatePostResult?: CreatePostResultResolvers; + CreatePostSuccess?: CreatePostSuccessResolvers; CreateReactionError?: CreateReactionErrorResolvers; CreateReactionResult?: CreateReactionResultResolvers; CreateReactionSuccess?: CreateReactionSuccessResolvers; @@ -8009,6 +8310,9 @@ export type Resolvers = { DeleteNewsletterEmailError?: DeleteNewsletterEmailErrorResolvers; DeleteNewsletterEmailResult?: DeleteNewsletterEmailResultResolvers; DeleteNewsletterEmailSuccess?: DeleteNewsletterEmailSuccessResolvers; + DeletePostError?: DeletePostErrorResolvers; + DeletePostResult?: DeletePostResultResolvers; + DeletePostSuccess?: DeletePostSuccessResolvers; DeleteReactionError?: DeleteReactionErrorResolvers; DeleteReactionResult?: DeleteReactionResultResolvers; DeleteReactionSuccess?: DeleteReactionSuccessResolvers; @@ -8158,6 +8462,14 @@ export type Resolvers = { OptInFeatureSuccess?: OptInFeatureSuccessResolvers; Page?: PageResolvers; PageInfo?: PageInfoResolvers; + Post?: PostResolvers; + PostEdge?: PostEdgeResolvers; + PostError?: PostErrorResolvers; + PostResult?: PostResultResolvers; + PostSuccess?: PostSuccessResolvers; + PostsError?: PostsErrorResolvers; + PostsResult?: PostsResultResolvers; + PostsSuccess?: PostsSuccessResolvers; Profile?: ProfileResolvers; Query?: QueryResolvers; Reaction?: ReactionResolvers; @@ -8305,6 +8617,9 @@ export type Resolvers = { UpdatePageError?: UpdatePageErrorResolvers; UpdatePageResult?: UpdatePageResultResolvers; UpdatePageSuccess?: UpdatePageSuccessResolvers; + UpdatePostError?: UpdatePostErrorResolvers; + UpdatePostResult?: UpdatePostResultResolvers; + UpdatePostSuccess?: UpdatePostSuccessResolvers; UpdateReminderError?: UpdateReminderErrorResolvers; UpdateReminderResult?: UpdateReminderResultResolvers; UpdateReminderSuccess?: UpdateReminderSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index d7e61f20f..c9346054f 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -455,6 +455,30 @@ type CreateNewsletterEmailSuccess { newsletterEmail: NewsletterEmail! } +type CreatePostError { + errorCodes: [CreatePostErrorCode!]! +} + +enum CreatePostErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +input CreatePostInput { + content: String! + highlightIds: [ID!] + libraryItemIds: [ID!] + thought: String + thumbnail: String + title: String! +} + +union CreatePostResult = CreatePostError | CreatePostSuccess + +type CreatePostSuccess { + post: Post! +} + type CreateReactionError { errorCodes: [CreateReactionErrorCode!]! } @@ -673,6 +697,21 @@ type DeleteNewsletterEmailSuccess { newsletterEmail: NewsletterEmail! } +type DeletePostError { + errorCodes: [DeletePostErrorCode!]! +} + +enum DeletePostErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union DeletePostResult = DeletePostError | DeletePostSuccess + +type DeletePostSuccess { + success: Boolean! +} + type DeleteReactionError { errorCodes: [DeleteReactionErrorCode!]! } @@ -1625,6 +1664,7 @@ type Mutation { createHighlight(input: CreateHighlightInput!): CreateHighlightResult! createLabel(input: CreateLabelInput!): CreateLabelResult! createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult! + createPost(input: CreatePostInput!): CreatePostResult! deleteAccount(userID: ID!): DeleteAccountResult! deleteDiscoverArticle(input: DeleteDiscoverArticleInput!): DeleteDiscoverArticleResult! deleteDiscoverFeed(input: DeleteDiscoverFeedInput!): DeleteDiscoverFeedResult! @@ -1634,6 +1674,7 @@ type Mutation { deleteIntegration(id: ID!): DeleteIntegrationResult! deleteLabel(id: ID!): DeleteLabelResult! deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! + deletePost(id: ID!): DeletePostResult! deleteRule(id: ID!): DeleteRuleResult! deleteWebhook(id: ID!): DeleteWebhookResult! editDiscoverFeed(input: EditDiscoverFeedInput!): EditDiscoverFeedResult! @@ -1684,6 +1725,7 @@ type Mutation { updateLabel(input: UpdateLabelInput!): UpdateLabelResult! updateNewsletterEmail(input: UpdateNewsletterEmailInput!): UpdateNewsletterEmailResult! updatePage(input: UpdatePageInput!): UpdatePageResult! + updatePost(input: UpdatePostInput!): UpdatePostResult! updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult! updateUser(input: UpdateUserInput!): UpdateUserResult! updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! @@ -1800,6 +1842,56 @@ input ParseResult { title: String! } +type Post { + author: User! + content: String! + createdAt: Date! + highlights: [Highlight!] + id: ID! + libraryItems: [Article!] + ownedByViewer: Boolean! + thought: String + thumbnail: String + title: String! + updatedAt: Date! +} + +type PostEdge { + cursor: String! + node: Post! +} + +type PostError { + errorCodes: [PostErrorCode!]! +} + +enum PostErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union PostResult = PostError | PostSuccess + +type PostSuccess { + post: Post! +} + +type PostsError { + errorCodes: [PostsErrorCode!]! +} + +enum PostsErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union PostsResult = PostsError | PostsSuccess + +type PostsSuccess { + edges: [PostEdge!]! + pageInfo: PageInfo! +} + input PreparedDocumentInput { document: String! pageInfo: PageInfoInput! @@ -1835,6 +1927,8 @@ type Query { labels: LabelsResult! me: User newsletterEmails: NewsletterEmailsResult! + post(id: ID!): PostResult! + posts(after: String, first: Int, userId: ID!): PostsResult! recentEmails: RecentEmailsResult! recentSearches: RecentSearchesResult! rules(enabled: Boolean): RulesResult! @@ -3068,6 +3162,31 @@ type UpdatePageSuccess { updatedPage: Article! } +type UpdatePostError { + errorCodes: [UpdatePostErrorCode!]! +} + +enum UpdatePostErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +input UpdatePostInput { + content: String + highlightIds: [ID!] + id: ID! + libraryItemIds: [ID!] + thought: String + thumbnail: String + title: String +} + +union UpdatePostResult = UpdatePostError | UpdatePostSuccess + +type UpdatePostSuccess { + post: Post! +} + enum UpdateReason { CREATED DELETED diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index aa226adc3..8c4543808 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -150,6 +150,7 @@ import { webhookResolver, webhooksResolver, } from './index' +import { postsResolver } from './posts' import { markEmailAsItemResolver, recentEmailsResolver, @@ -352,6 +353,7 @@ export const functionResolvers = { hiddenHomeSection: hiddenHomeSectionResolver, highlights: highlightsResolver, folderPolicies: folderPoliciesResolver, + posts: postsResolver, }, User: { async intercomHash(user: User) { @@ -777,6 +779,23 @@ export const functionResolvers = { name: (recommendation: Recommendation) => recommendation.group.name, recommendedAt: (recommendation: Recommendation) => recommendation.createdAt, }, + Post: { + author(post: { userId: string }, _: unknown, ctx: WithDataSourcesContext) { + return ctx.dataLoaders.users.load(post.userId) + }, + ownedByViewer(post: { userId: string }, ctx: WithDataSourcesContext) { + return post.userId === ctx.uid + }, + libraryItems( + post: { libraryItemIds: string[] }, + ctx: WithDataSourcesContext + ) { + return ctx.dataLoaders.libraryItems.loadMany(post.libraryItemIds) + }, + highlights(post: { highlightIds: string[] }, ctx: WithDataSourcesContext) { + return ctx.dataLoaders.highlights.loadMany(post.highlightIds) + }, + }, ...resultResolveTypeResolver('Login'), ...resultResolveTypeResolver('LogOut'), ...resultResolveTypeResolver('GoogleSignup'), @@ -875,4 +894,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('CreateFolderPolicy'), ...resultResolveTypeResolver('UpdateFolderPolicy'), ...resultResolveTypeResolver('DeleteFolderPolicy'), + ...resultResolveTypeResolver('Posts'), } diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts new file mode 100644 index 000000000..ada8dd0ac --- /dev/null +++ b/packages/api/src/resolvers/posts/index.ts @@ -0,0 +1,63 @@ +import { Post } from '../../entity/post' +import { + PostEdge, + PostsErrorCode, + PostsResult, + QueryPostsArgs, + ResolverFn, +} from '../../generated/graphql' +import { findPostsByUserId } from '../../services/post' +import { Merge } from '../../util' +import { ResolverContext } from '../types' + +type PartialPostEdge = Merge< + PostEdge, + { + node: Post + } +> +type PartialPostsResult = Merge< + PostsResult, + { + edges?: Array + } +> +export const postsResolver: ResolverFn< + PartialPostsResult, + never, + ResolverContext, + QueryPostsArgs +> = async (_, { first, after, userId }, { log }) => { + const limit = first || 10 + const offset = parseInt(after || '0') + if (isNaN(offset) || offset < 0 || limit > 50) { + log.error('Invalid args', { after, first }) + + return { + errorCodes: [PostsErrorCode.BadRequest], + } + } + + const posts = await findPostsByUserId(userId, limit + 1, offset) + + const hasNextPage = posts.length > limit + if (hasNextPage) { + posts.pop() + } + const endCursor = String(offset + posts.length) + + const edges = posts.map((post) => ({ + cursor: endCursor, + node: post, + })) + + return { + edges, + pageInfo: { + startCursor: String(offset), + endCursor, + hasPreviousPage: offset > 0, + hasNextPage, + }, + } +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 68caafc98..1d2b5ca7a 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3339,6 +3339,120 @@ const schema = gql` afterDays: Int } + type Post { + id: ID! + title: String! + content: String! + author: User! + ownedByViewer: Boolean! + thumbnail: String + thought: String + libraryItems: [Article!] + highlights: [Highlight!] + createdAt: Date! + updatedAt: Date! + } + + input CreatePostInput { + title: String! + content: String! + thumbnail: String + libraryItemIds: [ID!] + highlightIds: [ID!] + thought: String + } + + union CreatePostResult = CreatePostSuccess | CreatePostError + + type CreatePostSuccess { + post: Post! + } + + type CreatePostError { + errorCodes: [CreatePostErrorCode!]! + } + + enum CreatePostErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + input UpdatePostInput { + id: ID! + title: String + content: String + thumbnail: String + libraryItemIds: [ID!] + highlightIds: [ID!] + thought: String + } + + union UpdatePostResult = UpdatePostSuccess | UpdatePostError + + type UpdatePostSuccess { + post: Post! + } + + type UpdatePostError { + errorCodes: [UpdatePostErrorCode!]! + } + + enum UpdatePostErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + union DeletePostResult = DeletePostSuccess | DeletePostError + + type DeletePostSuccess { + success: Boolean! + } + + type DeletePostError { + errorCodes: [DeletePostErrorCode!]! + } + + enum DeletePostErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + union PostsResult = PostsSuccess | PostsError + + type PostsSuccess { + edges: [PostEdge!]! + pageInfo: PageInfo! + } + + type PostEdge { + cursor: String! + node: Post! + } + + type PostsError { + errorCodes: [PostsErrorCode!]! + } + + enum PostsErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + + union PostResult = PostSuccess | PostError + + type PostSuccess { + post: Post! + } + + type PostError { + errorCodes: [PostErrorCode!]! + } + + enum PostErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -3353,24 +3467,10 @@ const schema = gql` mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! - # createHighlightReply( - # input: CreateHighlightReplyInput! - # ): CreateHighlightReplyResult! - # updateHighlightReply( - # input: UpdateHighlightReplyInput! - # ): UpdateHighlightReplyResult! - # deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! - # createReaction(input: CreateReactionInput!): CreateReactionResult! - # deleteReaction(id: ID!): DeleteReactionResult! uploadFileRequest(input: UploadFileRequestInput!): UploadFileRequestResult! saveArticleReadingProgress( input: SaveArticleReadingProgressInput! ): SaveArticleReadingProgressResult! - # setShareArticle(input: SetShareArticleInput!): SetShareArticleResult! - # updateSharedComment( - # input: UpdateSharedCommentInput! - # ): UpdateSharedCommentResult! - # setFollow(input: SetFollowInput!): SetFollowResult! setBookmarkArticle( input: SetBookmarkArticleInput! ): SetBookmarkArticleResult! @@ -3380,11 +3480,7 @@ const schema = gql` createArticleSavingRequest( input: CreateArticleSavingRequestInput! ): CreateArticleSavingRequestResult! - # setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! reportItem(input: ReportItemInput!): ReportItemResult! - # updateLinkShareInfo( - # input: UpdateLinkShareInfoInput! - # ): UpdateLinkShareInfoResult! setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult! createNewsletterEmail( input: CreateNewsletterEmailInput @@ -3472,6 +3568,9 @@ const schema = gql` input: UpdateFolderPolicyInput! ): UpdateFolderPolicyResult! deleteFolderPolicy(id: ID!): DeleteFolderPolicyResult! + createPost(input: CreatePostInput!): CreatePostResult! + updatePost(input: UpdatePostInput!): UpdatePostResult! + deletePost(id: ID!): DeletePostResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed @@ -3481,17 +3580,6 @@ const schema = gql` me: User user(userId: ID, username: String): UserResult! article(username: String!, slug: String!, format: String): ArticleResult! - # sharedArticle( - # username: String! - # slug: String! - # selectedHighlightId: String - # ): SharedArticleResult! - # feedArticles( - # after: String - # first: Int - # sort: SortParams - # sharedByUser: ID - # ): FeedArticlesResult! users: UsersResult! validateUsername(username: String!): Boolean! # getFollowers(userId: ID): GetFollowersResult! @@ -3547,6 +3635,8 @@ const schema = gql` hiddenHomeSection: HiddenHomeSectionResult! highlights(after: String, first: Int, query: String): HighlightsResult! folderPolicies: FolderPoliciesResult! + posts(userId: ID!, after: String, first: Int): PostsResult! + post(id: ID!): PostResult! } schema { diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts new file mode 100644 index 000000000..c53df7997 --- /dev/null +++ b/packages/api/src/services/post.ts @@ -0,0 +1,26 @@ +import { Post } from '../entity/post' +import { getRepository } from '../repository' + +export const findPostsByUserId = async ( + userId: string, + limit: number, + offset: number +) => { + const posts = await getRepository(Post).find({ + where: { + user: { + id: userId, + profile: { + private: false, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + take: limit, + skip: offset, + }) + + return posts +} diff --git a/packages/db/migrations/0183.do.post.sql b/packages/db/migrations/0183.do.post.sql index 26f1d5e31..a5e410761 100755 --- a/packages/db/migrations/0183.do.post.sql +++ b/packages/db/migrations/0183.do.post.sql @@ -19,6 +19,24 @@ CREATE TABLE omnivore.post ( CREATE INDEX post_user_id_idx ON omnivore.post(user_id); -ALTER TABLE omnivore.user_profile ALTER COLUMN private SET DEFAULT true; +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.post TO omnivore_user; + +ALTER TABLE omnivore.post ENABLE ROW LEVEL SECURITY; + +CREATE POLICY read_post ON omnivore.post + FOR SELECT TO omnivore_user + USING (true); + +CREATE POLICY write_post ON omnivore.post + FOR INSERT TO omnivore_user + WITH CHECK (user_id = omnivore.get_current_user_id()); + +CREATE POLICY update_post ON omnivore.post + FOR UPDATE TO omnivore_user + USING (user_id = omnivore.get_current_user_id()); + +CREATE POLICY delete_post ON omnivore.post + FOR DELETE TO omnivore_user + USING (user_id = omnivore.get_current_user_id()); COMMIT; diff --git a/packages/db/migrations/0183.undo.post.sql b/packages/db/migrations/0183.undo.post.sql index aa74c9420..f49547e78 100755 --- a/packages/db/migrations/0183.undo.post.sql +++ b/packages/db/migrations/0183.undo.post.sql @@ -4,8 +4,6 @@ BEGIN; -ALTER TABLE omnivore.user_profile ALTER COLUMN private SET DEFAULT false; - DROP TABLE omnivore.post; COMMIT; From e9b945b375e1bbf3306e7120c9ef1edc6d442bd2 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sun, 16 Jun 2024 22:05:54 +0800 Subject: [PATCH 03/14] add tests --- packages/api/src/services/post.ts | 14 ++- packages/api/test/resolvers/post.test.ts | 130 +++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/api/test/resolvers/post.test.ts diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index c53df7997..32c8bfb16 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -1,5 +1,6 @@ +import { DeepPartial } from 'typeorm' import { Post } from '../entity/post' -import { getRepository } from '../repository' +import { authTrx, getRepository } from '../repository' export const findPostsByUserId = async ( userId: string, @@ -24,3 +25,14 @@ export const findPostsByUserId = async ( return posts } + +export const createPosts = async ( + userId: string, + posts: Array> +) => { + return authTrx( + async (trx) => trx.getRepository(Post).save(posts), + undefined, + userId + ) +} diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts new file mode 100644 index 000000000..fff9eb301 --- /dev/null +++ b/packages/api/test/resolvers/post.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai' +import { User } from '../../src/entity/user' +import { createPosts } from '../../src/services/post' +import { deleteUser } from '../../src/services/user' +import { createTestUser } from '../db' +import { graphqlRequest, loginAndGetAuthToken } from '../util' + +describe('Post Resolvers', () => { + let loginUser: User + let authToken: string + + before(async () => { + // create test user and login + loginUser = await createTestUser('loginUser') + authToken = await loginAndGetAuthToken(loginUser.email) + }) + + after(async () => { + await deleteUser(loginUser.id) + }) + + describe('postsResolver', () => { + const query = ` + query Posts($first: Int, $after: String, $userId: ID!) { + posts(first: $first, after: $after, userId: $userId) { + ... on PostsSuccess { + edges { + cursor + node { + id + title + content + ownedByViewer + } + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + } + } + ... on PostsError { + errorCodes + } + } + } + ` + + it('should return an error if the args are invalid', async () => { + const response = await graphqlRequest(query, '', { + first: 100, + userId: loginUser.id, + }) + + expect(response.body.data.posts.errorCodes).to.eql(['BAD_REQUEST']) + }) + + context('when the user is authenticated', () => { + before(async () => { + const posts = [ + { + title: 'Post 1', + content: 'Content 1', + user: loginUser, + }, + { + title: 'Post 2', + content: 'Content 2', + user: loginUser, + }, + ] + await createPosts(loginUser.id, posts) + }) + + it('should return posts if the user is the owner', async () => { + const response = await graphqlRequest(query, authToken, { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges).to.be.an('array') + expect(response.body.data.posts.pageInfo).to.be.an('object') + }) + }) + + it('should return posts if the user is not authenticated and the posts are public', async () => { + const posts = [ + { + title: 'Post 1', + content: 'Content 1', + user: loginUser, + }, + { + title: 'Post 2', + content: 'Content 2', + user: loginUser, + }, + ] + await createPosts(loginUser.id, posts) + + const response = await graphqlRequest(query, '', { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges).to.be.an('array') + expect(response.body.data.posts.pageInfo).to.be.an('object') + }) + + it('should return empty array if the user is not authenticated and the posts are private', async () => { + const response = await graphqlRequest(query, '', { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.errorCodes).to.eql(['UNAUTHORIZED']) + }) + + it('should return posts if the user is the owner', async () => { + const response = await graphqlRequest(query, authToken, { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges).to.be.an('array') + expect(response.body.data.posts.pageInfo).to.be.an('object') + }) + }) +}) From 091af055c0e334de420e8b7a5e2c7a4153049191 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 17 Jun 2024 21:53:06 +0800 Subject: [PATCH 04/14] add tests --- packages/api/src/services/post.ts | 10 ++ packages/api/src/services/profile.ts | 15 +++ packages/api/test/resolvers/post.test.ts | 112 +++++++++++------------ 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index 32c8bfb16..1c25661be 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -36,3 +36,13 @@ export const createPosts = async ( userId ) } + +export const deletePosts = async (userId: string, postIds: string[]) => { + return authTrx( + async (trx) => { + await trx.getRepository(Post).delete(postIds) + }, + undefined, + userId + ) +} diff --git a/packages/api/src/services/profile.ts b/packages/api/src/services/profile.ts index 6547ab09f..a3b1bd72c 100644 --- a/packages/api/src/services/profile.ts +++ b/packages/api/src/services/profile.ts @@ -5,3 +5,18 @@ import { getRepository } from '../repository' export const findProfile = async (user: User): Promise => { return getRepository(Profile).findOneBy({ user: { id: user.id } }) } + +export const updateProfile = async ( + userId: string, + profile: Partial +): Promise => { + const profileRepository = getRepository(Profile) + const existingProfile = await findProfile(user) + + if (!existingProfile) { + return profileRepository.save({ ...profile, user }) + } + + const updatedProfile = { ...existingProfile, ...profile } + return profileRepository.save(updatedProfile) +} diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index fff9eb301..91cdd0085 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai' import { User } from '../../src/entity/user' -import { createPosts } from '../../src/services/post' +import { updateUserProfileResolver } from '../../src/resolvers' +import { createPosts, deletePosts } from '../../src/services/post' import { deleteUser } from '../../src/services/user' import { createTestUser } from '../db' import { graphqlRequest, loginAndGetAuthToken } from '../util' @@ -47,44 +48,9 @@ describe('Post Resolvers', () => { } ` - it('should return an error if the args are invalid', async () => { - const response = await graphqlRequest(query, '', { - first: 100, - userId: loginUser.id, - }) + let postIds: Array = [] - expect(response.body.data.posts.errorCodes).to.eql(['BAD_REQUEST']) - }) - - context('when the user is authenticated', () => { - before(async () => { - const posts = [ - { - title: 'Post 1', - content: 'Content 1', - user: loginUser, - }, - { - title: 'Post 2', - content: 'Content 2', - user: loginUser, - }, - ] - await createPosts(loginUser.id, posts) - }) - - it('should return posts if the user is the owner', async () => { - const response = await graphqlRequest(query, authToken, { - first: 10, - userId: loginUser.id, - }) - - expect(response.body.data.posts.edges).to.be.an('array') - expect(response.body.data.posts.pageInfo).to.be.an('object') - }) - }) - - it('should return posts if the user is not authenticated and the posts are public', async () => { + before(async () => { const posts = [ { title: 'Post 1', @@ -97,34 +63,64 @@ describe('Post Resolvers', () => { user: loginUser, }, ] - await createPosts(loginUser.id, posts) + const newPosts = await createPosts(loginUser.id, posts) - const response = await graphqlRequest(query, '', { - first: 10, - userId: loginUser.id, - }) - - expect(response.body.data.posts.edges).to.be.an('array') - expect(response.body.data.posts.pageInfo).to.be.an('object') + postIds = newPosts.map((post) => post.id) }) - it('should return empty array if the user is not authenticated and the posts are private', async () => { - const response = await graphqlRequest(query, '', { - first: 10, - userId: loginUser.id, - }) - - expect(response.body.data.posts.errorCodes).to.eql(['UNAUTHORIZED']) + after(async () => { + await deletePosts(loginUser.id, postIds) }) - it('should return posts if the user is the owner', async () => { - const response = await graphqlRequest(query, authToken, { - first: 10, + it('should return an error if the args are invalid', async () => { + const response = await graphqlRequest(query, '', { + first: 100, userId: loginUser.id, }) - expect(response.body.data.posts.edges).to.be.an('array') - expect(response.body.data.posts.pageInfo).to.be.an('object') + expect(response.body.data.posts.errorCodes).to.eql(['BAD_REQUEST']) + }) + + context('when the user is authenticated', () => { + it('should return posts', async () => { + const response = await graphqlRequest(query, authToken, { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges[0].node.id).to.eql(postIds[1]) + expect(response.body.data.posts.edges[1].node.id).to.eql(postIds[0]) + expect(response.body.data.posts.edges[0].node.ownedByViewer).to.be.true + }) + }) + + context('when the user is not authenticated', () => { + context('when the posts are public', () => { + before(async () => { + await updateUserProfileResolver + it('should return posts', async () => { + const response = await graphqlRequest(query, '', { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges[0].node.id).to.eql(postIds[1]) + expect(response.body.data.posts.edges[1].node.id).to.eql(postIds[0]) + expect(response.body.data.posts.edges[0].node.ownedByViewer).to.be + .true + }) + }) + + context('when the posts are private', () => { + it('should return empty array', async () => { + const response = await graphqlRequest(query, '', { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.errorCodes).to.eql(['UNAUTHORIZED']) + }) + }) }) }) }) From 81b0fc19a2f59e1182d5b3fa16cec19655dea764 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 11:01:00 +0800 Subject: [PATCH 05/14] tidy --- .../api/src/resolvers/function_resolvers.ts | 17 +++++++---- packages/api/src/resolvers/posts/index.ts | 5 ++-- packages/api/src/services/post.ts | 15 +++++----- packages/api/src/services/profile.ts | 21 +++++++------- packages/api/test/resolvers/post.test.ts | 28 +++++++++++++++---- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 8c4543808..be237727e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -11,6 +11,7 @@ import { EXISTING_NEWSLETTER_FOLDER, NewsletterEmail, } from '../entity/newsletter_email' +import { Post } from '../entity/post' import { PublicItem } from '../entity/public_item' import { Recommendation } from '../entity/recommendation' import { @@ -780,19 +781,25 @@ export const functionResolvers = { recommendedAt: (recommendation: Recommendation) => recommendation.createdAt, }, Post: { - author(post: { userId: string }, _: unknown, ctx: WithDataSourcesContext) { + author(post: Post, _: never, ctx: ResolverContext) { return ctx.dataLoaders.users.load(post.userId) }, - ownedByViewer(post: { userId: string }, ctx: WithDataSourcesContext) { - return post.userId === ctx.uid + ownedByViewer(post: Post, _: never, ctx: ResolverContext) { + console.log('ownedByViewer: ctx.claims?.uid', ctx.claims?.uid) + return post.userId === ctx.claims?.uid }, libraryItems( post: { libraryItemIds: string[] }, - ctx: WithDataSourcesContext + _: never, + ctx: ResolverContext ) { return ctx.dataLoaders.libraryItems.loadMany(post.libraryItemIds) }, - highlights(post: { highlightIds: string[] }, ctx: WithDataSourcesContext) { + highlights( + post: { highlightIds: string[] }, + _: never, + ctx: ResolverContext + ) { return ctx.dataLoaders.highlights.loadMany(post.highlightIds) }, }, diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index ada8dd0ac..c0ee1bd36 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -6,7 +6,7 @@ import { QueryPostsArgs, ResolverFn, } from '../../generated/graphql' -import { findPostsByUserId } from '../../services/post' +import { findPublicPostsByUserId } from '../../services/post' import { Merge } from '../../util' import { ResolverContext } from '../types' @@ -38,7 +38,8 @@ export const postsResolver: ResolverFn< } } - const posts = await findPostsByUserId(userId, limit + 1, offset) + const posts = await findPublicPostsByUserId(userId, limit + 1, offset) + console.log(posts) const hasNextPage = posts.length > limit if (hasNextPage) { diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index 1c25661be..474b09870 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -2,7 +2,7 @@ import { DeepPartial } from 'typeorm' import { Post } from '../entity/post' import { authTrx, getRepository } from '../repository' -export const findPostsByUserId = async ( +export const findPublicPostsByUserId = async ( userId: string, limit: number, offset: number @@ -30,11 +30,9 @@ export const createPosts = async ( userId: string, posts: Array> ) => { - return authTrx( - async (trx) => trx.getRepository(Post).save(posts), - undefined, - userId - ) + return authTrx(async (trx) => trx.getRepository(Post).save(posts), { + uid: userId, + }) } export const deletePosts = async (userId: string, postIds: string[]) => { @@ -42,7 +40,8 @@ export const deletePosts = async (userId: string, postIds: string[]) => { async (trx) => { await trx.getRepository(Post).delete(postIds) }, - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/profile.ts b/packages/api/src/services/profile.ts index a3b1bd72c..b444ae43f 100644 --- a/packages/api/src/services/profile.ts +++ b/packages/api/src/services/profile.ts @@ -1,6 +1,6 @@ import { Profile } from '../entity/profile' import { User } from '../entity/user' -import { getRepository } from '../repository' +import { authTrx, getRepository } from '../repository' export const findProfile = async (user: User): Promise => { return getRepository(Profile).findOneBy({ user: { id: user.id } }) @@ -9,14 +9,13 @@ export const findProfile = async (user: User): Promise => { export const updateProfile = async ( userId: string, profile: Partial -): Promise => { - const profileRepository = getRepository(Profile) - const existingProfile = await findProfile(user) - - if (!existingProfile) { - return profileRepository.save({ ...profile, user }) - } - - const updatedProfile = { ...existingProfile, ...profile } - return profileRepository.save(updatedProfile) +) => { + return authTrx( + (tx) => { + return tx.getRepository(Profile).update({ user: { id: userId } }, profile) + }, + { + uid: userId, + } + ) } diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index 91cdd0085..52fa30dab 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai' import { User } from '../../src/entity/user' -import { updateUserProfileResolver } from '../../src/resolvers' import { createPosts, deletePosts } from '../../src/services/post' +import { updateProfile } from '../../src/services/profile' import { deleteUser } from '../../src/services/user' import { createTestUser } from '../db' import { graphqlRequest, loginAndGetAuthToken } from '../util' @@ -56,11 +56,13 @@ describe('Post Resolvers', () => { title: 'Post 1', content: 'Content 1', user: loginUser, + createdAt: new Date('2021-01-01'), }, { title: 'Post 2', content: 'Content 2', user: loginUser, + createdAt: new Date('2021-01-02'), }, ] const newPosts = await createPosts(loginUser.id, posts) @@ -95,9 +97,15 @@ describe('Post Resolvers', () => { }) context('when the user is not authenticated', () => { - context('when the posts are public', () => { + context('when user profile is public', () => { before(async () => { - await updateUserProfileResolver + await updateProfile(loginUser.id, { private: false }) + }) + + after(async () => { + await updateProfile(loginUser.id, { private: true }) + }) + it('should return posts', async () => { const response = await graphqlRequest(query, '', { first: 10, @@ -107,18 +115,26 @@ describe('Post Resolvers', () => { expect(response.body.data.posts.edges[0].node.id).to.eql(postIds[1]) expect(response.body.data.posts.edges[1].node.id).to.eql(postIds[0]) expect(response.body.data.posts.edges[0].node.ownedByViewer).to.be - .true + .false }) }) - context('when the posts are private', () => { + context('when user profile is private', () => { + before(async () => { + await updateProfile(loginUser.id, { private: true }) + }) + + after(async () => { + await updateProfile(loginUser.id, { private: false }) + }) + it('should return empty array', async () => { const response = await graphqlRequest(query, '', { first: 10, userId: loginUser.id, }) - expect(response.body.data.posts.errorCodes).to.eql(['UNAUTHORIZED']) + expect(response.body.data.posts.edges).to.be.empty }) }) }) From 12db8b6a7dda29d941c782e9f21dd49886d277dd Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 15:07:38 +0800 Subject: [PATCH 06/14] add get post graphql api and tests --- packages/api/src/generated/graphql.ts | 1 + packages/api/src/generated/schema.graphql | 1 + .../api/src/resolvers/function_resolvers.ts | 4 +- packages/api/src/resolvers/posts/index.ts | 39 ++++++- packages/api/src/schema.ts | 1 + packages/api/src/services/post.ts | 11 ++ packages/api/test/resolvers/post.test.ts | 105 +++++++++++++++++- 7 files changed, 158 insertions(+), 4 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 9b6bfbb19..f4f2a6351 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -2435,6 +2435,7 @@ export type PostError = { export enum PostErrorCode { BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index c9346054f..5e3e584ca 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1867,6 +1867,7 @@ type PostError { enum PostErrorCode { BAD_REQUEST + NOT_FOUND UNAUTHORIZED } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index be237727e..572ae119e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -151,7 +151,7 @@ import { webhookResolver, webhooksResolver, } from './index' -import { postsResolver } from './posts' +import { postResolver, postsResolver } from './posts' import { markEmailAsItemResolver, recentEmailsResolver, @@ -355,6 +355,7 @@ export const functionResolvers = { highlights: highlightsResolver, folderPolicies: folderPoliciesResolver, posts: postsResolver, + post: postResolver, }, User: { async intercomHash(user: User) { @@ -902,4 +903,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('UpdateFolderPolicy'), ...resultResolveTypeResolver('DeleteFolderPolicy'), ...resultResolveTypeResolver('Posts'), + ...resultResolveTypeResolver('Post'), } diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index c0ee1bd36..bb35f0bb8 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -1,12 +1,18 @@ import { Post } from '../../entity/post' import { PostEdge, + PostErrorCode, + PostResult, PostsErrorCode, PostsResult, + QueryPostArgs, QueryPostsArgs, ResolverFn, } from '../../generated/graphql' -import { findPublicPostsByUserId } from '../../services/post' +import { + findPublicPostById, + findPublicPostsByUserId, +} from '../../services/post' import { Merge } from '../../util' import { ResolverContext } from '../types' @@ -39,7 +45,6 @@ export const postsResolver: ResolverFn< } const posts = await findPublicPostsByUserId(userId, limit + 1, offset) - console.log(posts) const hasNextPage = posts.length > limit if (hasNextPage) { @@ -62,3 +67,33 @@ export const postsResolver: ResolverFn< }, } } + +export const postResolver: ResolverFn< + Merge, + never, + ResolverContext, + QueryPostArgs +> = async (_, { id }, { log }) => { + if (!id) { + log.error('Invalid args', { id }) + + return { + errorCodes: [PostErrorCode.BadRequest], + } + } + + const post = await findPublicPostById(id) + + if (!post) { + log.error('Post not found', { id }) + + return { + errorCodes: [PostErrorCode.NotFound], + } + } + + return { + post, + } +} + diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 1d2b5ca7a..5e1f8958b 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3451,6 +3451,7 @@ const schema = gql` enum PostErrorCode { UNAUTHORIZED BAD_REQUEST + NOT_FOUND } # Mutations diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index 474b09870..a3974c5c0 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -45,3 +45,14 @@ export const deletePosts = async (userId: string, postIds: string[]) => { } ) } + +export const findPublicPostById = async (id: string) => { + return getRepository(Post).findOneBy({ + id, + user: { + profile: { + private: false, + }, + }, + }) +} diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index 52fa30dab..d0ec1e6a5 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -4,7 +4,7 @@ import { createPosts, deletePosts } from '../../src/services/post' import { updateProfile } from '../../src/services/profile' import { deleteUser } from '../../src/services/user' import { createTestUser } from '../db' -import { graphqlRequest, loginAndGetAuthToken } from '../util' +import { generateFakeUuid, graphqlRequest, loginAndGetAuthToken } from '../util' describe('Post Resolvers', () => { let loginUser: User @@ -139,4 +139,107 @@ describe('Post Resolvers', () => { }) }) }) + + describe('postResolver', () => { + const query = ` + query Post($id: ID!) { + post(id: $id) { + ... on PostSuccess { + post { + id + title + content + ownedByViewer + } + } + ... on PostError { + errorCodes + } + } + } + ` + + let postId: string + + before(async () => { + const post = { + title: 'Post', + content: 'Content', + user: loginUser, + } + const newPost = await createPosts(loginUser.id, [post]) + + postId = newPost[0].id + }) + + after(async () => { + await deletePosts(loginUser.id, [postId]) + }) + + it('should return an error if the args are invalid', async () => { + const response = await graphqlRequest(query, '', { + id: '', + }) + + expect(response.body.data.post.errorCodes).to.eql(['BAD_REQUEST']) + }) + + it('should return an error if the post is not found', async () => { + const response = await graphqlRequest(query, '', { + id: generateFakeUuid(), + }) + + expect(response.body.data.post.errorCodes).to.eql(['NOT_FOUND']) + }) + + context('when the user is authenticated', () => { + it('should return the post', async () => { + const response = await graphqlRequest(query, authToken, { + id: postId, + }) + + expect(response.body.data.post.post.id).to.eql(postId) + expect(response.body.data.post.post.ownedByViewer).to.be.true + }) + }) + + context('when the user is not authenticated', () => { + context('when user profile is public', () => { + before(async () => { + await updateProfile(loginUser.id, { private: false }) + }) + + after(async () => { + await updateProfile(loginUser.id, { private: true }) + }) + + it('should return the post', async () => { + const response = await graphqlRequest(query, '', { + id: postId, + }) + + expect(response.body.data.post.post.id).to.eql(postId) + expect(response.body.data.post.post.ownedByViewer).to.be.false + }) + }) + + context('when user profile is private', () => { + before(async () => { + await updateProfile(loginUser.id, { private: true }) + }) + + after(async () => { + await updateProfile(loginUser.id, { private: false }) + }) + + it('should return an error', async () => { + const response = await graphqlRequest(query, '', { + id: postId, + }) + + expect(response.body.data.post.errorCodes).to.eql(['NOT_FOUND']) + }) + }) + }) + }) }) From 9caad12f09f54a9ac82a011d85ab7b91281d016b Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 15:55:06 +0800 Subject: [PATCH 07/14] add create post graphql api and tests --- packages/api/src/generated/graphql.ts | 1 - packages/api/src/generated/schema.graphql | 1 - packages/api/src/repository/index.ts | 2 +- .../api/src/resolvers/function_resolvers.ts | 4 +- packages/api/src/resolvers/posts/index.ts | 38 ++++++++++++++ packages/api/src/schema.ts | 5 +- packages/api/src/services/post.ts | 25 ++++++++++ packages/api/test/resolvers/post.test.ts | 50 ++++++++++++++++++- 8 files changed, 117 insertions(+), 9 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index f4f2a6351..8b4830ad8 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -515,7 +515,6 @@ export type CreatePostError = { }; export enum CreatePostErrorCode { - BadRequest = 'BAD_REQUEST', Unauthorized = 'UNAUTHORIZED' } diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 5e3e584ca..0e8c2a93d 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -460,7 +460,6 @@ type CreatePostError { } enum CreatePostErrorCode { - BAD_REQUEST UNAUTHORIZED } diff --git a/packages/api/src/repository/index.ts b/packages/api/src/repository/index.ts index d8c574725..d4005ed25 100644 --- a/packages/api/src/repository/index.ts +++ b/packages/api/src/repository/index.ts @@ -72,7 +72,7 @@ export const authTrx = async ( ): Promise => { let { uid, userRole } = options - // if uid and dbRole are not passed in, then get them from the claims + // if uid and dbRole are not passed in, then get them from the http context if (!uid && !userRole) { const claims: Claims | undefined = httpContext.get('claims') uid = claims?.uid diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 572ae119e..8bb629929 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -151,7 +151,7 @@ import { webhookResolver, webhooksResolver, } from './index' -import { postResolver, postsResolver } from './posts' +import { createPostResolver, postResolver, postsResolver } from './posts' import { markEmailAsItemResolver, recentEmailsResolver, @@ -318,6 +318,7 @@ export const functionResolvers = { createFolderPolicy: createFolderPolicyResolver, updateFolderPolicy: updateFolderPolicyResolver, deleteFolderPolicy: deleteFolderPolicyResolver, + createPost: createPostResolver, }, Query: { me: getMeUserResolver, @@ -904,4 +905,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('DeleteFolderPolicy'), ...resultResolveTypeResolver('Posts'), ...resultResolveTypeResolver('Post'), + ...resultResolveTypeResolver('CreatePost'), } diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index bb35f0bb8..2bde99791 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -1,5 +1,9 @@ import { Post } from '../../entity/post' import { + CreatePostError, + CreatePostErrorCode, + CreatePostSuccess, + MutationCreatePostArgs, PostEdge, PostErrorCode, PostResult, @@ -10,10 +14,13 @@ import { ResolverFn, } from '../../generated/graphql' import { + createPosts, + createPublicPost, findPublicPostById, findPublicPostsByUserId, } from '../../services/post' import { Merge } from '../../util' +import { authorized } from '../../utils/gql-utils' import { ResolverContext } from '../types' type PartialPostEdge = Merge< @@ -97,3 +104,34 @@ export const postResolver: ResolverFn< } } +export const createPostResolver = authorized< + Merge, + CreatePostError, + MutationCreatePostArgs +>(async (_, { input }, { uid, log }) => { + const { title, content, highlightIds, libraryItemIds, thought, thumbnail } = + input + + const postToCreate = { + userId: uid, + title, + content, + highlightIds: highlightIds || undefined, + libraryItemIds: libraryItemIds || undefined, + thought: thought || undefined, + thumbnail: thumbnail || undefined, + } + + const post = await createPublicPost(uid, postToCreate) + if (!post) { + log.error('Failed to create post', { postToCreate }) + + return { + errorCodes: [CreatePostErrorCode.Unauthorized], + } + } + + return { + post, + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 5e1f8958b..07035d315 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3354,8 +3354,8 @@ const schema = gql` } input CreatePostInput { - title: String! - content: String! + title: String! @sanitize(minLength: 1, maxLength: 255) + content: String! @sanitize(minLength: 1) thumbnail: String libraryItemIds: [ID!] highlightIds: [ID!] @@ -3374,7 +3374,6 @@ const schema = gql` enum CreatePostErrorCode { UNAUTHORIZED - BAD_REQUEST } input UpdatePostInput { diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index a3974c5c0..f67b0cd5c 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -1,5 +1,6 @@ import { DeepPartial } from 'typeorm' import { Post } from '../entity/post' +import { Profile } from '../entity/profile' import { authTrx, getRepository } from '../repository' export const findPublicPostsByUserId = async ( @@ -26,6 +27,30 @@ export const findPublicPostsByUserId = async ( return posts } +export const createPublicPost = async ( + userId: string, + post: DeepPartial +) => { + return authTrx( + async (trx) => { + const newPost = await trx.getRepository(Post).save(post) + + // Make user profile public when user creates a post + await trx.getRepository(Profile).update( + { user: { id: userId } }, + { + private: false, + } + ) + + return newPost + }, + { + uid: userId, + } + ) +} + export const createPosts = async ( userId: string, posts: Array> diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index d0ec1e6a5..bc17c094f 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -1,7 +1,11 @@ import { expect } from 'chai' import { User } from '../../src/entity/user' -import { createPosts, deletePosts } from '../../src/services/post' -import { updateProfile } from '../../src/services/profile' +import { + createPosts, + deletePosts, + findPublicPostById, +} from '../../src/services/post' +import { findProfile, updateProfile } from '../../src/services/profile' import { deleteUser } from '../../src/services/user' import { createTestUser } from '../db' import { generateFakeUuid, graphqlRequest, loginAndGetAuthToken } from '../util' @@ -242,4 +246,46 @@ describe('Post Resolvers', () => { }) }) }) + + describe('createPostResolver', () => { + const mutation = ` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + ... on CreatePostSuccess { + post { + id + title + content + } + } + ... on CreatePostError { + errorCodes + } + } + } + ` + + it('should create a post', async () => { + const response = await graphqlRequest(mutation, authToken, { + input: { + title: 'Post', + content: 'Content', + }, + }) + + expect(response.body.data.createPost.post.title).to.eql('Post') + expect(response.body.data.createPost.post.content).to.eql('Content') + + const postId = response.body.data.createPost.post.id as string + + const post = await findPublicPostById(postId) + expect(post).to.exist + expect(post?.title).to.eql('Post') + + const profile = await findProfile(loginUser) + expect(profile?.private).to.be.false + + await deletePosts(loginUser.id, [postId]) + }) + }) }) From 16f67c854c5a6b6a95f7ebae7b696fb5e9bd57ef Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 16:17:53 +0800 Subject: [PATCH 08/14] add update post graphql api and tests --- packages/api/src/entity/post.ts | 8 +- .../api/src/resolvers/function_resolvers.ts | 9 +- packages/api/src/resolvers/posts/index.ts | 60 +++++++++++- packages/api/src/schema.ts | 4 +- packages/api/src/services/post.ts | 10 ++ packages/api/test/resolvers/post.test.ts | 95 +++++++++++++++++++ 6 files changed, 178 insertions(+), 8 deletions(-) diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts index d689d9202..3e8bf997e 100644 --- a/packages/api/src/entity/post.ts +++ b/packages/api/src/entity/post.ts @@ -20,10 +20,10 @@ export class Post { user!: User @Column('uuid', { array: true, nullable: true }) - libraryItemIds?: string[] + libraryItemIds?: string[] | null @Column('uuid', { array: true, nullable: true }) - highlightIds?: string[] + highlightIds?: string[] | null @Column('text') title!: string @@ -32,10 +32,10 @@ export class Post { content!: string @Column('text', { nullable: true }) - thumbnail?: string + thumbnail?: string | null @Column('text', { nullable: true }) - thought?: string + thought?: string | null @Column('timestamptz') createdAt!: Date diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 8bb629929..c2a8d83a3 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -151,7 +151,12 @@ import { webhookResolver, webhooksResolver, } from './index' -import { createPostResolver, postResolver, postsResolver } from './posts' +import { + createPostResolver, + postResolver, + postsResolver, + updatePostResolver, +} from './posts' import { markEmailAsItemResolver, recentEmailsResolver, @@ -319,6 +324,7 @@ export const functionResolvers = { updateFolderPolicy: updateFolderPolicyResolver, deleteFolderPolicy: deleteFolderPolicyResolver, createPost: createPostResolver, + updatePost: updatePostResolver, }, Query: { me: getMeUserResolver, @@ -906,4 +912,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Posts'), ...resultResolveTypeResolver('Post'), ...resultResolveTypeResolver('CreatePost'), + ...resultResolveTypeResolver('UpdatePost'), } diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index 2bde99791..e76ed3dd4 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -4,6 +4,7 @@ import { CreatePostErrorCode, CreatePostSuccess, MutationCreatePostArgs, + MutationUpdatePostArgs, PostEdge, PostErrorCode, PostResult, @@ -12,12 +13,15 @@ import { QueryPostArgs, QueryPostsArgs, ResolverFn, + UpdatePostError, + UpdatePostErrorCode, + UpdatePostSuccess, } from '../../generated/graphql' import { - createPosts, createPublicPost, findPublicPostById, findPublicPostsByUserId, + updatePost, } from '../../services/post' import { Merge } from '../../util' import { authorized } from '../../utils/gql-utils' @@ -135,3 +139,57 @@ export const createPostResolver = authorized< post, } }) + +export const updatePostResolver = authorized< + Merge, + UpdatePostError, + MutationUpdatePostArgs +>(async (_, { input }, { uid, log }) => { + const { + id, + title, + content, + highlightIds, + libraryItemIds, + thought, + thumbnail, + } = input + + if (!id || title === null || content === null) { + log.error('Invalid args', { id }) + + return { + errorCodes: [UpdatePostErrorCode.BadRequest], + } + } + + const result = await updatePost(uid, id, { + title, + content, + highlightIds, + libraryItemIds, + thought, + thumbnail, + }) + + if (!result.affected) { + log.error('Failed to update post', { id }) + + return { + errorCodes: [UpdatePostErrorCode.Unauthorized], + } + } + + const post = await findPublicPostById(id) + if (!post) { + log.error('Post not found', { id }) + + return { + errorCodes: [UpdatePostErrorCode.Unauthorized], + } + } + + return { + post, + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 07035d315..c36cb13e5 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3378,8 +3378,8 @@ const schema = gql` input UpdatePostInput { id: ID! - title: String - content: String + title: String @sanitize(minLength: 1, maxLength: 255) + content: String @sanitize(minLength: 1) thumbnail: String libraryItemIds: [ID!] highlightIds: [ID!] diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index f67b0cd5c..f51b95f27 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -81,3 +81,13 @@ export const findPublicPostById = async (id: string) => { }, }) } + +export const updatePost = async ( + userId: string, + postId: string, + post: Partial +) => { + return authTrx(async (trx) => trx.getRepository(Post).update(postId, post), { + uid: userId, + }) +} diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index bc17c094f..0c28a0621 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -288,4 +288,99 @@ describe('Post Resolvers', () => { await deletePosts(loginUser.id, [postId]) }) }) + + describe('updatePostResolver', () => { + const mutation = ` + mutation UpdatePost($input: UpdatePostInput!) { + updatePost(input: $input) { + ... on UpdatePostSuccess { + post { + id + title + content + } + } + ... on UpdatePostError { + errorCodes + } + } + } + ` + + let postId: string + + before(async () => { + const post = { + title: 'Post', + content: 'Content', + user: loginUser, + } + const newPost = await createPosts(loginUser.id, [post]) + + postId = newPost[0].id + }) + + after(async () => { + await deletePosts(loginUser.id, [postId]) + }) + + it('should return an error if the args are invalid', async () => { + const response = await graphqlRequest(mutation, authToken, { + input: { + id: postId, + title: null, + content: null, + }, + }) + + expect(response.body.data.updatePost.errorCodes).to.eql(['BAD_REQUEST']) + }) + + it('should return an error if the post is not found', async () => { + const response = await graphqlRequest(mutation, authToken, { + input: { + id: generateFakeUuid(), + title: 'Post', + content: 'Content', + }, + }) + + expect(response.body.data.updatePost.errorCodes).to.eql(['UNAUTHORIZED']) + }) + + it('should return an error if the user is not the owner of the post', async () => { + const notOwner = await createTestUser('notOwner') + const notOwnerToken = await loginAndGetAuthToken(notOwner.email) + + const response = await graphqlRequest(mutation, notOwnerToken, { + input: { + id: postId, + title: 'Post', + content: 'Content', + }, + }) + + expect(response.body.data.updatePost.errorCodes).to.eql(['UNAUTHORIZED']) + + await deleteUser(notOwner.id) + }) + + it('should update the post', async () => { + const response = await graphqlRequest(mutation, authToken, { + input: { + id: postId, + title: 'Updated Post', + content: 'Updated Content', + }, + }) + + expect(response.body.data.updatePost.post.title).to.eql('Updated Post') + expect(response.body.data.updatePost.post.content).to.eql( + 'Updated Content' + ) + + const post = await findPublicPostById(postId) + expect(post?.title).to.eql('Updated Post') + }) + }) }) From 72a477c25aa2a2722de46c3a3833ee84c23bc0b7 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 16:23:34 +0800 Subject: [PATCH 09/14] add delete post graphql api and tests --- .../api/src/resolvers/function_resolvers.ts | 3 + packages/api/src/resolvers/posts/index.ts | 33 +++++++++ packages/api/src/services/post.ts | 11 +-- packages/api/test/resolvers/post.test.ts | 71 +++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index c2a8d83a3..bb23933e5 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -153,6 +153,7 @@ import { } from './index' import { createPostResolver, + deletePostResolver, postResolver, postsResolver, updatePostResolver, @@ -325,6 +326,7 @@ export const functionResolvers = { deleteFolderPolicy: deleteFolderPolicyResolver, createPost: createPostResolver, updatePost: updatePostResolver, + deletePost: deletePostResolver, }, Query: { me: getMeUserResolver, @@ -913,4 +915,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Post'), ...resultResolveTypeResolver('CreatePost'), ...resultResolveTypeResolver('UpdatePost'), + ...resultResolveTypeResolver('DeletePost'), } diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index e76ed3dd4..88704bc0c 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -3,7 +3,11 @@ import { CreatePostError, CreatePostErrorCode, CreatePostSuccess, + DeletePostError, + DeletePostErrorCode, + DeletePostSuccess, MutationCreatePostArgs, + MutationDeletePostArgs, MutationUpdatePostArgs, PostEdge, PostErrorCode, @@ -19,6 +23,7 @@ import { } from '../../generated/graphql' import { createPublicPost, + deletePosts, findPublicPostById, findPublicPostsByUserId, updatePost, @@ -193,3 +198,31 @@ export const updatePostResolver = authorized< post, } }) + +export const deletePostResolver = authorized< + DeletePostSuccess, + DeletePostError, + MutationDeletePostArgs +>(async (_, { id }, { uid, log }) => { + if (!id) { + log.error('Invalid args', { id }) + + return { + errorCodes: [DeletePostErrorCode.BadRequest], + } + } + + const result = await deletePosts(uid, [id]) + + if (!result.affected) { + log.error('Failed to delete post', { id }) + + return { + errorCodes: [DeletePostErrorCode.Unauthorized], + } + } + + return { + success: true, + } +}) diff --git a/packages/api/src/services/post.ts b/packages/api/src/services/post.ts index f51b95f27..6a2be8715 100644 --- a/packages/api/src/services/post.ts +++ b/packages/api/src/services/post.ts @@ -61,14 +61,9 @@ export const createPosts = async ( } export const deletePosts = async (userId: string, postIds: string[]) => { - return authTrx( - async (trx) => { - await trx.getRepository(Post).delete(postIds) - }, - { - uid: userId, - } - ) + return authTrx(async (trx) => trx.getRepository(Post).delete(postIds), { + uid: userId, + }) } export const findPublicPostById = async (id: string) => { diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index 0c28a0621..99e9e51f6 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -383,4 +383,75 @@ describe('Post Resolvers', () => { expect(post?.title).to.eql('Updated Post') }) }) + + describe('deletePostResolver', () => { + const mutation = ` + mutation DeletePost($id: ID!) { + deletePost(id: $id) { + ... on DeletePostSuccess { + success + } + ... on DeletePostError { + errorCodes + } + } + } + ` + + let postId: string + + before(async () => { + const post = { + title: 'Post', + content: 'Content', + user: loginUser, + } + const newPost = await createPosts(loginUser.id, [post]) + + postId = newPost[0].id + }) + + it('should return an error if the args are invalid', async () => { + const response = await graphqlRequest(mutation, authToken, { + id: '', + }) + + expect(response.body.data.deletePost.errorCodes).to.eql(['BAD_REQUEST']) + }) + + it('should return an error if the post is not found', async () => { + const response = await graphqlRequest(mutation, authToken, { + id: generateFakeUuid(), + }) + + expect(response.body.data.deletePost.errorCodes).to.eql(['UNAUTHORIZED']) + }) + + it('should return an error if the user is not the owner of the post', async () => { + const notOwner = await createTestUser('notOwner') + const notOwnerToken = await loginAndGetAuthToken(notOwner.email) + + const response = await graphqlRequest(mutation, notOwnerToken, { + id: postId, + }) + + expect(response.body.data.deletePost.errorCodes).to.eql(['UNAUTHORIZED']) + + const post = await findPublicPostById(postId) + expect(post).to.exist + + await deleteUser(notOwner.id) + }) + + it('should delete the post', async () => { + const response = await graphqlRequest(mutation, authToken, { + id: postId, + }) + + expect(response.body.data.deletePost.success).to.be.true + + const post = await findPublicPostById(postId) + expect(post).to.not.exist + }) + }) }) From fa3e6fe341550fb6e52f64a140c8473aabb41296 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 16:41:58 +0800 Subject: [PATCH 10/14] make libraryItemIds not null --- packages/api/src/entity/post.ts | 4 +-- packages/api/src/generated/graphql.ts | 6 ++-- packages/api/src/generated/schema.graphql | 4 +-- packages/api/src/resolvers/posts/index.ts | 4 +-- packages/api/src/schema.ts | 4 +-- packages/api/test/resolvers/post.test.ts | 37 ++++++++++++++++++++++- packages/db/migrations/0183.do.post.sql | 2 +- 7 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts index 3e8bf997e..2890a3af9 100644 --- a/packages/api/src/entity/post.ts +++ b/packages/api/src/entity/post.ts @@ -19,8 +19,8 @@ export class Post { @JoinColumn({ name: 'user_id' }) user!: User - @Column('uuid', { array: true, nullable: true }) - libraryItemIds?: string[] | null + @Column('uuid', { array: true }) + libraryItemIds!: string[] @Column('uuid', { array: true, nullable: true }) highlightIds?: string[] | null diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 8b4830ad8..7a128adaa 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -521,7 +521,7 @@ export enum CreatePostErrorCode { export type CreatePostInput = { content: Scalars['String']; highlightIds?: InputMaybe>; - libraryItemIds?: InputMaybe>; + libraryItemIds: Array; thought?: InputMaybe; thumbnail?: InputMaybe; title: Scalars['String']; @@ -2413,7 +2413,7 @@ export type Post = { createdAt: Scalars['Date']; highlights?: Maybe>; id: Scalars['ID']; - libraryItems?: Maybe>; + libraryItems: Array
; ownedByViewer: Scalars['Boolean']; thought?: Maybe; thumbnail?: Maybe; @@ -7064,7 +7064,7 @@ export type PostResolvers; highlights?: Resolver>, ParentType, ContextType>; id?: Resolver; - libraryItems?: Resolver>, ParentType, ContextType>; + libraryItems?: Resolver, ParentType, ContextType>; ownedByViewer?: Resolver; thought?: Resolver, ParentType, ContextType>; thumbnail?: Resolver, ParentType, ContextType>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 0e8c2a93d..ca5744364 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -466,7 +466,7 @@ enum CreatePostErrorCode { input CreatePostInput { content: String! highlightIds: [ID!] - libraryItemIds: [ID!] + libraryItemIds: [ID!]! thought: String thumbnail: String title: String! @@ -1847,7 +1847,7 @@ type Post { createdAt: Date! highlights: [Highlight!] id: ID! - libraryItems: [Article!] + libraryItems: [Article!]! ownedByViewer: Boolean! thought: String thumbnail: String diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index 88704bc0c..38326dc1a 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -125,8 +125,8 @@ export const createPostResolver = authorized< userId: uid, title, content, + libraryItemIds, highlightIds: highlightIds || undefined, - libraryItemIds: libraryItemIds || undefined, thought: thought || undefined, thumbnail: thumbnail || undefined, } @@ -160,7 +160,7 @@ export const updatePostResolver = authorized< thumbnail, } = input - if (!id || title === null || content === null) { + if (!id || title === null || content === null || libraryItemIds === null) { log.error('Invalid args', { id }) return { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index c36cb13e5..3099e9b03 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3347,7 +3347,7 @@ const schema = gql` ownedByViewer: Boolean! thumbnail: String thought: String - libraryItems: [Article!] + libraryItems: [Article!]! highlights: [Highlight!] createdAt: Date! updatedAt: Date! @@ -3357,7 +3357,7 @@ const schema = gql` title: String! @sanitize(minLength: 1, maxLength: 255) content: String! @sanitize(minLength: 1) thumbnail: String - libraryItemIds: [ID!] + libraryItemIds: [ID!]! highlightIds: [ID!] thought: String } diff --git a/packages/api/test/resolvers/post.test.ts b/packages/api/test/resolvers/post.test.ts index 99e9e51f6..8d9c8f536 100644 --- a/packages/api/test/resolvers/post.test.ts +++ b/packages/api/test/resolvers/post.test.ts @@ -1,5 +1,7 @@ import { expect } from 'chai' +import { LibraryItem } from '../../src/entity/library_item' import { User } from '../../src/entity/user' +import { deleteLibraryItemById } from '../../src/services/library_item' import { createPosts, deletePosts, @@ -7,7 +9,7 @@ import { } from '../../src/services/post' import { findProfile, updateProfile } from '../../src/services/profile' import { deleteUser } from '../../src/services/user' -import { createTestUser } from '../db' +import { createTestLibraryItem, createTestUser } from '../db' import { generateFakeUuid, graphqlRequest, loginAndGetAuthToken } from '../util' describe('Post Resolvers', () => { @@ -53,20 +55,27 @@ describe('Post Resolvers', () => { ` let postIds: Array = [] + let libraryItem: LibraryItem + let libraryItem1: LibraryItem before(async () => { + libraryItem = await createTestLibraryItem(loginUser.id) + libraryItem1 = await createTestLibraryItem(loginUser.id) + const posts = [ { title: 'Post 1', content: 'Content 1', user: loginUser, createdAt: new Date('2021-01-01'), + libraryItemIds: [libraryItem.id], }, { title: 'Post 2', content: 'Content 2', user: loginUser, createdAt: new Date('2021-01-02'), + libraryItemIds: [libraryItem1.id], }, ] const newPosts = await createPosts(loginUser.id, posts) @@ -76,6 +85,9 @@ describe('Post Resolvers', () => { after(async () => { await deletePosts(loginUser.id, postIds) + + await deleteLibraryItemById(libraryItem.id, loginUser.id) + await deleteLibraryItemById(libraryItem1.id, loginUser.id) }) it('should return an error if the args are invalid', async () => { @@ -164,12 +176,16 @@ describe('Post Resolvers', () => { ` let postId: string + let libraryItem: LibraryItem before(async () => { + libraryItem = await createTestLibraryItem(loginUser.id) + const post = { title: 'Post', content: 'Content', user: loginUser, + libraryItemIds: [libraryItem.id], } const newPost = await createPosts(loginUser.id, [post]) @@ -178,6 +194,7 @@ describe('Post Resolvers', () => { after(async () => { await deletePosts(loginUser.id, [postId]) + await deleteLibraryItemById(libraryItem.id, loginUser.id) }) it('should return an error if the args are invalid', async () => { @@ -266,10 +283,13 @@ describe('Post Resolvers', () => { ` it('should create a post', async () => { + const libraryItem = await createTestLibraryItem(loginUser.id) + const response = await graphqlRequest(mutation, authToken, { input: { title: 'Post', content: 'Content', + libraryItemIds: [libraryItem.id], }, }) @@ -286,6 +306,7 @@ describe('Post Resolvers', () => { expect(profile?.private).to.be.false await deletePosts(loginUser.id, [postId]) + await deleteLibraryItemById(libraryItem.id, loginUser.id) }) }) @@ -308,12 +329,16 @@ describe('Post Resolvers', () => { ` let postId: string + let libraryItem: LibraryItem before(async () => { + libraryItem = await createTestLibraryItem(loginUser.id) + const post = { title: 'Post', content: 'Content', user: loginUser, + libraryItemIds: [libraryItem.id], } const newPost = await createPosts(loginUser.id, [post]) @@ -322,6 +347,7 @@ describe('Post Resolvers', () => { after(async () => { await deletePosts(loginUser.id, [postId]) + await deleteLibraryItemById(libraryItem.id, loginUser.id) }) it('should return an error if the args are invalid', async () => { @@ -399,18 +425,27 @@ describe('Post Resolvers', () => { ` let postId: string + let libraryItem: LibraryItem before(async () => { + libraryItem = await createTestLibraryItem(loginUser.id) + const post = { title: 'Post', content: 'Content', user: loginUser, + libraryItemIds: [libraryItem.id], } const newPost = await createPosts(loginUser.id, [post]) postId = newPost[0].id }) + after(async () => { + await deletePosts(loginUser.id, [postId]) + await deleteLibraryItemById(libraryItem.id, loginUser.id) + }) + it('should return an error if the args are invalid', async () => { const response = await graphqlRequest(mutation, authToken, { id: '', diff --git a/packages/db/migrations/0183.do.post.sql b/packages/db/migrations/0183.do.post.sql index a5e410761..d8b30ba33 100755 --- a/packages/db/migrations/0183.do.post.sql +++ b/packages/db/migrations/0183.do.post.sql @@ -7,7 +7,7 @@ BEGIN; CREATE TABLE omnivore.post ( id UUID PRIMARY KEY DEFAULT uuid_generate_v1mc(), user_id UUID NOT NULL REFERENCES omnivore.user(id) ON DELETE CASCADE, - library_item_ids UUID[], + library_item_ids UUID[] NOT NULL, highlight_ids UUID[], title TEXT NOT NULL, content TEXT NOT NULL, -- generated from template From 5d07e554e229affc394b2cdcebdc5d72bf365dee Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Tue, 18 Jun 2024 16:59:22 +0800 Subject: [PATCH 11/14] return author name only --- packages/api/src/entity/post.ts | 6 ++++-- packages/api/src/generated/graphql.ts | 9 +++++---- packages/api/src/generated/schema.graphql | 5 +++-- .../api/src/resolvers/function_resolvers.ts | 20 ++++++++++++------- packages/api/src/resolvers/posts/index.ts | 8 ++++++++ packages/api/src/schema.ts | 5 +++-- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts index 2890a3af9..530bf78be 100644 --- a/packages/api/src/entity/post.ts +++ b/packages/api/src/entity/post.ts @@ -1,9 +1,11 @@ import { Column, + CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm' import { User } from './user' @@ -37,9 +39,9 @@ export class Post { @Column('text', { nullable: true }) thought?: string | null - @Column('timestamptz') + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date - @Column('timestamptz') + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 7a128adaa..069fed32e 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -515,6 +515,7 @@ export type CreatePostError = { }; export enum CreatePostErrorCode { + BadRequest = 'BAD_REQUEST', Unauthorized = 'UNAUTHORIZED' } @@ -2408,12 +2409,12 @@ export type ParseResult = { export type Post = { __typename?: 'Post'; - author: User; + author: Scalars['String']; content: Scalars['String']; createdAt: Scalars['Date']; highlights?: Maybe>; id: Scalars['ID']; - libraryItems: Array
; + libraryItems?: Maybe>; ownedByViewer: Scalars['Boolean']; thought?: Maybe; thumbnail?: Maybe; @@ -7059,12 +7060,12 @@ export type PageInfoResolvers = { - author?: Resolver; + author?: Resolver; content?: Resolver; createdAt?: Resolver; highlights?: Resolver>, ParentType, ContextType>; id?: Resolver; - libraryItems?: Resolver, ParentType, ContextType>; + libraryItems?: Resolver>, ParentType, ContextType>; ownedByViewer?: Resolver; thought?: Resolver, ParentType, ContextType>; thumbnail?: Resolver, ParentType, ContextType>; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index ca5744364..849c09f9d 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -460,6 +460,7 @@ type CreatePostError { } enum CreatePostErrorCode { + BAD_REQUEST UNAUTHORIZED } @@ -1842,12 +1843,12 @@ input ParseResult { } type Post { - author: User! + author: String! content: String! createdAt: Date! highlights: [Highlight!] id: ID! - libraryItems: [Article!]! + libraryItems: [Article!] ownedByViewer: Boolean! thought: String thumbnail: String diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index bb23933e5..840476bce 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -791,26 +791,32 @@ export const functionResolvers = { recommendedAt: (recommendation: Recommendation) => recommendation.createdAt, }, Post: { - author(post: Post, _: never, ctx: ResolverContext) { - return ctx.dataLoaders.users.load(post.userId) + async author(post: Post, _: never, ctx: ResolverContext) { + const author = await ctx.dataLoaders.users.load(post.userId) + return author?.name }, ownedByViewer(post: Post, _: never, ctx: ResolverContext) { - console.log('ownedByViewer: ctx.claims?.uid', ctx.claims?.uid) return post.userId === ctx.claims?.uid }, - libraryItems( + async libraryItems( post: { libraryItemIds: string[] }, _: never, ctx: ResolverContext ) { - return ctx.dataLoaders.libraryItems.loadMany(post.libraryItemIds) + const items = await ctx.dataLoaders.libraryItems.loadMany( + post.libraryItemIds + ) + return items.filter((item) => !!item) }, - highlights( + async highlights( post: { highlightIds: string[] }, _: never, ctx: ResolverContext ) { - return ctx.dataLoaders.highlights.loadMany(post.highlightIds) + const highlights = await ctx.dataLoaders.highlights.loadMany( + post.highlightIds + ) + return highlights.filter((highlight) => !!highlight) }, }, ...resultResolveTypeResolver('Login'), diff --git a/packages/api/src/resolvers/posts/index.ts b/packages/api/src/resolvers/posts/index.ts index 38326dc1a..c65fcab7d 100644 --- a/packages/api/src/resolvers/posts/index.ts +++ b/packages/api/src/resolvers/posts/index.ts @@ -121,6 +121,14 @@ export const createPostResolver = authorized< const { title, content, highlightIds, libraryItemIds, thought, thumbnail } = input + if (libraryItemIds.length === 0) { + log.error('Invalid args', { libraryItemIds }) + + return { + errorCodes: [CreatePostErrorCode.BadRequest], + } + } + const postToCreate = { userId: uid, title, diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 3099e9b03..09840b724 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3343,11 +3343,11 @@ const schema = gql` id: ID! title: String! content: String! - author: User! + author: String! ownedByViewer: Boolean! thumbnail: String thought: String - libraryItems: [Article!]! + libraryItems: [Article!] highlights: [Highlight!] createdAt: Date! updatedAt: Date! @@ -3374,6 +3374,7 @@ const schema = gql` enum CreatePostErrorCode { UNAUTHORIZED + BAD_REQUEST } input UpdatePostInput { From ded0c04b49e893227f112022ac985d359cef1754 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 19 Jun 2024 13:59:10 +0800 Subject: [PATCH 12/14] resolve rebase conflicts --- packages/db/migrations/{0183.do.post.sql => 0184.do.post.sql} | 0 packages/db/migrations/{0183.undo.post.sql => 0184.undo.post.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/db/migrations/{0183.do.post.sql => 0184.do.post.sql} (100%) rename packages/db/migrations/{0183.undo.post.sql => 0184.undo.post.sql} (100%) diff --git a/packages/db/migrations/0183.do.post.sql b/packages/db/migrations/0184.do.post.sql similarity index 100% rename from packages/db/migrations/0183.do.post.sql rename to packages/db/migrations/0184.do.post.sql diff --git a/packages/db/migrations/0183.undo.post.sql b/packages/db/migrations/0184.undo.post.sql similarity index 100% rename from packages/db/migrations/0183.undo.post.sql rename to packages/db/migrations/0184.undo.post.sql From e93d7b646f6bc22d95138f6968919b83935a8ba1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 19 Jun 2024 17:26:21 +0800 Subject: [PATCH 13/14] resolve rebase conflicts --- packages/db/migrations/{0184.do.post.sql => 0183.do.post.sql} | 0 packages/db/migrations/{0184.undo.post.sql => 0183.undo.post.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/db/migrations/{0184.do.post.sql => 0183.do.post.sql} (100%) rename packages/db/migrations/{0184.undo.post.sql => 0183.undo.post.sql} (100%) diff --git a/packages/db/migrations/0184.do.post.sql b/packages/db/migrations/0183.do.post.sql similarity index 100% rename from packages/db/migrations/0184.do.post.sql rename to packages/db/migrations/0183.do.post.sql diff --git a/packages/db/migrations/0184.undo.post.sql b/packages/db/migrations/0183.undo.post.sql similarity index 100% rename from packages/db/migrations/0184.undo.post.sql rename to packages/db/migrations/0183.undo.post.sql From 166b338e0c60df7eac5b1b13e0ba065ef3bdacd7 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Sat, 22 Jun 2024 20:18:56 +0800 Subject: [PATCH 14/14] resolve rebase conflicts --- packages/db/migrations/{0183.do.post.sql => 0184.do.post.sql} | 0 packages/db/migrations/{0183.undo.post.sql => 0184.undo.post.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/db/migrations/{0183.do.post.sql => 0184.do.post.sql} (100%) rename packages/db/migrations/{0183.undo.post.sql => 0184.undo.post.sql} (100%) diff --git a/packages/db/migrations/0183.do.post.sql b/packages/db/migrations/0184.do.post.sql similarity index 100% rename from packages/db/migrations/0183.do.post.sql rename to packages/db/migrations/0184.do.post.sql diff --git a/packages/db/migrations/0183.undo.post.sql b/packages/db/migrations/0184.undo.post.sql similarity index 100% rename from packages/db/migrations/0183.undo.post.sql rename to packages/db/migrations/0184.undo.post.sql