Browse Source

Reacting to posts, updating posts

Leszek Wiesner 3 years ago
parent
commit
b7d692906c

+ 84 - 6
query-node/mappings/forum.ts

@@ -36,10 +36,18 @@ import {
   CategoryMembershipOfModeratorUpdatedEvent,
   PostModeratedEvent,
   PostStatusModerated,
+  ForumPostReaction,
+  PostReaction,
+  PostReactedEvent,
+  PostReactionResult,
+  PostReactionResultCancel,
+  PostReactionResultValid,
+  PostReactionResultInvalid,
+  PostTextUpdatedEvent,
 } from 'query-node/dist/model'
 import { Forum } from './generated/types'
-import { PrivilegedActor } from '@joystream/types/augment/all'
-import { ForumPostMetadata } from '@joystream/metadata-protobuf'
+import { PostReactionId, PrivilegedActor } from '@joystream/types/augment/all'
+import { ForumPostMetadata, ForumPostReaction as SupportedPostReactions } from '@joystream/metadata-protobuf'
 import { Not, In } from 'typeorm'
 
 async function getCategory(db: DatabaseManager, categoryId: string, relations?: string[]): Promise<ForumCategory> {
@@ -98,6 +106,27 @@ async function getActorWorker(db: DatabaseManager, actor: PrivilegedActor): Prom
   return worker
 }
 
+// Get standarized PostReactionResult by PostReactionId
+function parseReaction(reactionId: PostReactionId): typeof PostReactionResult {
+  switch (reactionId.toNumber()) {
+    case SupportedPostReactions.Reaction.CANCEL: {
+      return new PostReactionResultCancel()
+    }
+    case SupportedPostReactions.Reaction.LIKE: {
+      const result = new PostReactionResultValid()
+      result.reaction = PostReaction.LIKE
+      result.reactionId = reactionId.toNumber()
+      return result
+    }
+    default: {
+      console.warn(`Invalid post reaction id: ${reactionId.toString()}`)
+      const result = new PostReactionResultInvalid()
+      result.reactionId = reactionId.toNumber()
+      return result
+    }
+  }
+}
+
 export async function forum_CategoryCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   const [categoryId, parentCategoryId, titleBytes, descriptionBytes] = new Forum.CategoryCreatedEvent(event_).params
   const eventTime = new Date(event_.blockTimestamp)
@@ -454,14 +483,63 @@ export async function forum_PostModerated(db: DatabaseManager, event_: Substrate
   await db.save<ForumPost>(post)
 }
 
-export async function forum_PostDeleted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+export async function forum_PostReacted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
+  const [userId, postId, reactionId] = new Forum.PostReactedEvent(event_).params
+  const eventTime = new Date(event_.blockTimestamp)
+
+  const reactionResult = parseReaction(reactionId)
+  const postReactedEvent = new PostReactedEvent({
+    ...genericEventFields(event_),
+    post: new ForumPost({ id: postId.toString() }),
+    reactingMember: new Membership({ id: userId.toString() }),
+    reactionResult,
+  })
+  await db.save<PostReactedEvent>(postReactedEvent)
+
+  const existingUserPostReaction = await db.get(ForumPostReaction, {
+    where: { post: { id: postId.toString() }, member: { id: userId.toString() } },
+  })
+
+  if (reactionResult.isTypeOf === 'PostReactionResultValid') {
+    const { reaction } = reactionResult as PostReactionResultValid
+
+    if (existingUserPostReaction) {
+      existingUserPostReaction.updatedAt = eventTime
+      existingUserPostReaction.reaction = reaction
+      await db.save<ForumPostReaction>(existingUserPostReaction)
+    } else {
+      const newUserPostReaction = new ForumPostReaction({
+        createdAt: eventTime,
+        updatedAt: eventTime,
+        post: new ForumPost({ id: postId.toString() }),
+        member: new Membership({ id: userId.toString() }),
+        reaction,
+      })
+      await db.save<ForumPostReaction>(newUserPostReaction)
+    }
+  } else if (existingUserPostReaction) {
+    await db.remove<ForumPostReaction>(existingUserPostReaction)
+  }
 }
 
 export async function forum_PostTextUpdated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+  const [postId, , , , newTextBytes] = new Forum.PostTextUpdatedEvent(event_).params
