diff --git a/packages/api/src/entity/post.ts b/packages/api/src/entity/post.ts new file mode 100644 index 000000000..530bf78be --- /dev/null +++ b/packages/api/src/entity/post.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} 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, nullable: true }) + highlightIds?: string[] | null + + @Column('text') + title!: string + + @Column('text') + content!: string + + @Column('text', { nullable: true }) + thumbnail?: string | null + + @Column('text', { nullable: true }) + thought?: string | null + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 43df9ea00..6a436da8b 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: Array; + 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,63 @@ export type ParseResult = { title: Scalars['String']; }; +export type Post = { + __typename?: 'Post'; + author: Scalars['String']; + 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', + NotFound = 'NOT_FOUND', + 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 +2501,8 @@ export type Query = { labels: LabelsResult; me?: Maybe; newsletterEmails: NewsletterEmailsResult; + post: PostResult; + posts: PostsResult; recentEmails: RecentEmailsResult; recentSearches: RecentSearchesResult; rules: RulesResult; @@ -2445,6 +2565,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 +3956,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', @@ -4325,6 +4484,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; @@ -4378,6 +4542,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']; @@ -4590,6 +4758,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<{}>; @@ -4824,6 +5002,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; @@ -4953,6 +5136,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']; @@ -4994,6 +5181,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; @@ -5158,6 +5348,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: {}; @@ -5340,6 +5538,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']; @@ -5703,6 +5905,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; @@ -5875,6 +6091,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; @@ -6694,6 +6924,7 @@ export type MutationResolvers>; createLabel?: Resolver>; createNewsletterEmail?: Resolver>; + createPost?: Resolver>; deleteAccount?: Resolver>; deleteDiscoverArticle?: Resolver>; deleteDiscoverFeed?: Resolver>; @@ -6703,6 +6934,7 @@ export type MutationResolvers>; deleteLabel?: Resolver>; deleteNewsletterEmail?: Resolver>; + deletePost?: Resolver>; deleteRule?: Resolver>; deleteWebhook?: Resolver>; editDiscoverFeed?: Resolver>; @@ -6753,6 +6985,7 @@ export type MutationResolvers>; updateNewsletterEmail?: Resolver>; updatePage?: Resolver>; + updatePost?: Resolver>; updateSubscription?: Resolver>; updateUser?: Resolver>; updateUserProfile?: Resolver>; @@ -6827,6 +7060,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; @@ -6858,6 +7141,8 @@ export type QueryResolvers; me?: Resolver, ParentType, ContextType>; newsletterEmails?: Resolver; + post?: Resolver>; + posts?: Resolver>; recentEmails?: Resolver; recentSearches?: Resolver; rules?: Resolver>; @@ -7696,6 +7981,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; @@ -7974,6 +8273,9 @@ export type Resolvers = { CreateNewsletterEmailError?: CreateNewsletterEmailErrorResolvers; CreateNewsletterEmailResult?: CreateNewsletterEmailResultResolvers; CreateNewsletterEmailSuccess?: CreateNewsletterEmailSuccessResolvers; + CreatePostError?: CreatePostErrorResolvers; + CreatePostResult?: CreatePostResultResolvers; + CreatePostSuccess?: CreatePostSuccessResolvers; CreateReactionError?: CreateReactionErrorResolvers; CreateReactionResult?: CreateReactionResultResolvers; CreateReactionSuccess?: CreateReactionSuccessResolvers; @@ -8011,6 +8313,9 @@ export type Resolvers = { DeleteNewsletterEmailError?: DeleteNewsletterEmailErrorResolvers; DeleteNewsletterEmailResult?: DeleteNewsletterEmailResultResolvers; DeleteNewsletterEmailSuccess?: DeleteNewsletterEmailSuccessResolvers; + DeletePostError?: DeletePostErrorResolvers; + DeletePostResult?: DeletePostResultResolvers; + DeletePostSuccess?: DeletePostSuccessResolvers; DeleteReactionError?: DeleteReactionErrorResolvers; DeleteReactionResult?: DeleteReactionResultResolvers; DeleteReactionSuccess?: DeleteReactionSuccessResolvers; @@ -8160,6 +8465,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; @@ -8307,6 +8620,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 123e4019c..fe2b268d4 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,57 @@ input ParseResult { title: String! } +type Post { + author: String! + 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 + NOT_FOUND + 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 +1928,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 +3163,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/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 43bd5cf5e..55c5ac245 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -12,6 +12,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 { @@ -151,6 +152,13 @@ import { webhookResolver, webhooksResolver, } from './index' +import { + createPostResolver, + deletePostResolver, + postResolver, + postsResolver, + updatePostResolver, +} from './posts' import { markEmailAsItemResolver, recentEmailsResolver, @@ -317,6 +325,9 @@ export const functionResolvers = { createFolderPolicy: createFolderPolicyResolver, updateFolderPolicy: updateFolderPolicyResolver, deleteFolderPolicy: deleteFolderPolicyResolver, + createPost: createPostResolver, + updatePost: updatePostResolver, + deletePost: deletePostResolver, }, Query: { me: getMeUserResolver, @@ -353,6 +364,8 @@ export const functionResolvers = { hiddenHomeSection: hiddenHomeSectionResolver, highlights: highlightsResolver, folderPolicies: folderPoliciesResolver, + posts: postsResolver, + post: postResolver, }, User: { async intercomHash(user: User) { @@ -792,6 +805,35 @@ export const functionResolvers = { name: (recommendation: Recommendation) => recommendation.group.name, recommendedAt: (recommendation: Recommendation) => recommendation.createdAt, }, + Post: { + 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) { + return post.userId === ctx.claims?.uid + }, + async libraryItems( + post: { libraryItemIds: string[] }, + _: never, + ctx: ResolverContext + ) { + const items = await ctx.dataLoaders.libraryItems.loadMany( + post.libraryItemIds + ) + return items.filter((item) => !!item) + }, + async highlights( + post: { highlightIds: string[] }, + _: never, + ctx: ResolverContext + ) { + const highlights = await ctx.dataLoaders.highlights.loadMany( + post.highlightIds + ) + return highlights.filter((highlight) => !!highlight) + }, + }, ...resultResolveTypeResolver('Login'), ...resultResolveTypeResolver('LogOut'), ...resultResolveTypeResolver('GoogleSignup'), @@ -890,4 +932,9 @@ export const functionResolvers = { ...resultResolveTypeResolver('CreateFolderPolicy'), ...resultResolveTypeResolver('UpdateFolderPolicy'), ...resultResolveTypeResolver('DeleteFolderPolicy'), + ...resultResolveTypeResolver('Posts'), + ...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 new file mode 100644 index 000000000..c65fcab7d --- /dev/null +++ b/packages/api/src/resolvers/posts/index.ts @@ -0,0 +1,236 @@ +import { Post } from '../../entity/post' +import { + CreatePostError, + CreatePostErrorCode, + CreatePostSuccess, + DeletePostError, + DeletePostErrorCode, + DeletePostSuccess, + MutationCreatePostArgs, + MutationDeletePostArgs, + MutationUpdatePostArgs, + PostEdge, + PostErrorCode, + PostResult, + PostsErrorCode, + PostsResult, + QueryPostArgs, + QueryPostsArgs, + ResolverFn, + UpdatePostError, + UpdatePostErrorCode, + UpdatePostSuccess, +} from '../../generated/graphql' +import { + createPublicPost, + deletePosts, + findPublicPostById, + findPublicPostsByUserId, + updatePost, +} from '../../services/post' +import { Merge } from '../../util' +import { authorized } from '../../utils/gql-utils' +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 findPublicPostsByUserId(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, + }, + } +} + +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, + } +} + +export const createPostResolver = authorized< + Merge, + CreatePostError, + MutationCreatePostArgs +>(async (_, { input }, { uid, log }) => { + 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, + content, + libraryItemIds, + highlightIds: highlightIds || 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, + } +}) + +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 || libraryItemIds === 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, + } +}) + +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/schema.ts b/packages/api/src/schema.ts index 230f6c1ec..9cb9343e9 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3340,6 +3340,121 @@ const schema = gql` afterDays: Int } + type Post { + id: ID! + title: String! + content: String! + author: String! + ownedByViewer: Boolean! + thumbnail: String + thought: String + libraryItems: [Article!] + highlights: [Highlight!] + createdAt: Date! + updatedAt: Date! + } + + input CreatePostInput { + title: String! @sanitize(minLength: 1, maxLength: 255) + content: String! @sanitize(minLength: 1) + 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 @sanitize(minLength: 1, maxLength: 255) + content: String @sanitize(minLength: 1) + 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 + NOT_FOUND + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -3354,24 +3469,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! @@ -3381,11 +3482,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 @@ -3473,6 +3570,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 @@ -3482,17 +3582,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! @@ -3548,6 +3637,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..6a2be8715 --- /dev/null +++ b/packages/api/src/services/post.ts @@ -0,0 +1,88 @@ +import { DeepPartial } from 'typeorm' +import { Post } from '../entity/post' +import { Profile } from '../entity/profile' +import { authTrx, getRepository } from '../repository' + +export const findPublicPostsByUserId = 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 +} + +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> +) => { + return authTrx(async (trx) => trx.getRepository(Post).save(posts), { + uid: userId, + }) +} + +export const deletePosts = async (userId: string, postIds: string[]) => { + return authTrx(async (trx) => trx.getRepository(Post).delete(postIds), { + uid: userId, + }) +} + +export const findPublicPostById = async (id: string) => { + return getRepository(Post).findOneBy({ + id, + user: { + profile: { + private: false, + }, + }, + }) +} + +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/src/services/profile.ts b/packages/api/src/services/profile.ts index 6547ab09f..b444ae43f 100644 --- a/packages/api/src/services/profile.ts +++ b/packages/api/src/services/profile.ts @@ -1,7 +1,21 @@ 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 } }) } + +export const updateProfile = async ( + userId: string, + profile: Partial +) => { + 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 new file mode 100644 index 000000000..8d9c8f536 --- /dev/null +++ b/packages/api/test/resolvers/post.test.ts @@ -0,0 +1,492 @@ +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, + findPublicPostById, +} from '../../src/services/post' +import { findProfile, updateProfile } from '../../src/services/profile' +import { deleteUser } from '../../src/services/user' +import { createTestLibraryItem, createTestUser } from '../db' +import { generateFakeUuid, 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 + } + } + } + ` + + 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) + + postIds = newPosts.map((post) => post.id) + }) + + 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 () => { + 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', () => { + 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 user profile is public', () => { + before(async () => { + 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, + 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 + .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 empty array', async () => { + const response = await graphqlRequest(query, '', { + first: 10, + userId: loginUser.id, + }) + + expect(response.body.data.posts.edges).to.be.empty + }) + }) + }) + }) + + describe('postResolver', () => { + const query = ` + query Post($id: ID!) { + post(id: $id) { + ... on PostSuccess { + post { + id + title + content + ownedByViewer + } + } + ... on PostError { + errorCodes + } + } + } + ` + + 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(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']) + }) + }) + }) + }) + + 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 libraryItem = await createTestLibraryItem(loginUser.id) + + const response = await graphqlRequest(mutation, authToken, { + input: { + title: 'Post', + content: 'Content', + libraryItemIds: [libraryItem.id], + }, + }) + + 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]) + await deleteLibraryItemById(libraryItem.id, loginUser.id) + }) + }) + + describe('updatePostResolver', () => { + const mutation = ` + mutation UpdatePost($input: UpdatePostInput!) { + updatePost(input: $input) { + ... on UpdatePostSuccess { + post { + id + title + content + } + } + ... on UpdatePostError { + errorCodes + } + } + } + ` + + 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, { + 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') + }) + }) + + describe('deletePostResolver', () => { + const mutation = ` + mutation DeletePost($id: ID!) { + deletePost(id: $id) { + ... on DeletePostSuccess { + success + } + ... on DeletePostError { + errorCodes + } + } + } + ` + + 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: '', + }) + + 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 + }) + }) +}) diff --git a/packages/db/migrations/0184.do.post.sql b/packages/db/migrations/0184.do.post.sql new file mode 100755 index 000000000..d8b30ba33 --- /dev/null +++ b/packages/db/migrations/0184.do.post.sql @@ -0,0 +1,42 @@ +-- 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[] NOT NULL, + 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); + +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/0184.undo.post.sql b/packages/db/migrations/0184.undo.post.sql new file mode 100755 index 000000000..f49547e78 --- /dev/null +++ b/packages/db/migrations/0184.undo.post.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: post +-- Description: Create a post table + +BEGIN; + +DROP TABLE omnivore.post; + +COMMIT;