+  const eventTime = new Date(event_.blockTimestamp)
+  const post = await getPost(db, postId.toString())
+
+  const postTextUpdatedEvent = new PostTextUpdatedEvent({
+    ...genericEventFields(event_),
+    post,
+    newText: bytesToString(newTextBytes),
+  })
+
+  await db.save<PostTextUpdatedEvent>(postTextUpdatedEvent)
+
+  post.updatedAt = eventTime
+  post.text = bytesToString(newTextBytes)
+  await db.save<ForumPost>(post)
 }
 
-export async function forum_PostReacted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
+export async function forum_PostDeleted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   // TODO
 }

+ 17 - 2
query-node/schemas/forumEvents.graphql

@@ -362,6 +362,21 @@ type PostTextUpdatedEvent @entity {
   # Only author can edit the post, so no actor context required
 }
 
+type PostReactionResultCancel @variant {
+  _phantom: Int
+}
+
+type PostReactionResultValid @variant {
+  reaction: PostReaction!
+  reactionId: Int!
+}
+
+type PostReactionResultInvalid @variant {
+  reactionId: Int!
+}
+
+union PostReactionResult = PostReactionResultCancel | PostReactionResultValid | PostReactionResultInvalid
+
 type PostReactedEvent @entity {
   ### GENERIC DATA ###
 
@@ -385,8 +400,8 @@ type PostReactedEvent @entity {
   "The post beeing reacted to"
   post: ForumPost!
 
-  "The reaction. Overrides the previous reaction. If empty - cancels the previous reaction."
-  reaction: PostReaction
+  "The reaction result - new valid reaction, cancelation of previous reaction or invalid reaction (which also cancels the previous one)"
+  reactionResult: PostReactionResult!
 
   "The member reacting to the post"
   reactingMember: Membership!

+ 24 - 0
tests/integration-tests/src/QueryNodeApi.ts

@@ -239,6 +239,14 @@ import {
   GetPostModeratedEventsByEventIdsQuery,
   GetPostModeratedEventsByEventIdsQueryVariables,
   GetPostModeratedEventsByEventIds,
+  PostReactedEventFieldsFragment,
+  GetPostReactedEventsByEventIdsQuery,
+  GetPostReactedEventsByEventIdsQueryVariables,
+  GetPostReactedEventsByEventIds,
+  PostTextUpdatedEventFieldsFragment,
+  GetPostTextUpdatedEventsByEventIdsQuery,
+  GetPostTextUpdatedEventsByEventIdsQueryVariables,
+  GetPostTextUpdatedEventsByEventIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -881,4 +889,20 @@ export class QueryNodeApi {
       GetPostModeratedEventsByEventIdsQueryVariables
     >(GetPostModeratedEventsByEventIds, { eventIds }, 'postModeratedEvents')
   }
+
+  public async getPostReactedEvents(events: EventDetails[]): Promise<PostReactedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostReactedEventsByEventIdsQuery,
+      GetPostReactedEventsByEventIdsQueryVariables
+    >(GetPostReactedEventsByEventIds, { eventIds }, 'postReactedEvents')
+  }
+
+  public async getPostTextUpdatedEvents(events: EventDetails[]): Promise<PostTextUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetPostTextUpdatedEventsByEventIdsQuery,
+      GetPostTextUpdatedEventsByEventIdsQueryVariables
+    >(GetPostTextUpdatedEventsByEventIds, { eventIds }, 'postTextUpdatedEvents')
+  }
 }

+ 114 - 0
tests/integration-tests/src/fixtures/forum/ReactToPostsFixture.ts

@@ -0,0 +1,114 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, PostPath } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostReactedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import _ from 'lodash'
+import { MemberId } from '@joystream/types/common'
+import { ForumPostReaction } from '@joystream/metadata-protobuf'
+import { PostReaction } from '../../graphql/generated/schema'
+
+export type PostReactionParams = PostPath & {
+  reactionId: number
+  asMember: MemberId
+}
+
+export class ReactToPostsFixture extends StandardizedFixture {
+  protected reactions: PostReactionParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, reactions: PostReactionParams[]) {
+    super(api, query)
+    this.reactions = reactions
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return await Promise.all(
+      this.reactions.map(async ({ asMember }) =>
+        (await this.api.query.members.membershipById(asMember)).controller_account.toString()
+      )
+    )
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.reactions.map((r) =>
+      this.api.tx.forum.reactPost(r.asMember, r.categoryId, r.threadId, r.postId, r.reactionId)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostReacted')
+  }
+
+  protected getExpectedReaction(reactionId: number): PostReaction | null {
+    if (reactionId === ForumPostReaction.Reaction.LIKE) {
+      return PostReaction.Like
+    }
+
+    return null
+  }
+
+  protected assertQueriedPostsAreValid(qPosts: ForumPostFieldsFragment[]): void {
+    // Check against lastest reaction per user per post
+    _.uniqBy([...this.reactions].reverse(), (v) => `${v.postId.toString()}:${v.asMember.toString()}`).map(
+      (reaction) => {
+        const expectedReaction = this.getExpectedReaction(reaction.reactionId)
+        const qPost = qPosts.find((p) => p.id === reaction.postId.toString())
+        Utils.assert(qPost, 'Query node: Post not found')
+
+        const qReaction = qPost.reactions.find((r) => r.member.id === reaction.asMember.toString())
+        if (expectedReaction) {
+          Utils.assert(
+            qReaction,
+            `Query node: Expected post reaction by member ${reaction.asMember.toString()} not found!`
+          )
+          assert.equal(qReaction.reaction, expectedReaction)
+        } else {
+          assert.isUndefined(qReaction)
+        }
+      }
+    )
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostReactedEventFieldsFragment, i: number): void {
+    const { postId, asMember, reactionId } = this.reactions[i]
+    const expectedReaction = this.getExpectedReaction(reactionId)
+    assert.equal(qEvent.post.id, postId.toString())
+    assert.equal(qEvent.reactingMember.id, asMember.toString())
+    if (reactionId && expectedReaction === null) {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultInvalid',
+        'Query node: Invalid reaction result'
+      )
+      assert.equal(qEvent.reactionResult.reactionId, reactionId)
+    } else if (!reactionId) {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultCancel',
+        'Query node: Invalid reaction result'
+      )
+    } else {
+      Utils.assert(
+        qEvent.reactionResult.__typename === 'PostReactionResultValid',
+        'Query node: Invalid reaction result'
+      )
+      assert.equal(qEvent.reactionResult.reaction, expectedReaction)
+      assert.equal(qEvent.reactionResult.reactionId, reactionId)
+    }
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    await this.query.tryQueryWithTimeout(
+      () => this.query.getPostReactedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qPosts = await this.query.getPostsByIds(this.reactions.map((r) => r.postId))
+    this.assertQueriedPostsAreValid(qPosts)
+  }
+}

+ 96 - 0
tests/integration-tests/src/fixtures/forum/UpdatePostsTextFixture.ts

@@ -0,0 +1,96 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails, MemberContext, PostPath } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import { ForumPostFieldsFragment, PostTextUpdatedEventFieldsFragment } from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import _ from 'lodash'
+import { PostTextUpdatedEvent } from '../../graphql/generated/schema'
+
+export type PostTextUpdate = PostPath & {
+  newText: string
+}
+
+export class UpdatePostsTextFixture extends StandardizedFixture {
+  protected postAuthors: MemberContext[] = []
+  protected updates: PostTextUpdate[]
+
+  public constructor(api: Api, query: QueryNodeApi, updates: PostTextUpdate[]) {
+    super(api, query)
+    this.updates = updates
+  }
+
+  protected async loadAuthors(): Promise<void> {
+    this.postAuthors = await Promise.all(
+      this.updates.map(async (u) => {
+        const post = await this.api.query.forum.postById(u.threadId, u.postId)
+        const member = await this.api.query.members.membershipById(post.author_id)
+        return { account: member.controller_account.toString(), memberId: post.author_id }
+      })
+    )
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.postAuthors.map((a) => a.account)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.updates.map((u, i) =>
+      this.api.tx.forum.editPostText(this.postAuthors[i].memberId, u.categoryId, u.threadId, u.postId, u.newText)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveForumEventDetails(result, 'PostTextUpdated')
+  }
+
+  public async execute(): Promise<void> {
+    await this.loadAuthors()
+    await super.execute()
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ForumPostFieldsFragment[],
+    qEvents: PostTextUpdatedEventFieldsFragment[]
+  ): void {
+    // Check update events are included in posts One-to-Many relation
+    this.events.forEach((e, i) => {
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const qPost = qPosts.find((p) => p.id === this.updates[i].postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.include(
+        qPost.edits.map((e) => e.id),
+        qEvent.id
+      )
+    })
+
+    // Check post text against lastest update per post
+    _.uniqBy([...this.updates].reverse(), (v) => v.postId).map((update) => {
+      const qPost = qPosts.find((p) => p.id === update.postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.equal(qPost.text, update.newText)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: PostTextUpdatedEvent, i: number): void {
+    const { postId, newText } = this.updates[i]
+    assert.equal(qEvent.post.id, postId.toString())
+    assert.equal(qEvent.newText, newText)
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getPostTextUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the threads
+    const qPosts = await this.query.getPostsByIds(this.updates.map((u) => u.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 2 - 0
tests/integration-tests/src/fixtures/forum/index.ts

@@ -12,3 +12,5 @@ export { UpdateCategoryModeratorsFixture, CategoryModeratorStatusUpdate } from '
 export { ModerateThreadsFixture, ThreadModerationInput } from './ModerateThreadsFixture'
 export { ModeratePostsFixture, PostModerationInput } from './ModeratePostsFixture'
 export { InitializeForumFixture, InitializeForumConfig } from './InitializeForumFixture'
+export { ReactToPostsFixture, PostReactionParams } from './ReactToPostsFixture'
+export { UpdatePostsTextFixture, PostTextUpdate } from './UpdatePostsTextFixture'

+ 75 - 2
tests/integration-tests/src/flows/forum/posts.ts

@@ -1,7 +1,16 @@
 import { FlowProps } from '../../Flow'
 import Debugger from 'debug'
 import { FixtureRunner } from '../../Fixture'
-import { AddPostsFixture, InitializeForumFixture, PostParams } from '../../fixtures/forum'
+import {
+  AddPostsFixture,
+  InitializeForumFixture,
+  PostParams,
+  PostReactionParams,
+  PostTextUpdate,
+  ReactToPostsFixture,
+  UpdatePostsTextFixture,
+} from '../../fixtures/forum'
+import { ForumPostReaction } from '@joystream/metadata-protobuf'
 
 export default async function threads({ api, query }: FlowProps): Promise<void> {
   const debug = Debugger(`flow:threads`)
@@ -75,7 +84,71 @@ export default async function threads({ api, query }: FlowProps): Promise<void>
   const addRepliesRunner = new FixtureRunner(addRepliesFixture)
   await addRepliesRunner.run()
 
-  await Promise.all([addPostsFixture.runQueryNodeChecks(), addRepliesRunner.runQueryNodeChecks()])
+  // Post reactions
+  const postReactions: PostReactionParams[] = [
+    {
+      ...threadPaths[0],
+      postId: postIds[0],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[0],
+    },
+    {
+      ...threadPaths[1],
+      postId: postIds[1],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[1],
+    },
+    {
+      ...threadPaths[1],
+      postId: postIds[1],
+      reactionId: 0, // Cancel previous one
+      asMember: memberIds[1],
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      reactionId: ForumPostReaction.Reaction.LIKE,
+      asMember: memberIds[2],
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      reactionId: 999, // Cancel previous one by providing invalid id
+      asMember: memberIds[2],
+    },
+  ]
+  const reactToPostsFixture = new ReactToPostsFixture(api, query, postReactions)
+  const reactToPostsRunner = new FixtureRunner(reactToPostsFixture)
+  await reactToPostsRunner.run()
+
+  // Run compound query node checks
+  await Promise.all([
+    addPostsFixture.runQueryNodeChecks(),
+    addRepliesRunner.runQueryNodeChecks(),
+    reactToPostsRunner.runQueryNodeChecks(),
+  ])
+
+  // Post text updates
+  const postTextUpdates: PostTextUpdate[] = [
+    {
+      ...threadPaths[0],
+      postId: postIds[0],
+      newText: 'New post 0 text',
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      newText: 'New post 2 text',
+    },
+    {
+      ...threadPaths[2],
+      postId: postIds[2],
+      newText: '',
+    },
+  ]
+  const updatePostsTextFixture = new UpdatePostsTextFixture(api, query, postTextUpdates)
+  const updatePostsTextRunner = new FixtureRunner(updatePostsTextFixture)
+  await updatePostsTextRunner.runWithQueryNodeChecks()
 
   debug('Done')
 }

+ 96 - 0
tests/integration-tests/src/graphql/generated/queries.ts

@@ -319,6 +319,46 @@ export type GetPostModeratedEventsByEventIdsQueryVariables = Types.Exact<{
 
 export type GetPostModeratedEventsByEventIdsQuery = { postModeratedEvents: Array<PostModeratedEventFieldsFragment> }
 
+export type PostReactedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  post: { id: string }
+  reactionResult:
+    | { __typename: 'PostReactionResultCancel' }
+    | { __typename: 'PostReactionResultValid'; reaction: Types.PostReaction; reactionId: number }
+    | { __typename: 'PostReactionResultInvalid'; reactionId: number }
+  reactingMember: { id: string }
+}
+
+export type GetPostReactedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostReactedEventsByEventIdsQuery = { postReactedEvents: Array<PostReactedEventFieldsFragment> }
+
+export type PostTextUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inBlock: number
+  network: Types.Network
+  inExtrinsic?: Types.Maybe<string>
+  indexInBlock: number
+  newText: string
+  post: { id: string }
+}
+
+export type GetPostTextUpdatedEventsByEventIdsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetPostTextUpdatedEventsByEventIdsQuery = {
+  postTextUpdatedEvents: Array<PostTextUpdatedEventFieldsFragment>
+}
+
 export type MemberMetadataFieldsFragment = { name?: Types.Maybe<string>; about?: Types.Maybe<string> }
 
 export type MembershipFieldsFragment = {
@@ -1529,6 +1569,46 @@ export const PostModeratedEventFields = gql`
     }
   }
 `
+export const PostReactedEventFields = gql`
+  fragment PostReactedEventFields on PostReactedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    reactionResult {
+      __typename
+      ... on PostReactionResultValid {
+        reaction
+        reactionId
+      }
+      ... on PostReactionResultInvalid {
+        reactionId
+      }
+    }
+    reactingMember {
+      id
+    }
+  }
+`
+export const PostTextUpdatedEventFields = gql`
+  fragment PostTextUpdatedEventFields on PostTextUpdatedEvent {
+    id
+    createdAt
+    inBlock
+    network
+    inExtrinsic
+    indexInBlock
+    post {
+      id
+    }
+    newText
+  }
+`
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -2456,6 +2536,22 @@ export const GetPostModeratedEventsByEventIds = gql`
   }
   ${PostModeratedEventFields}
 `
+export const GetPostReactedEventsByEventIds = gql`
+  query getPostReactedEventsByEventIds($eventIds: [ID!]) {
+    postReactedEvents(where: { id_in: $eventIds }) {
+      ...PostReactedEventFields
+    }
+  }
+  ${PostReactedEventFields}
+`
+export const GetPostTextUpdatedEventsByEventIds = gql`
+  query getPostTextUpdatedEventsByEventIds($eventIds: [ID!]) {
+    postTextUpdatedEvents(where: { id_in: $eventIds }) {
+      ...PostTextUpdatedEventFields
+    }
+  }
+  ${PostTextUpdatedEventFields}
+`
 export const GetMemberById = gql`
   query getMemberById($id: ID!) {
     membershipByUniqueInput(where: { id: $id }) {

+ 165 - 8
tests/integration-tests/src/graphql/generated/schema.ts

@@ -6302,8 +6302,8 @@ export type PostReactedEvent = BaseGraphQlObject & {
   indexInBlock: Scalars['Int']
   post: ForumPost
   postId: Scalars['String']
-  /** The reaction. Overrides the previous reaction. If empty - cancels the previous reaction. */
-  reaction?: Maybe<PostReaction>
+  /** The reaction result (new reaction, cancelation of previous reaction or invalid reaction (no effect)) */
+  reactionResult: PostReactionResult
   reactingMember: Membership
   reactingMemberId: Scalars['String']
 }
@@ -6320,7 +6320,7 @@ export type PostReactedEventCreateInput = {
   network: Network
   indexInBlock: Scalars['Float']
   post: Scalars['ID']
-  reaction?: Maybe<PostReaction>
+  reactionResult: Scalars['JSONObject']
   reactingMember: Scalars['ID']
 }
 
@@ -6346,8 +6346,6 @@ export enum PostReactedEventOrderByInput {
   IndexInBlockDesc = 'indexInBlock_DESC',
   PostAsc = 'post_ASC',
   PostDesc = 'post_DESC',
-  ReactionAsc = 'reaction_ASC',
-  ReactionDesc = 'reaction_DESC',
   ReactingMemberAsc = 'reactingMember_ASC',
   ReactingMemberDesc = 'reactingMember_DESC',
 }
@@ -6358,7 +6356,7 @@ export type PostReactedEventUpdateInput = {
   network?: Maybe<Network>
   indexInBlock?: Maybe<Scalars['Float']>
   post?: Maybe<Scalars['ID']>
-  reaction?: Maybe<PostReaction>
+  reactionResult?: Maybe<Scalars['JSONObject']>
   reactingMember?: Maybe<Scalars['ID']>
 }
 
@@ -6408,8 +6406,7 @@ export type PostReactedEventWhereInput = {
   indexInBlock_in?: Maybe<Array<Scalars['Int']>>
   post_eq?: Maybe<Scalars['ID']>
   post_in?: Maybe<Array<Scalars['ID']>>
-  reaction_eq?: Maybe<PostReaction>
-  reaction_in?: Maybe<Array<PostReaction>>
+  reactionResult_json?: Maybe<Scalars['JSONObject']>
   reactingMember_eq?: Maybe<Scalars['ID']>
   reactingMember_in?: Maybe<Array<Scalars['ID']>>
   post?: Maybe<ForumPostWhereInput>
@@ -6426,6 +6423,166 @@ export enum PostReaction {
   Like = 'LIKE',
 }
 
+export type PostReactionResult = PostReactionResultCancel | PostReactionResultValid | PostReactionResultInvalid
+
+export type PostReactionResultCancel = {
+  phantom?: Maybe<Scalars['Int']>
+}
+
+export type PostReactionResultCancelCreateInput = {
+  phantom?: Maybe<Scalars['Float']>
+}
+
+export type PostReactionResultCancelUpdateInput = {
+  phantom?: Maybe<Scalars['Float']>
+}
+
+export type PostReactionResultCancelWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  phantom_eq?: Maybe<Scalars['Int']>
+  phantom_gt?: Maybe<Scalars['Int']>
+  phantom_gte?: Maybe<Scalars['Int']>
+  phantom_lt?: Maybe<Scalars['Int']>
+  phantom_lte?: Maybe<Scalars['Int']>
+  phantom_in?: Maybe<Array<Scalars['Int']>>
+  AND?: Maybe<Array<PostReactionResultCancelWhereInput>>
+  OR?: Maybe<Array<PostReactionResultCancelWhereInput>>
+}
+
+export type PostReactionResultCancelWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
+export type PostReactionResultInvalid = {
+  reactionId: Scalars['Int']
+}
+
+export type PostReactionResultInvalidCreateInput = {
+  reactionId: Scalars['Float']
+}
+
+export type PostReactionResultInvalidUpdateInput = {
+  reactionId?: Maybe<Scalars['Float']>
+}
+
+export type PostReactionResultInvalidWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  reactionId_eq?: Maybe<Scalars['Int']>
+  reactionId_gt?: Maybe<Scalars['Int']>
+  reactionId_gte?: Maybe<Scalars['Int']>
+  reactionId_lt?: Maybe<Scalars['Int']>
+  reactionId_lte?: Maybe<Scalars['Int']>
+  reactionId_in?: Maybe<Array<Scalars['Int']>>
+  AND?: Maybe<Array<PostReactionResultInvalidWhereInput>>
+  OR?: Maybe<Array<PostReactionResultInvalidWhereInput>>
+}
+
+export type PostReactionResultInvalidWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
+export type PostReactionResultValid = {
+  reaction: PostReaction
+  reactionId: Scalars['Int']
+}
+
+export type PostReactionResultValidCreateInput = {
+  reaction: PostReaction
+  reactionId: Scalars['Float']
+}
+
+export type PostReactionResultValidUpdateInput = {
+  reaction?: Maybe<PostReaction>
+  reactionId?: Maybe<Scalars['Float']>
+}
+
+export type PostReactionResultValidWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  reaction_eq?: Maybe<PostReaction>
+  reaction_in?: Maybe<Array<PostReaction>>
+  reactionId_eq?: Maybe<Scalars['Int']>
+  reactionId_gt?: Maybe<Scalars['Int']>
+  reactionId_gte?: Maybe<Scalars['Int']>
+  reactionId_lt?: Maybe<Scalars['Int']>
+  reactionId_lte?: Maybe<Scalars['Int']>
+  reactionId_in?: Maybe<Array<Scalars['Int']>>
+  AND?: Maybe<Array<PostReactionResultValidWhereInput>>
+  OR?: Maybe<Array<PostReactionResultValidWhereInput>>
+}
+
+export type PostReactionResultValidWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type PostsByTextFtsOutput = {
   item: PostsByTextSearchResult
   rank: Scalars['Float']

+ 50 - 0
tests/integration-tests/src/graphql/queries/forumEvents.graphql

@@ -278,3 +278,53 @@ query getPostModeratedEventsByEventIds($eventIds: [ID!]) {
     ...PostModeratedEventFields
   }
 }
+
+fragment PostReactedEventFields on PostReactedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  reactionResult {
+    __typename
+    ... on PostReactionResultValid {
+      reaction
+      reactionId
+    }
+    ... on PostReactionResultInvalid {
+      reactionId
+    }
+  }
+  reactingMember {
+    id
+  }
+}
+
+query getPostReactedEventsByEventIds($eventIds: [ID!]) {
+  postReactedEvents(where: { id_in: $eventIds }) {
+    ...PostReactedEventFields
+  }
+}
+
+fragment PostTextUpdatedEventFields on PostTextUpdatedEvent {
+  id
+  createdAt
+  inBlock
+  network
+  inExtrinsic
+  indexInBlock
+  post {
+    id
+  }
+  newText
+}
+
+query getPostTextUpdatedEventsByEventIds($eventIds: [ID!]) {
+  postTextUpdatedEvents(where: { id_in: $eventIds }) {
+    ...PostTextUpdatedEventFields
+  }
+}