Browse Source

Merge branch 'olympia-proposals-mappings' into olympia-forum-mappings-part-2

Leszek Wiesner 3 years ago
parent
commit
96a637fa70
33 changed files with 1724 additions and 305 deletions
  1. 1 1
      README.md
  2. 2 2
      docker-compose.yml
  3. 96 0
      metadata-protobuf/compiled/index.d.ts
  4. 210 0
      metadata-protobuf/compiled/index.js
  5. 6 0
      metadata-protobuf/proto/ProposalsDiscussion.proto
  6. 2 3
      pioneer/packages/joy-election/src/VoteForm.tsx
  7. 2 0
      query-node/manifest.yml
  8. 8 1
      query-node/mappings/common.ts
  9. 18 2
      query-node/mappings/proposals.ts
  10. 149 14
      query-node/mappings/proposalsDiscussion.ts
  11. 5 2
      query-node/schemas/proposalDiscussion.graphql
  12. 0 24
      query-node/schemas/proposalDiscussionEvents.graphql
  13. 23 0
      tests/integration-tests/src/Api.ts
  14. 82 0
      tests/integration-tests/src/QueryNodeApi.ts
  15. 2 0
      tests/integration-tests/src/consts.ts
  16. 1 0
      tests/integration-tests/src/fixtures/proposals/CreateProposalsFixture.ts
  17. 103 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/ChangeThreadsModeFixture.ts
  18. 122 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/CreatePostsFixture.ts
  19. 85 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/DeletePostsFixture.ts
  20. 81 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/UpdatePostsFixture.ts
  21. 4 0
      tests/integration-tests/src/fixtures/proposalsDiscussion/index.ts
  22. 127 0
      tests/integration-tests/src/flows/proposalsDiscussion/index.ts
  23. 346 0
      tests/integration-tests/src/graphql/generated/queries.ts
  24. 17 157
      tests/integration-tests/src/graphql/generated/schema.ts
  25. 6 0
      tests/integration-tests/src/graphql/queries/proposals.graphql
  26. 75 0
      tests/integration-tests/src/graphql/queries/proposalsDiscussion.graphql
  27. 82 0
      tests/integration-tests/src/graphql/queries/proposalsDiscussionEvents.graphql
  28. 3 1
      tests/integration-tests/src/scenarios/full.ts
  29. 9 3
      tests/integration-tests/src/scenarios/proposals.ts
  30. 8 0
      tests/integration-tests/src/scenarios/proposalsDiscussion.ts
  31. 10 0
      tests/integration-tests/src/types.ts
  32. 4 0
      tests/integration-tests/src/utils.ts
  33. 35 95
      yarn.lock

+ 1 - 1
README.md

@@ -12,7 +12,7 @@ functionality to support the [various roles](https://www.joystream.org/roles) th
 The following tools are required for building, testing and contributing to this repo:
 
 - [Rust](https://www.rust-lang.org/tools/install) toolchain - _required_
-- [nodejs](https://nodejs.org/) v12.x - _required_
+- [nodejs](https://nodejs.org/) v14.x - _required_
 - [yarn classic](https://classic.yarnpkg.com/en/docs/install) package manager v1.22.x- _required_
 - [docker](https://www.docker.com/get-started) and docker-compose - _optional_
 - [ansible](https://www.ansible.com/) - _optional_

+ 2 - 2
docker-compose.yml

@@ -89,7 +89,7 @@ services:
 
   graphql-server-mnt:
     <<: *graphql-server
-    image: node:12
+    image: node:14
     build: .
     volumes:
       - type: bind
@@ -121,7 +121,7 @@ services:
 
   processor-mnt:
     <<: *processor
-    image: node:12
+    image: node:14
     build: .
     volumes:
       - type: bind

+ 96 - 0
metadata-protobuf/compiled/index.d.ts

@@ -927,6 +927,102 @@ export class PlaylistMetadata implements IPlaylistMetadata {
     public toJSON(): { [k: string]: any };
 }
 
+/** Properties of a ProposalsDiscussionPostMetadata. */
+export interface IProposalsDiscussionPostMetadata {
+
+    /** ProposalsDiscussionPostMetadata text */
+    text?: (string|null);
+
+    /** ProposalsDiscussionPostMetadata repliesTo */
+    repliesTo?: (number|null);
+}
+
+/** Represents a ProposalsDiscussionPostMetadata. */
+export class ProposalsDiscussionPostMetadata implements IProposalsDiscussionPostMetadata {
+
+    /**
+     * Constructs a new ProposalsDiscussionPostMetadata.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IProposalsDiscussionPostMetadata);
+
+    /** ProposalsDiscussionPostMetadata text. */
+    public text: string;
+
+    /** ProposalsDiscussionPostMetadata repliesTo. */
+    public repliesTo: number;
+
+    /**
+     * Creates a new ProposalsDiscussionPostMetadata instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ProposalsDiscussionPostMetadata instance
+     */
+    public static create(properties?: IProposalsDiscussionPostMetadata): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @param message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IProposalsDiscussionPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message, length delimited. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @param message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IProposalsDiscussionPostMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Verifies a ProposalsDiscussionPostMetadata message.
+     * @param message Plain object to verify
+     * @returns `null` if valid, otherwise the reason why it is not
+     */
+    public static verify(message: { [k: string]: any }): (string|null);
+
+    /**
+     * Creates a ProposalsDiscussionPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ProposalsDiscussionPostMetadata
+     */
+    public static fromObject(object: { [k: string]: any }): ProposalsDiscussionPostMetadata;
+
+    /**
+     * Creates a plain object from a ProposalsDiscussionPostMetadata message. Also converts values to other types if specified.
+     * @param message ProposalsDiscussionPostMetadata
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ProposalsDiscussionPostMetadata, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ProposalsDiscussionPostMetadata to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
 /** Properties of a SeriesMetadata. */
 export interface ISeriesMetadata {
 

+ 210 - 0
metadata-protobuf/compiled/index.js

@@ -2182,6 +2182,216 @@ $root.PlaylistMetadata = (function() {
     return PlaylistMetadata;
 })();
 
+$root.ProposalsDiscussionPostMetadata = (function() {
+
+    /**
+     * Properties of a ProposalsDiscussionPostMetadata.
+     * @exports IProposalsDiscussionPostMetadata
+     * @interface IProposalsDiscussionPostMetadata
+     * @property {string|null} [text] ProposalsDiscussionPostMetadata text
+     * @property {number|null} [repliesTo] ProposalsDiscussionPostMetadata repliesTo
+     */
+
+    /**
+     * Constructs a new ProposalsDiscussionPostMetadata.
+     * @exports ProposalsDiscussionPostMetadata
+     * @classdesc Represents a ProposalsDiscussionPostMetadata.
+     * @implements IProposalsDiscussionPostMetadata
+     * @constructor
+     * @param {IProposalsDiscussionPostMetadata=} [properties] Properties to set
+     */
+    function ProposalsDiscussionPostMetadata(properties) {
+        if (properties)
+            for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i)
+                if (properties[keys[i]] != null)
+                    this[keys[i]] = properties[keys[i]];
+    }
+
+    /**
+     * ProposalsDiscussionPostMetadata text.
+     * @member {string} text
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     */
+    ProposalsDiscussionPostMetadata.prototype.text = "";
+
+    /**
+     * ProposalsDiscussionPostMetadata repliesTo.
+     * @member {number} repliesTo
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     */
+    ProposalsDiscussionPostMetadata.prototype.repliesTo = 0;
+
+    /**
+     * Creates a new ProposalsDiscussionPostMetadata instance using the specified properties.
+     * @function create
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata=} [properties] Properties to set
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata instance
+     */
+    ProposalsDiscussionPostMetadata.create = function create(properties) {
+        return new ProposalsDiscussionPostMetadata(properties);
+    };
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @function encode
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ProposalsDiscussionPostMetadata.encode = function encode(message, writer) {
+        if (!writer)
+            writer = $Writer.create();
+        if (message.text != null && Object.hasOwnProperty.call(message, "text"))
+            writer.uint32(/* id 1, wireType 2 =*/10).string(message.text);
+        if (message.repliesTo != null && Object.hasOwnProperty.call(message, "repliesTo"))
+            writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.repliesTo);
+        return writer;
+    };
+
+    /**
+     * Encodes the specified ProposalsDiscussionPostMetadata message, length delimited. Does not implicitly {@link ProposalsDiscussionPostMetadata.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {IProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ProposalsDiscussionPostMetadata.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer.
+     * @function decode
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ProposalsDiscussionPostMetadata.decode = function decode(reader, length) {
+        if (!(reader instanceof $Reader))
+            reader = $Reader.create(reader);
+        var end = length === undefined ? reader.len : reader.pos + length, message = new $root.ProposalsDiscussionPostMetadata();
+        while (reader.pos < end) {
+            var tag = reader.uint32();
+            switch (tag >>> 3) {
+            case 1:
+                message.text = reader.string();
+                break;
+            case 2:
+                message.repliesTo = reader.uint32();
+                break;
+            default:
+                reader.skipType(tag & 7);
+                break;
+            }
+        }
+        return message;
+    };
+
+    /**
+     * Decodes a ProposalsDiscussionPostMetadata message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ProposalsDiscussionPostMetadata.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ProposalsDiscussionPostMetadata message.
+     * @function verify
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ProposalsDiscussionPostMetadata.verify = function verify(message) {
+        if (typeof message !== "object" || message === null)
+            return "object expected";
+        if (message.text != null && message.hasOwnProperty("text"))
+            if (!$util.isString(message.text))
+                return "text: string expected";
+        if (message.repliesTo != null && message.hasOwnProperty("repliesTo"))
+            if (!$util.isInteger(message.repliesTo))
+                return "repliesTo: integer expected";
+        return null;
+    };
+
+    /**
+     * Creates a ProposalsDiscussionPostMetadata message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ProposalsDiscussionPostMetadata} ProposalsDiscussionPostMetadata
+     */
+    ProposalsDiscussionPostMetadata.fromObject = function fromObject(object) {
+        if (object instanceof $root.ProposalsDiscussionPostMetadata)
+            return object;
+        var message = new $root.ProposalsDiscussionPostMetadata();
+        if (object.text != null)
+            message.text = String(object.text);
+        if (object.repliesTo != null)
+            message.repliesTo = object.repliesTo >>> 0;
+        return message;
+    };
+
+    /**
+     * Creates a plain object from a ProposalsDiscussionPostMetadata message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ProposalsDiscussionPostMetadata
+     * @static
+     * @param {ProposalsDiscussionPostMetadata} message ProposalsDiscussionPostMetadata
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ProposalsDiscussionPostMetadata.toObject = function toObject(message, options) {
+        if (!options)
+            options = {};
+        var object = {};
+        if (options.defaults) {
+            object.text = "";
+            object.repliesTo = 0;
+        }
+        if (message.text != null && message.hasOwnProperty("text"))
+            object.text = message.text;
+        if (message.repliesTo != null && message.hasOwnProperty("repliesTo"))
+            object.repliesTo = message.repliesTo;
+        return object;
+    };
+
+    /**
+     * Converts this ProposalsDiscussionPostMetadata to JSON.
+     * @function toJSON
+     * @memberof ProposalsDiscussionPostMetadata
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ProposalsDiscussionPostMetadata.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    return ProposalsDiscussionPostMetadata;
+})();
+
 $root.SeriesMetadata = (function() {
 
     /**

+ 6 - 0
metadata-protobuf/proto/ProposalsDiscussion.proto

@@ -0,0 +1,6 @@
+syntax = "proto2";
+
+message ProposalsDiscussionPostMetadata {
+  optional string text = 1; // Post text content (md-formatted)
+  optional uint32 repliesTo = 2; // Id of the post that given post replies to (if any)
+}

+ 2 - 3
pioneer/packages/joy-election/src/VoteForm.tsx

@@ -1,5 +1,5 @@
 import BN from 'bn.js';
-import uuid from 'uuid/v4';
+import { randomAsHex } from '@polkadot/util-crypto';
 
 import React from 'react';
 import { Message, Table } from 'semantic-ui-react';
@@ -25,9 +25,8 @@ import { saveVote, NewVote } from './myVotesStore';
 import { TxFailedCallback } from '@polkadot/react-components/Status/types';
 import { RouteProps } from 'react-router-dom';
 
-// TODO use a crypto-prooven generator instead of UUID 4.
 function randomSalt () {
-  return uuid().replace(/-/g, '');
+  return randomAsHex();
 }
 
 // AppsProps is needed to get a location from the route.

+ 2 - 0
query-node/manifest.yml

@@ -141,6 +141,8 @@ typegen:
     - data_directory.remove_content
     - data_directory.accept_content
     - data_directory.update_content_uploading_status
+    # Proposals discussion
+    - proposalsDiscussion.addPost
   outDir: ./mappings/generated/types
   customTypes:
     lib: '@joystream/types/augment/all/types'

+ 8 - 1
query-node/mappings/common.ts

@@ -7,7 +7,7 @@ import {
   StoreContext,
 } from '@dzlzv/hydra-common'
 import { Bytes } from '@polkadot/types'
-import { WorkingGroup, WorkerId, ContentParameters } from '@joystream/types/augment/all'
+import { WorkingGroup, WorkerId, ThreadId, ContentParameters } from '@joystream/types/augment/all'
 import { Worker, Event, Network, DataObject, LiaisonJudgement, DataObjectOwner } from 'query-node/dist/model'
 import { BaseModel } from 'warthog'
 import { ContentParameters as Custom_ContentParameters } from '@joystream/types/storage'
@@ -203,6 +203,13 @@ export function extractSudoCallParameters<DataParams>(rawEvent: SubstrateEvent):
   return callArgs
 }
 
+// FIXME:
+type MappingsMemoryCache = {
+  lastCreatedProposalThreadId?: ThreadId
+}
+
+export const MemoryCache: MappingsMemoryCache = {}
+
 export function genericEventFields(substrateEvent: SubstrateEvent): Partial<BaseModel & Event> {
   const { blockNumber, indexInBlock, extrinsic, blockTimestamp } = substrateEvent
   const eventTime = new Date(blockTimestamp)

+ 18 - 2
query-node/mappings/proposals.ts

@@ -2,7 +2,7 @@
 eslint-disable @typescript-eslint/naming-convention
 */
 import { SubstrateEvent, DatabaseManager, EventContext, StoreContext } from '@dzlzv/hydra-common'
-import { ProposalDetails as RuntimeProposalDetails, ProposalId } from '@joystream/types/augment/all'
+import { ProposalDetails as RuntimeProposalDetails } from '@joystream/types/augment/all'
 import BN from 'bn.js'
 import {
   Proposal,
@@ -57,8 +57,10 @@ import {
   ProposalCancelledEvent,
   ProposalCreatedEvent,
   RuntimeWasmBytecode,
+  ProposalDiscussionThread,
+  ProposalDiscussionThreadModeOpen,
 } from 'query-node/dist/model'
-import { bytesToString, genericEventFields, getWorkingGroupModuleName, perpareString } from './common'
+import { bytesToString, genericEventFields, getWorkingGroupModuleName, MemoryCache, perpareString } from './common'
 import { ProposalsEngine, ProposalsCodex } from './generated/types'
 import { createWorkingGroupOpeningMetadata } from './workingGroups'
 import { blake2AsHex } from '@polkadot/util-crypto'
@@ -320,6 +322,10 @@ export async function proposalsCodex_ProposalCreated({ store, event }: EventCont
   const eventTime = new Date(event.blockTimestamp)
   const proposalDetails = await parseProposalDetails(event, store, runtimeProposalDetails)
 
+  if (!MemoryCache.lastCreatedProposalThreadId) {
+    throw new Error('Unexpected state: MemoryCache.lastCreatedProposalThreadId is empty')
+  }
+
   const proposal = new Proposal({
     id: proposalId.toString(),
     createdAt: eventTime,
@@ -338,6 +344,16 @@ export async function proposalsCodex_ProposalCreated({ store, event }: EventCont
   })
   await store.save<Proposal>(proposal)
 
+  // Thread is always created along with the proposal
+  const proposalThread = new ProposalDiscussionThread({
+    id: MemoryCache.lastCreatedProposalThreadId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    mode: new ProposalDiscussionThreadModeOpen(),
+    proposal,
+  })
+  await store.save<ProposalDiscussionThread>(proposalThread)
+
   const proposalCreatedEvent = new ProposalCreatedEvent({
     ...genericEventFields(event),
     proposal: proposal,

+ 149 - 14
query-node/mappings/proposalsDiscussion.ts

@@ -1,24 +1,159 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { SubstrateEvent, DatabaseManager } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common'
+import {
+  Membership,
+  ProposalDiscussionPostStatusActive,
+  ProposalDiscussionPostStatusLocked,
+  ProposalDiscussionPost,
+  ProposalDiscussionThread,
+  ProposalDiscussionPostCreatedEvent,
+  ProposalDiscussionPostUpdatedEvent,
+  ProposalDiscussionThreadModeClosed,
+  ProposalDiscussionWhitelist,
+  ProposalDiscussionThreadModeOpen,
+  ProposalDiscussionThreadModeChangedEvent,
+  ProposalDiscussionPostDeletedEvent,
+  ProposalDiscussionPostStatusRemoved,
+} from 'query-node/dist/model'
+import { bytesToString, deserializeMetadata, genericEventFields, MemoryCache } from './common'
+import { ProposalsDiscussion } from './generated/types'
+import { ProposalsDiscussionPostMetadata } from '@joystream/metadata-protobuf'
+import { In } from 'typeorm'
 
-export async function proposalsDiscussion_ThreadCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+async function getPost(store: DatabaseManager, id: string) {
+  const post = await store.get(ProposalDiscussionPost, { where: { id } })
+  if (!post) {
+    throw new Error(`Proposal discussion post not found by id: ${id}`)
+  }
+
+  return post
 }
-export async function proposalsDiscussion_PostCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+
+async function getThread(store: DatabaseManager, id: string) {
+  const thread = await store.get(ProposalDiscussionThread, { where: { id } })
+  if (!thread) {
+    throw new Error(`Proposal discussion thread not found by id: ${id}`)
+  }
+
+  return thread
 }
-export async function proposalsDiscussion_PostUpdated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+
+export async function proposalsDiscussion_ThreadCreated({ event }: EventContext & StoreContext): Promise<void> {
+  const [threadId] = new ProposalsDiscussion.ThreadCreatedEvent(event).params
+  MemoryCache.lastCreatedProposalThreadId = threadId
 }
-export async function proposalsDiscussion_ThreadModeChanged(
-  db: DatabaseManager,
-  event_: SubstrateEvent
-): Promise<void> {
-  // TODO
+
+export async function proposalsDiscussion_PostCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const { editable } = new ProposalsDiscussion.AddPostCall(event).args // FIXME: batch and sudo extrinsics handling
+  const [postId, memberId, threadId, metadataBytes] = new ProposalsDiscussion.PostCreatedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const metadata = deserializeMetadata(ProposalsDiscussionPostMetadata, metadataBytes)
+
+  const repliesTo =
+    typeof metadata?.repliesTo === 'number'
+      ? await store.get(ProposalDiscussionPost, { where: { id: metadata.repliesTo.toString() } })
+      : undefined
+
+  const text = typeof metadata?.text === 'string' ? metadata.text : bytesToString(metadataBytes)
+
+  const post = new ProposalDiscussionPost({
+    id: postId.toString(),
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    author: new Membership({ id: memberId.toString() }),
+    status: editable.isTrue ? new ProposalDiscussionPostStatusActive() : new ProposalDiscussionPostStatusLocked(),
+    text,
+    repliesTo,
+    discussionThread: new ProposalDiscussionThread({ id: threadId.toString() }),
+  })
+  await store.save<ProposalDiscussionPost>(post)
+
+  const postCreatedEvent = new ProposalDiscussionPostCreatedEvent({
+    ...genericEventFields(event),
+    post: post,
+    text,
+  })
+  await store.save<ProposalDiscussionPostCreatedEvent>(postCreatedEvent)
 }
 
-export async function proposalsDiscussion_PostDeleted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
+export async function proposalsDiscussion_PostUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [postId, , , newTextBytes] = new ProposalsDiscussion.PostUpdatedEvent(event).params
+
+  const post = await getPost(store, postId.toString())
+  const newText = bytesToString(newTextBytes)
+
+  post.text = newText
+  post.updatedAt = new Date(event.blockTimestamp)
+  await store.save<ProposalDiscussionPost>(post)
+
+  const postUpdatedEvent = new ProposalDiscussionPostUpdatedEvent({
+    ...genericEventFields(event),
+    post,
+    text: newText,
+  })
+  await store.save<ProposalDiscussionPostUpdatedEvent>(postUpdatedEvent)
+}
+
+export async function proposalsDiscussion_ThreadModeChanged({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  const [threadId, threadMode, memberId] = new ProposalsDiscussion.ThreadModeChangedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+
+  const thread = await getThread(store, threadId.toString())
+
+  if (threadMode.isClosed) {
+    const newMode = new ProposalDiscussionThreadModeClosed()
+    const whitelistMemberIds = threadMode.asClosed
+    const members = await store.getMany(Membership, {
+      where: { id: In(whitelistMemberIds.map((id) => id.toString())) },
+    })
+    const whitelist = new ProposalDiscussionWhitelist({
+      createdAt: eventTime,
+      updatedAt: eventTime,
+      members,
+    })
+    await store.save<ProposalDiscussionWhitelist>(whitelist)
+    newMode.whitelistId = whitelist.id
+    thread.mode = newMode
+  } else if (threadMode.isOpen) {
+    const newMode = new ProposalDiscussionThreadModeOpen()
+    thread.mode = newMode
+  } else {
+    throw new Error(`Unrecognized proposal thread mode: ${threadMode.type}`)
+  }
+
+  thread.updatedAt = eventTime
+  await store.save<ProposalDiscussionThread>(thread)
+
+  const threadModeChangedEvent = new ProposalDiscussionThreadModeChangedEvent({
+    ...genericEventFields(event),
+    actor: new Membership({ id: memberId.toString() }),
+    newMode: thread.mode,
+    thread: thread,
+  })
+  await store.save<ProposalDiscussionThreadModeChangedEvent>(threadModeChangedEvent)
+}
+
+export async function proposalsDiscussion_PostDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [memberId, , postId, hide] = new ProposalsDiscussion.PostDeletedEvent(event).params
+  const eventTime = new Date(event.blockTimestamp)
+  const post = await getPost(store, postId.toString())
+
+  const postDeletedEvent = new ProposalDiscussionPostDeletedEvent({
+    ...genericEventFields(event),
+    post,
+    actor: new Membership({ id: memberId.toString() }),
+  })
+  await store.save<ProposalDiscussionPostDeletedEvent>(postDeletedEvent)
+
+  const newStatus = hide.isTrue ? new ProposalDiscussionPostStatusRemoved() : new ProposalDiscussionPostStatusLocked()
+  newStatus.deletedInEventId = postDeletedEvent.id
+  post.status = newStatus
+  post.updatedAt = eventTime
+  await store.save<ProposalDiscussionPost>(post)
 }

+ 5 - 2
query-node/schemas/proposalDiscussion.graphql

@@ -26,10 +26,13 @@ type ProposalDiscussionThread @entity {
   proposal: Proposal!
 
   "List of posts in the the thread"
-  posts: [ProposalDiscussionPost!] @derivedFrom(field: "thread")
+  posts: [ProposalDiscussionPost!] @derivedFrom(field: "discussionThread")
 
   "Current thread mode"
   mode: ProposalDiscussionThreadMode!
+
+  "List of related thread mode change events"
+  modeChanges: [ProposalDiscussionThreadModeChangedEvent!] @derivedFrom(field: "thread")
 }
 
 "The post is visible and editable"
@@ -59,7 +62,7 @@ type ProposalDiscussionPost @entity {
   id: ID!
 
   "Proposal discussion thread the post was created in"
-  thread: ProposalDiscussionThread!
+  discussionThread: ProposalDiscussionThread!
 
   "The author of the post"
   author: Membership!

+ 0 - 24
query-node/schemas/proposalDiscussionEvents.graphql

@@ -1,27 +1,3 @@
-type ProposalDiscussionThreadCreatedEvent implements Event @entity {
-  ### GENERIC DATA ###
-
-  "(network}-{blockNumber}-{indexInBlock}"
-  id: ID!
-
-  "Hash of the extrinsic which caused the event to be emitted"
-  inExtrinsic: String
-
-  "Blocknumber of the block in which the event was emitted."
-  inBlock: Int!
-
-  "Network the block was produced in"
-  network: Network!
-
-  "Index of event in block from which it was emitted."
-  indexInBlock: Int!
-
-  ### SPECIFIC DATA ###
-
-  "The created thread"
-  thread: ProposalDiscussionThread!
-}
-
 type ProposalDiscussionPostCreatedEvent implements Event @entity {
   ### GENERIC DATA ###
 

+ 23 - 0
tests/integration-tests/src/Api.ts

@@ -32,6 +32,8 @@ import {
   PostAddedEventDetails,
   ThreadCreatedEventDetails,
   ProposalsCodexEventName,
+  ProposalDiscussionPostCreatedEventDetails,
+  ProposalsDiscussionEventName,
 } from './types'
 import {
   ApplicationId,
@@ -470,6 +472,27 @@ export class Api {
     }
   }
 
+  public async retrieveProposalsDiscussionEventDetails(
+    result: ISubmittableResult,
+    eventName: ProposalsDiscussionEventName
+  ): Promise<EventDetails> {
+    const details = await this.retrieveEventDetails(result, 'proposalsDiscussion', eventName)
+    if (!details) {
+      throw new Error(`${eventName} event details not found in result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return details
+  }
+
+  public async retrieveProposalDiscussionPostCreatedEventDetails(
+    result: ISubmittableResult
+  ): Promise<ProposalDiscussionPostCreatedEventDetails> {
+    const details = await this.retrieveProposalsDiscussionEventDetails(result, 'PostCreated')
+    return {
+      ...details,
+      postId: details.event.data[0] as PostId,
+    }
+  }
+
   public async getMemberSigners(inputs: { asMember: MemberId }[]): Promise<string[]> {
     return await Promise.all(
       inputs.map(async ({ asMember }) => {

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

@@ -263,6 +263,30 @@ import {
   GetPostDeletedEventsByEventIdsQueryVariables,
   GetPostDeletedEventsByEventIds,
   CategoryArchivalStatusUpdatedEventFieldsFragment,
+  ProposalDiscussionPostCreatedEventFieldsFragment,
+  GetProposalDiscussionPostCreatedEventsQuery,
+  GetProposalDiscussionPostCreatedEventsQueryVariables,
+  GetProposalDiscussionPostCreatedEvents,
+  ProposalDiscussionPostUpdatedEventFieldsFragment,
+  GetProposalDiscussionPostUpdatedEventsQuery,
+  GetProposalDiscussionPostUpdatedEventsQueryVariables,
+  GetProposalDiscussionPostUpdatedEvents,
+  ProposalDiscussionThreadModeChangedEventFieldsFragment,
+  GetProposalDiscussionThreadModeChangedEventsQuery,
+  GetProposalDiscussionThreadModeChangedEventsQueryVariables,
+  GetProposalDiscussionThreadModeChangedEvents,
+  ProposalDiscussionPostDeletedEventFieldsFragment,
+  GetProposalDiscussionPostDeletedEventsQuery,
+  GetProposalDiscussionPostDeletedEventsQueryVariables,
+  GetProposalDiscussionPostDeletedEvents,
+  ProposalDiscussionPostFieldsFragment,
+  GetProposalDiscussionPostsByIdsQuery,
+  GetProposalDiscussionPostsByIdsQueryVariables,
+  GetProposalDiscussionPostsByIds,
+  ProposalDiscussionThreadFieldsFragment,
+  GetProposalDiscussionThreadsByIdsQuery,
+  GetProposalDiscussionThreadsByIdsQueryVariables,
+  GetProposalDiscussionThreadsByIds,
 } from './graphql/generated/queries'
 import { Maybe } from './graphql/generated/schema'
 import { OperationDefinitionNode } from 'graphql'
@@ -956,4 +980,62 @@ export class QueryNodeApi {
       GetPostDeletedEventsByEventIdsQueryVariables
     >(GetPostDeletedEventsByEventIds, { eventIds }, 'postDeletedEvents')
   }
+
+  public async getProposalDiscussionPostCreatedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostCreatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostCreatedEventsQuery,
+      GetProposalDiscussionPostCreatedEventsQueryVariables
+    >(GetProposalDiscussionPostCreatedEvents, { eventIds }, 'proposalDiscussionPostCreatedEvents')
+  }
+
+  public async getProposalDiscussionPostUpdatedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostUpdatedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostUpdatedEventsQuery,
+      GetProposalDiscussionPostUpdatedEventsQueryVariables
+    >(GetProposalDiscussionPostUpdatedEvents, { eventIds }, 'proposalDiscussionPostUpdatedEvents')
+  }
+
+  public async getProposalDiscussionThreadModeChangedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionThreadModeChangedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionThreadModeChangedEventsQuery,
+      GetProposalDiscussionThreadModeChangedEventsQueryVariables
+    >(GetProposalDiscussionThreadModeChangedEvents, { eventIds }, 'proposalDiscussionThreadModeChangedEvents')
+  }
+
+  public async getProposalDiscussionPostDeletedEvents(
+    events: EventDetails[]
+  ): Promise<ProposalDiscussionPostDeletedEventFieldsFragment[]> {
+    const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostDeletedEventsQuery,
+      GetProposalDiscussionPostDeletedEventsQueryVariables
+    >(GetProposalDiscussionPostDeletedEvents, { eventIds }, 'proposalDiscussionPostDeletedEvents')
+  }
+
+  public async getProposalDiscussionPostsByIds(
+    ids: (PostId | number)[]
+  ): Promise<ProposalDiscussionPostFieldsFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionPostsByIdsQuery,
+      GetProposalDiscussionPostsByIdsQueryVariables
+    >(GetProposalDiscussionPostsByIds, { ids: ids.map((id) => id.toString()) }, 'proposalDiscussionPosts')
+  }
+
+  public async getProposalDiscussionThreadsByIds(
+    ids: (PostId | number)[]
+  ): Promise<ProposalDiscussionThreadFieldsFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetProposalDiscussionThreadsByIdsQuery,
+      GetProposalDiscussionThreadsByIdsQueryVariables
+    >(GetProposalDiscussionThreadsByIds, { ids: ids.map((id) => id.toString()) }, 'proposalDiscussionThreads')
+  }
 }

+ 2 - 0
tests/integration-tests/src/consts.ts

@@ -15,6 +15,8 @@ export const MIN_UNSTANKING_PERIOD = 43201
 export const LEADER_OPENING_STAKE = new BN(2000)
 export const THREAD_DEPOSIT = new BN(30)
 export const POST_DEPOSIT = new BN(10)
+export const PROPOSALS_POST_DEPOSIT = new BN(2000)
+export const ALL_BYTES = '0x' + Array.from({ length: 256 }, (v, i) => Buffer.from([i]).toString('hex')).join('')
 
 export const lockIdByWorkingGroup: { [K in WorkingGroupModuleName]: string } = {
   storageWorkingGroup: '0x0606060606060606',

+ 1 - 0
tests/integration-tests/src/fixtures/proposals/CreateProposalsFixture.ts

@@ -317,6 +317,7 @@ export class CreateProposalsFixture extends StandardizedFixture {
       assert.equal(qProposal.createdInEvent.inBlock, e.blockNumber)
       assert.equal(qProposal.createdInEvent.inExtrinsic, this.extrinsics[i].hash.toString())
       assert.equal(qProposal.isFinalized, false)
+      assert.equal(qProposal.discussionThread.mode.__typename, 'ProposalDiscussionThreadModeOpen')
       this.assertProposalDetailsAreValid(proposalParams, qProposal)
     })
   }

+ 103 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/ChangeThreadsModeFixture.ts

@@ -0,0 +1,103 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ProposalDiscussionThreadFieldsFragment,
+  ProposalDiscussionThreadModeChangedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, ThreadId } from '@joystream/types/common'
+import { CreateInterface } from '@joystream/types'
+import { ThreadMode } from '@joystream/types/proposals'
+import _ from 'lodash'
+
+export type ThreadModeChangeParams = {
+  threadId: ThreadId | number
+  newMode: CreateInterface<ThreadMode>
+  asMember: MemberId
+}
+
+export class ChangeThreadsModeFixture extends StandardizedFixture {
+  protected threadsModeChangeParams: ThreadModeChangeParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, threadsModeChangeParams: ThreadModeChangeParams[]) {
+    super(api, query)
+    this.threadsModeChangeParams = threadsModeChangeParams
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.threadsModeChangeParams)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.threadsModeChangeParams.map((params) =>
+      this.api.tx.proposalsDiscussion.changeThreadMode(params.asMember, params.threadId, params.newMode)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'ThreadModeChanged')
+  }
+
+  protected assertQueriedThreadsAreValid(
+    qThreads: ProposalDiscussionThreadFieldsFragment[],
+    qEvents: ProposalDiscussionThreadModeChangedEventFieldsFragment[]
+  ): void {
+    for (const [threadId, changes] of _.entries(
+      _.groupBy(this.threadsModeChangeParams, (p) => p.threadId.toString())
+    )) {
+      const finalUpdate = _.last(changes)
+      const qThread = qThreads.find((t) => t.id === threadId.toString())
+      Utils.assert(qThread, 'Query node: Thread not found!')
+      assert.includeDeepMembers(
+        qThread.modeChanges.map((e) => e.id),
+        qEvents.filter((e) => e.thread.id === qThread.id).map((e) => e.id)
+      )
+      Utils.assert(finalUpdate)
+      const newMode = this.api.createType('ThreadMode', finalUpdate.newMode)
+      if (newMode.isOfType('Closed')) {
+        Utils.assert(
+          qThread.mode.__typename === 'ProposalDiscussionThreadModeClosed',
+          `Invalid thread status ${qThread.mode.__typename}`
+        )
+        Utils.assert(qThread.mode.whitelist, 'Query node: Missing thread.mode.whitelist')
+        assert.sameDeepMembers(
+          qThread.mode.whitelist.members.map((m) => m.id),
+          newMode.asType('Closed').map((memberId) => memberId.toString())
+        )
+      } else if (newMode.isOfType('Open')) {
+        assert.equal(qThread.mode.__typename, 'ProposalDiscussionThreadModeOpen')
+      } else {
+        throw new Error(`Unknown thread mode: ${newMode.type}`)
+      }
+    }
+  }
+
+  protected assertQueryNodeEventIsValid(
+    qEvent: ProposalDiscussionThreadModeChangedEventFieldsFragment,
+    i: number
+  ): void {
+    const params = this.threadsModeChangeParams[i]
+    assert.equal(qEvent.thread.id, params.threadId.toString())
+    assert.equal(qEvent.actor.id, params.asMember.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionThreadModeChangedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qThreads = await this.query.getProposalDiscussionThreadsByIds(
+      this.threadsModeChangeParams.map((p) => p.threadId)
+    )
+    this.assertQueriedThreadsAreValid(qThreads, qEvents)
+  }
+}

+ 122 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/CreatePostsFixture.ts

@@ -0,0 +1,122 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { MetadataInput, ProposalDiscussionPostCreatedEventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ProposalDiscussionPostCreatedEventFieldsFragment,
+  ProposalDiscussionPostFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import { PROPOSALS_POST_DEPOSIT } from '../../consts'
+import { ProposalsDiscussionPostMetadata, IProposalsDiscussionPostMetadata } from '@joystream/metadata-protobuf'
+
+export type PostParams = {
+  threadId: ThreadId | number
+  asMember: MemberId
+  editable?: boolean // defaults to true
+  metadata: MetadataInput<IProposalsDiscussionPostMetadata> & { expectReplyFailure?: boolean }
+}
+
+export class CreatePostsFixture extends StandardizedFixture {
+  protected events: ProposalDiscussionPostCreatedEventDetails[] = []
+  protected postsParams: PostParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, postsParams: PostParams[]) {
+    super(api, query)
+    this.postsParams = postsParams
+  }
+
+  public getCreatedPostsIds(): PostId[] {
+    if (!this.events.length) {
+      throw new Error('Trying to get created posts ids before they were created!')
+    }
+    return this.events.map((e) => e.postId)
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.postsParams)
+  }
+
+  public async execute(): Promise<void> {
+    const accounts = await this.getSignerAccountOrAccounts()
+    // Send required funds to accounts (ProposalsPostDeposit)
+    await Promise.all(accounts.map((a) => this.api.treasuryTransferBalance(a, PROPOSALS_POST_DEPOSIT)))
+    await super.execute()
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.postsParams.map((params) =>
+      this.api.tx.proposalsDiscussion.addPost(
+        params.asMember,
+        params.threadId,
+        Utils.getMetadataBytesFromInput(ProposalsDiscussionPostMetadata, params.metadata),
+        params.editable === undefined || params.editable
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<ProposalDiscussionPostCreatedEventDetails> {
+    return this.api.retrieveProposalDiscussionPostCreatedEventDetails(result)
+  }
+
+  protected getPostExpectedText(postParams: PostParams): string {
+    const expectedMetadata = Utils.getDeserializedMetadataFormInput(
+      ProposalsDiscussionPostMetadata,
+      postParams.metadata
+    )
+    const metadataBytes = Utils.getMetadataBytesFromInput(ProposalsDiscussionPostMetadata, postParams.metadata)
+    return typeof expectedMetadata?.text === 'string' ? expectedMetadata.text : Utils.bytesToString(metadataBytes)
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostCreatedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const qPost = qPosts.find((p) => p.id === e.postId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const postParams = this.postsParams[i]
+      const expectedStatus =
+        postParams.editable === undefined || postParams.editable
+          ? 'ProposalDiscussionPostStatusActive'
+          : 'ProposalDiscussionPostStatusLocked'
+      const expectedMetadata = Utils.getDeserializedMetadataFormInput(
+        ProposalsDiscussionPostMetadata,
+        postParams.metadata
+      )
+      Utils.assert(qPost, 'Query node: Post not found')
+      assert.equal(qPost.discussionThread.id, postParams.threadId.toString())
+      assert.equal(qPost.author.id, postParams.asMember.toString())
+      assert.equal(qPost.status.__typename, expectedStatus)
+      assert.equal(qPost.text, this.getPostExpectedText(postParams))
+      assert.equal(
+        qPost.repliesTo?.id,
+        postParams.metadata.expectReplyFailure ? undefined : expectedMetadata?.repliesTo?.toString()
+      )
+      assert.equal(qPost.createdInEvent.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostCreatedEventFieldsFragment, i: number): void {
+    const params = this.postsParams[i]
+    assert.equal(qEvent.post.id, this.events[i].postId.toString())
+    assert.equal(qEvent.text, this.getPostExpectedText(params))
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostCreatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.events.map((e) => e.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 85 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/DeletePostsFixture.ts

@@ -0,0 +1,85 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ProposalDiscussionPostDeletedEventFieldsFragment,
+  ProposalDiscussionPostFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+
+export type DeletePostParams = {
+  threadId: ThreadId | number
+  postId: PostId | number
+  asMember: MemberId
+  hide?: boolean // defaults to true
+}
+
+export class DeletePostsFixture extends StandardizedFixture {
+  protected deletePostsParams: DeletePostParams[]
+
+  public constructor(api: Api, query: QueryNodeApi, deletePostsParams: DeletePostParams[]) {
+    super(api, query)
+    this.deletePostsParams = deletePostsParams
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.deletePostsParams)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.deletePostsParams.map((params) =>
+      this.api.tx.proposalsDiscussion.deletePost(
+        params.asMember,
+        params.postId,
+        params.threadId,
+        params.hide === undefined || params.hide
+      )
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'PostDeleted')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostDeletedEventFieldsFragment[]
+  ): void {
+    this.events.map((e, i) => {
+      const params = this.deletePostsParams[i]
+      const qPost = qPosts.find((p) => p.id === params.postId.toString())
+      const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
+      const expectedStatus =
+        params.hide === undefined || params.hide
+          ? 'ProposalDiscussionPostStatusRemoved'
+          : 'ProposalDiscussionPostStatusLocked'
+      Utils.assert(qPost, 'Query node: Post not found')
+      Utils.assert(qPost.status.__typename === expectedStatus, `Invalid post status (${qPost.status.__typename})`)
+      assert.equal(qPost.status.deletedInEvent?.id, qEvent.id)
+    })
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostDeletedEventFieldsFragment, i: number): void {
+    const params = this.deletePostsParams[i]
+    assert.equal(qEvent.post.id, params.postId.toString())
+    assert.equal(qEvent.actor.id, params.asMember.toString())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostDeletedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.deletePostsParams.map((p) => p.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 81 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/UpdatePostsFixture.ts

@@ -0,0 +1,81 @@
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { EventDetails } from '../../types'
+import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { Utils } from '../../utils'
+import { ISubmittableResult } from '@polkadot/types/types/'
+import {
+  ProposalDiscussionPostFieldsFragment,
+  ProposalDiscussionPostUpdatedEventFieldsFragment,
+} from '../../graphql/generated/queries'
+import { assert } from 'chai'
+import { StandardizedFixture } from '../../Fixture'
+import { MemberId, PostId, ThreadId } from '@joystream/types/common'
+import _ from 'lodash'
+
+export type PostUpdateParams = {
+  threadId: ThreadId | number
+  postId: PostId | number
+  newText: string
+  asMember: MemberId // Cannot retrieve this information from the runtime currently
+}
+
+export class UpdatePostsFixture extends StandardizedFixture {
+  protected postsUpdates: PostUpdateParams[]
+  protected postsAuthors: MemberId[] = []
+
+  public constructor(api: Api, query: QueryNodeApi, postsUpdates: PostUpdateParams[]) {
+    super(api, query)
+    this.postsUpdates = postsUpdates
+  }
+
+  protected async getSignerAccountOrAccounts(): Promise<string[]> {
+    return this.api.getMemberSigners(this.postsUpdates)
+  }
+
+  protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
+    return this.postsUpdates.map((params) =>
+      this.api.tx.proposalsDiscussion.updatePost(params.threadId, params.postId, params.newText)
+    )
+  }
+
+  protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
+    return this.api.retrieveProposalsDiscussionEventDetails(result, 'PostUpdated')
+  }
+
+  protected assertQueriedPostsAreValid(
+    qPosts: ProposalDiscussionPostFieldsFragment[],
+    qEvents: ProposalDiscussionPostUpdatedEventFieldsFragment[]
+  ): void {
+    for (const [postId, updates] of _.entries(_.groupBy(this.postsUpdates, (p) => p.postId.toString()))) {
+      const finalUpdate = _.last(updates)
+      const qPost = qPosts.find((p) => p.id === postId.toString())
+      Utils.assert(qPost, 'Query node: Post not found!')
+      assert.includeDeepMembers(
+        qPost.updates.map((e) => e.id),
+        qEvents.filter((e) => e.post.id === qPost.id).map((e) => e.id)
+      )
+      Utils.assert(finalUpdate)
+      assert.equal(qPost.text, Utils.asText(finalUpdate.newText))
+    }
+  }
+
+  protected assertQueryNodeEventIsValid(qEvent: ProposalDiscussionPostUpdatedEventFieldsFragment, i: number): void {
+    const params = this.postsUpdates[i]
+    assert.equal(qEvent.post.id, params.postId.toString())
+    assert.equal(qEvent.text, Utils.asText(params.newText))
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    // Query the events
+    const qEvents = await this.query.tryQueryWithTimeout(
+      () => this.query.getProposalDiscussionPostUpdatedEvents(this.events),
+      (qEvents) => this.assertQueryNodeEventsAreValid(qEvents)
+    )
+
+    // Query the posts
+    const qPosts = await this.query.getProposalDiscussionPostsByIds(this.postsUpdates.map((u) => u.postId))
+    this.assertQueriedPostsAreValid(qPosts, qEvents)
+  }
+}

+ 4 - 0
tests/integration-tests/src/fixtures/proposalsDiscussion/index.ts

@@ -0,0 +1,4 @@
+export { ChangeThreadsModeFixture, ThreadModeChangeParams } from './ChangeThreadsModeFixture'
+export { CreatePostsFixture, PostParams } from './CreatePostsFixture'
+export { DeletePostsFixture, DeletePostParams } from './DeletePostsFixture'
+export { UpdatePostsFixture, PostUpdateParams } from './UpdatePostsFixture'

+ 127 - 0
tests/integration-tests/src/flows/proposalsDiscussion/index.ts

@@ -0,0 +1,127 @@
+import { FlowProps } from '../../Flow'
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import { BuyMembershipHappyCaseFixture } from '../../fixtures/membership'
+import { CreateProposalsFixture, ExpireProposalsFixture } from '../../fixtures/proposals'
+import {
+  ChangeThreadsModeFixture,
+  CreatePostsFixture,
+  DeletePostParams,
+  DeletePostsFixture,
+  PostParams,
+  PostUpdateParams,
+  ThreadModeChangeParams,
+  UpdatePostsFixture,
+} from '../../fixtures/proposalsDiscussion'
+import { Resource } from '../../Resources'
+import { ThreadId } from '../../../../../types/common'
+import { ALL_BYTES } from '../../consts'
+
+export default async function proposalsDiscussion({ api, query, lock }: FlowProps): Promise<void> {
+  const debug = Debugger('flow:proposals-discussion')
+  debug('Started')
+  api.enableDebugTxLogs()
+
+  const threadsN = 3
+  const accounts = (await api.createKeyPairs(threadsN)).map((kp) => kp.address)
+
+  const buyMembershipsFixture = new BuyMembershipHappyCaseFixture(api, query, accounts)
+  await new FixtureRunner(buyMembershipsFixture).run()
+  const memberIds = buyMembershipsFixture.getCreatedMembers()
+
+  const unlocks = await Promise.all(Array.from({ length: threadsN }, () => lock(Resource.Proposals)))
+  const createProposalFixture = new CreateProposalsFixture(
+    api,
+    query,
+    Array.from({ length: threadsN }, (v, i) => ({
+      type: 'Signal',
+      details: `Discussion test ${i}`,
+      asMember: memberIds[i],
+      title: `Discussion test proposal ${i}`,
+      description: `Proposals discussion test proposal ${i}`,
+    }))
+  )
+  await new FixtureRunner(createProposalFixture).run()
+  const proposalsIds = createProposalFixture.getCreatedProposalsIds()
+  const threadIds = await api.query.proposalsCodex.threadIdByProposalId.multi<ThreadId>(proposalsIds)
+
+  const createPostsParams: PostParams[] = threadIds.reduce(
+    (posts, threadId) =>
+      posts.concat([
+        // Standard case:
+        {
+          threadId,
+          asMember: memberIds[0],
+          metadata: { value: { text: 'Test' } },
+          editable: true,
+        },
+        // Invalid repliesTo case:
+        {
+          threadId,
+          asMember: memberIds[1],
+          metadata: { value: { text: 'Test', repliesTo: 9999 }, expectReplyFailure: true },
+          editable: true,
+        },
+        // ALL_BYTES metadata + non-editable case:
+        {
+          threadId,
+          asMember: memberIds[2],
+          metadata: { value: ALL_BYTES, expectFailure: true }, // expectFailure just means serialization failure, but the value will still be checked
+          editable: false,
+        },
+      ]),
+    [] as PostParams[]
+  )
+  const createPostsFixture = new CreatePostsFixture(api, query, createPostsParams)
+  await new FixtureRunner(createPostsFixture).runWithQueryNodeChecks()
+  const postIds = createPostsFixture.getCreatedPostsIds()
+
+  const threadModeChangesParams: ThreadModeChangeParams[] = [
+    { threadId: threadIds[0], asMember: memberIds[0], newMode: { Closed: memberIds } },
+    { threadId: threadIds[1], asMember: memberIds[1], newMode: { Closed: [memberIds[0]] } },
+    { threadId: threadIds[1], asMember: memberIds[1], newMode: 'Open' },
+  ]
+  const threadModeChanges = new ChangeThreadsModeFixture(api, query, threadModeChangesParams)
+  const threadModeChangesRunner = new FixtureRunner(threadModeChanges)
+  await threadModeChangesRunner.run()
+
+  const createPostRepliesParams: PostParams[] = createPostsParams.map((params, i) => ({
+    threadId: params.threadId,
+    asMember: memberIds[i % memberIds.length],
+    metadata: { value: { text: `Reply to post ${postIds[i].toString()}`, repliesTo: postIds[i].toNumber() } },
+  }))
+  const createRepliesFixture = new CreatePostsFixture(api, query, createPostRepliesParams)
+  const createRepliesRunner = new FixtureRunner(createRepliesFixture)
+  await createRepliesRunner.run()
+
+  const updatePostsParams: PostUpdateParams[] = [
+    { threadId: threadIds[0], postId: postIds[0], asMember: memberIds[0], newText: 'New text' },
+    { threadId: threadIds[0], postId: postIds[1], asMember: memberIds[1], newText: ALL_BYTES },
+  ]
+  const updatePostsFixture = new UpdatePostsFixture(api, query, updatePostsParams)
+  const updatePostsRunner = new FixtureRunner(updatePostsFixture)
+  await updatePostsRunner.run()
+
+  // TODO: Test anyone_can_delete_post (would require waiting PostLifetime)
+
+  const deletePostsParams: DeletePostParams[] = postIds
+    .map((postId, i) => ({ postId, ...createPostsParams[i] }))
+    .filter((p) => p.editable !== false)
+  const deletePostsFixture = new DeletePostsFixture(api, query, deletePostsParams)
+  const deletePostsRunner = new FixtureRunner(deletePostsFixture)
+  await deletePostsRunner.run()
+
+  // Run compound query-node checks
+  await Promise.all([
+    createRepliesRunner.runQueryNodeChecks(),
+    threadModeChangesRunner.runQueryNodeChecks(),
+    updatePostsRunner.runQueryNodeChecks(),
+    deletePostsRunner.runQueryNodeChecks(),
+  ])
+
+  // Wait until proposal expires and release locks
+  await new FixtureRunner(new ExpireProposalsFixture(api, query, proposalsIds)).run()
+  unlocks.forEach((unlock) => unlock())
+
+  debug('Done')
+}

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

@@ -1038,6 +1038,12 @@ export type ProposalFieldsFragment = {
     | ProposalStatusFields_ProposalStatusCancelled_Fragment
     | ProposalStatusFields_ProposalStatusCanceledByRuntime_Fragment
   createdInEvent: { id: string; inBlock: number; inExtrinsic?: Types.Maybe<string> }
+  discussionThread: {
+    id: string
+    mode:
+      | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+      | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  }
 }
 
 export type GetProposalsByIdsQueryVariables = Types.Exact<{
@@ -1046,6 +1052,157 @@ export type GetProposalsByIdsQueryVariables = Types.Exact<{
 
 export type GetProposalsByIdsQuery = { proposals: Array<ProposalFieldsFragment> }
 
+type ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment = {
+  __typename: 'ProposalDiscussionThreadModeOpen'
+}
+
+type ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment = {
+  __typename: 'ProposalDiscussionThreadModeClosed'
+  whitelist?: Types.Maybe<{ members: Array<{ id: string }> }>
+}
+
+export type ProposalDiscussionThreadModeFieldsFragment =
+  | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+  | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusActive'
+}
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusLocked'
+  deletedInEvent?: Types.Maybe<{ id: string }>
+}
+
+type ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment = {
+  __typename: 'ProposalDiscussionPostStatusRemoved'
+  deletedInEvent?: Types.Maybe<{ id: string }>
+}
+
+export type ProposalDiscussionPostStatusFieldsFragment =
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment
+  | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment
+
+export type ProposalDiscussionThreadFieldsFragment = {
+  id: string
+  proposal: { id: string }
+  posts: Array<{ id: string }>
+  mode:
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  modeChanges: Array<{ id: string }>
+}
+
+export type GetProposalDiscussionThreadsByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionThreadsByIdsQuery = {
+  proposalDiscussionThreads: Array<ProposalDiscussionThreadFieldsFragment>
+}
+
+export type ProposalDiscussionPostFieldsFragment = {
+  id: string
+  text: string
+  discussionThread: { id: string }
+  author: { id: string }
+  status:
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusActive_Fragment
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusLocked_Fragment
+    | ProposalDiscussionPostStatusFields_ProposalDiscussionPostStatusRemoved_Fragment
+  repliesTo?: Types.Maybe<{ id: string }>
+  updates: Array<{ id: string }>
+  createdInEvent: { id: string }
+}
+
+export type GetProposalDiscussionPostsByIdsQueryVariables = Types.Exact<{
+  ids?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostsByIdsQuery = {
+  proposalDiscussionPosts: Array<ProposalDiscussionPostFieldsFragment>
+}
+
+export type ProposalDiscussionPostCreatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  text: string
+  post: { id: string }
+}
+
+export type GetProposalDiscussionPostCreatedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostCreatedEventsQuery = {
+  proposalDiscussionPostCreatedEvents: Array<ProposalDiscussionPostCreatedEventFieldsFragment>
+}
+
+export type ProposalDiscussionPostUpdatedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  text: string
+  post: { id: string }
+}
+
+export type GetProposalDiscussionPostUpdatedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostUpdatedEventsQuery = {
+  proposalDiscussionPostUpdatedEvents: Array<ProposalDiscussionPostUpdatedEventFieldsFragment>
+}
+
+export type ProposalDiscussionThreadModeChangedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  thread: { id: string }
+  newMode:
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeOpen_Fragment
+    | ProposalDiscussionThreadModeFields_ProposalDiscussionThreadModeClosed_Fragment
+  actor: { id: string }
+}
+
+export type GetProposalDiscussionThreadModeChangedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionThreadModeChangedEventsQuery = {
+  proposalDiscussionThreadModeChangedEvents: Array<ProposalDiscussionThreadModeChangedEventFieldsFragment>
+}
+
+export type ProposalDiscussionPostDeletedEventFieldsFragment = {
+  id: string
+  createdAt: any
+  inExtrinsic?: Types.Maybe<string>
+  inBlock: number
+  network: Types.Network
+  indexInBlock: number
+  post: { id: string }
+  actor: { id: string }
+}
+
+export type GetProposalDiscussionPostDeletedEventsQueryVariables = Types.Exact<{
+  eventIds?: Types.Maybe<Array<Types.Scalars['ID']> | Types.Scalars['ID']>
+}>
+
+export type GetProposalDiscussionPostDeletedEventsQuery = {
+  proposalDiscussionPostDeletedEvents: Array<ProposalDiscussionPostDeletedEventFieldsFragment>
+}
+
 export type ProposalCreatedEventFieldsFragment = {
   id: string
   createdAt: any
@@ -2626,6 +2783,18 @@ export const ProposalStatusFields = gql`
     }
   }
 `
+export const ProposalDiscussionThreadModeFields = gql`
+  fragment ProposalDiscussionThreadModeFields on ProposalDiscussionThreadMode {
+    __typename
+    ... on ProposalDiscussionThreadModeClosed {
+      whitelist {
+        members {
+          id
+        }
+      }
+    }
+  }
+`
 export const ProposalFields = gql`
   fragment ProposalFields on Proposal {
     id
@@ -2661,9 +2830,138 @@ export const ProposalFields = gql`
       inBlock
       inExtrinsic
     }
+    discussionThread {
+      id
+      mode {
+        ...ProposalDiscussionThreadModeFields
+      }
+    }
   }
   ${ProposalDetailsFields}
   ${ProposalStatusFields}
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionThreadFields = gql`
+  fragment ProposalDiscussionThreadFields on ProposalDiscussionThread {
+    id
+    proposal {
+      id
+    }
+    posts {
+      id
+    }
+    mode {
+      ...ProposalDiscussionThreadModeFields
+    }
+    modeChanges {
+      id
+    }
+  }
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionPostStatusFields = gql`
+  fragment ProposalDiscussionPostStatusFields on ProposalDiscussionPostStatus {
+    __typename
+    ... on ProposalDiscussionPostStatusLocked {
+      deletedInEvent {
+        id
+      }
+    }
+    ... on ProposalDiscussionPostStatusRemoved {
+      deletedInEvent {
+        id
+      }
+    }
+  }
+`
+export const ProposalDiscussionPostFields = gql`
+  fragment ProposalDiscussionPostFields on ProposalDiscussionPost {
+    id
+    discussionThread {
+      id
+    }
+    author {
+      id
+    }
+    status {
+      ...ProposalDiscussionPostStatusFields
+    }
+    text
+    repliesTo {
+      id
+    }
+    updates {
+      id
+    }
+    createdInEvent {
+      id
+    }
+  }
+  ${ProposalDiscussionPostStatusFields}
+`
+export const ProposalDiscussionPostCreatedEventFields = gql`
+  fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    text
+  }
+`
+export const ProposalDiscussionPostUpdatedEventFields = gql`
+  fragment ProposalDiscussionPostUpdatedEventFields on ProposalDiscussionPostUpdatedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    text
+  }
+`
+export const ProposalDiscussionThreadModeChangedEventFields = gql`
+  fragment ProposalDiscussionThreadModeChangedEventFields on ProposalDiscussionThreadModeChangedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    thread {
+      id
+    }
+    newMode {
+      ...ProposalDiscussionThreadModeFields
+    }
+    actor {
+      id
+    }
+  }
+  ${ProposalDiscussionThreadModeFields}
+`
+export const ProposalDiscussionPostDeletedEventFields = gql`
+  fragment ProposalDiscussionPostDeletedEventFields on ProposalDiscussionPostDeletedEvent {
+    id
+    createdAt
+    inExtrinsic
+    inBlock
+    network
+    indexInBlock
+    post {
+      id
+    }
+    actor {
+      id
+    }
+  }
 `
 export const ProposalCreatedEventFields = gql`
   fragment ProposalCreatedEventFields on ProposalCreatedEvent {
@@ -3600,6 +3898,54 @@ export const GetProposalsByIds = gql`
   }
   ${ProposalFields}
 `
+export const GetProposalDiscussionThreadsByIds = gql`
+  query getProposalDiscussionThreadsByIds($ids: [ID!]) {
+    proposalDiscussionThreads(where: { id_in: $ids }) {
+      ...ProposalDiscussionThreadFields
+    }
+  }
+  ${ProposalDiscussionThreadFields}
+`
+export const GetProposalDiscussionPostsByIds = gql`
+  query getProposalDiscussionPostsByIds($ids: [ID!]) {
+    proposalDiscussionPosts(where: { id_in: $ids }) {
+      ...ProposalDiscussionPostFields
+    }
+  }
+  ${ProposalDiscussionPostFields}
+`
+export const GetProposalDiscussionPostCreatedEvents = gql`
+  query getProposalDiscussionPostCreatedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostCreatedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostCreatedEventFields
+    }
+  }
+  ${ProposalDiscussionPostCreatedEventFields}
+`
+export const GetProposalDiscussionPostUpdatedEvents = gql`
+  query getProposalDiscussionPostUpdatedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostUpdatedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostUpdatedEventFields
+    }
+  }
+  ${ProposalDiscussionPostUpdatedEventFields}
+`
+export const GetProposalDiscussionThreadModeChangedEvents = gql`
+  query getProposalDiscussionThreadModeChangedEvents($eventIds: [ID!]) {
+    proposalDiscussionThreadModeChangedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionThreadModeChangedEventFields
+    }
+  }
+  ${ProposalDiscussionThreadModeChangedEventFields}
+`
+export const GetProposalDiscussionPostDeletedEvents = gql`
+  query getProposalDiscussionPostDeletedEvents($eventIds: [ID!]) {
+    proposalDiscussionPostDeletedEvents(where: { id_in: $eventIds }) {
+      ...ProposalDiscussionPostDeletedEventFields
+    }
+  }
+  ${ProposalDiscussionPostDeletedEventFields}
+`
 export const GetProposalCreatedEventsByEventIds = gql`
   query getProposalCreatedEventsByEventIds($eventIds: [ID!]) {
     proposalCreatedEvents(where: { id_in: $eventIds }) {

+ 17 - 157
tests/integration-tests/src/graphql/generated/schema.ts

@@ -2913,7 +2913,6 @@ export enum EventTypeOptions {
   ProposalDiscussionPostCreatedEvent = 'ProposalDiscussionPostCreatedEvent',
   ProposalDiscussionPostDeletedEvent = 'ProposalDiscussionPostDeletedEvent',
   ProposalDiscussionPostUpdatedEvent = 'ProposalDiscussionPostUpdatedEvent',
-  ProposalDiscussionThreadCreatedEvent = 'ProposalDiscussionThreadCreatedEvent',
   ProposalDiscussionThreadModeChangedEvent = 'ProposalDiscussionThreadModeChangedEvent',
   ProposalExecutedEvent = 'ProposalExecutedEvent',
   ProposalStatusUpdatedEvent = 'ProposalStatusUpdatedEvent',
@@ -8750,6 +8749,8 @@ export type ProposalDiscussionPost = BaseGraphQlObject & {
   deletedAt?: Maybe<Scalars['DateTime']>
   deletedById?: Maybe<Scalars['String']>
   version: Scalars['Int']
+  discussionThread: ProposalDiscussionThread
+  discussionThreadId: Scalars['String']
   author: Membership
   authorId: Scalars['String']
   /** Current post status */
@@ -8907,6 +8908,7 @@ export type ProposalDiscussionPostCreatedEventWhereUniqueInput = {
 }
 
 export type ProposalDiscussionPostCreateInput = {
+  discussionThread: Scalars['ID']
   author: Scalars['ID']
   status: Scalars['JSONObject']
   text: Scalars['String']
@@ -9059,6 +9061,8 @@ export enum ProposalDiscussionPostOrderByInput {
   UpdatedAtDesc = 'updatedAt_DESC',
   DeletedAtAsc = 'deletedAt_ASC',
   DeletedAtDesc = 'deletedAt_DESC',
+  DiscussionThreadAsc = 'discussionThread_ASC',
+  DiscussionThreadDesc = 'discussionThread_DESC',
   AuthorAsc = 'author_ASC',
   AuthorDesc = 'author_DESC',
   TextAsc = 'text_ASC',
@@ -9270,6 +9274,7 @@ export type ProposalDiscussionPostUpdatedEventWhereUniqueInput = {
 }
 
 export type ProposalDiscussionPostUpdateInput = {
+  discussionThread?: Maybe<Scalars['ID']>
   author?: Maybe<Scalars['ID']>
   status?: Maybe<Scalars['JSONObject']>
   text?: Maybe<Scalars['String']>
@@ -9301,6 +9306,8 @@ export type ProposalDiscussionPostWhereInput = {
   deletedAt_gte?: Maybe<Scalars['DateTime']>
   deletedById_eq?: Maybe<Scalars['ID']>
   deletedById_in?: Maybe<Array<Scalars['ID']>>
+  discussionThread_eq?: Maybe<Scalars['ID']>
+  discussionThread_in?: Maybe<Array<Scalars['ID']>>
   author_eq?: Maybe<Scalars['ID']>
   author_in?: Maybe<Array<Scalars['ID']>>
   status_json?: Maybe<Scalars['JSONObject']>
@@ -9311,6 +9318,7 @@ export type ProposalDiscussionPostWhereInput = {
   text_in?: Maybe<Array<Scalars['String']>>
   repliesTo_eq?: Maybe<Scalars['ID']>
   repliesTo_in?: Maybe<Array<Scalars['ID']>>
+  discussionThread?: Maybe<ProposalDiscussionThreadWhereInput>
   author?: Maybe<MembershipWhereInput>
   repliesTo?: Maybe<ProposalDiscussionPostWhereInput>
   updates_none?: Maybe<ProposalDiscussionPostUpdatedEventWhereInput>
@@ -9342,10 +9350,10 @@ export type ProposalDiscussionThread = BaseGraphQlObject & {
   version: Scalars['Int']
   proposal: Proposal
   proposalId: Scalars['String']
+  posts: Array<ProposalDiscussionPost>
   /** Current thread mode */
   mode: ProposalDiscussionThreadMode
-  proposaldiscussionthreadcreatedeventthread?: Maybe<Array<ProposalDiscussionThreadCreatedEvent>>
-  proposaldiscussionthreadmodechangedeventthread?: Maybe<Array<ProposalDiscussionThreadModeChangedEvent>>
+  modeChanges: Array<ProposalDiscussionThreadModeChangedEvent>
 }
 
 export type ProposalDiscussionThreadConnection = {
@@ -9354,131 +9362,6 @@ export type ProposalDiscussionThreadConnection = {
   pageInfo: PageInfo
 }
 
-export type ProposalDiscussionThreadCreatedEvent = Event &
-  BaseGraphQlObject & {
-    /** Hash of the extrinsic which caused the event to be emitted */
-    inExtrinsic?: Maybe<Scalars['String']>
-    /** Blocknumber of the block in which the event was emitted. */
-    inBlock: Scalars['Int']
-    /** Network the block was produced in */
-    network: Network
-    /** Index of event in block from which it was emitted. */
-    indexInBlock: Scalars['Int']
-    /** Filtering options for interface implementers */
-    type?: Maybe<EventTypeOptions>
-    id: Scalars['ID']
-    createdAt: Scalars['DateTime']
-    createdById: Scalars['String']
-    updatedAt?: Maybe<Scalars['DateTime']>
-    updatedById?: Maybe<Scalars['String']>
-    deletedAt?: Maybe<Scalars['DateTime']>
-    deletedById?: Maybe<Scalars['String']>
-    version: Scalars['Int']
-    thread: ProposalDiscussionThread
-    threadId: Scalars['String']
-  }
-
-export type ProposalDiscussionThreadCreatedEventConnection = {
-  totalCount: Scalars['Int']
-  edges: Array<ProposalDiscussionThreadCreatedEventEdge>
-  pageInfo: PageInfo
-}
-
-export type ProposalDiscussionThreadCreatedEventCreateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock: Scalars['Float']
-  network: Network
-  indexInBlock: Scalars['Float']
-  thread: Scalars['ID']
-}
-
-export type ProposalDiscussionThreadCreatedEventEdge = {
-  node: ProposalDiscussionThreadCreatedEvent
-  cursor: Scalars['String']
-}
-
-export enum ProposalDiscussionThreadCreatedEventOrderByInput {
-  CreatedAtAsc = 'createdAt_ASC',
-  CreatedAtDesc = 'createdAt_DESC',
-  UpdatedAtAsc = 'updatedAt_ASC',
-  UpdatedAtDesc = 'updatedAt_DESC',
-  DeletedAtAsc = 'deletedAt_ASC',
-  DeletedAtDesc = 'deletedAt_DESC',
-  InExtrinsicAsc = 'inExtrinsic_ASC',
-  InExtrinsicDesc = 'inExtrinsic_DESC',
-  InBlockAsc = 'inBlock_ASC',
-  InBlockDesc = 'inBlock_DESC',
-  NetworkAsc = 'network_ASC',
-  NetworkDesc = 'network_DESC',
-  IndexInBlockAsc = 'indexInBlock_ASC',
-  IndexInBlockDesc = 'indexInBlock_DESC',
-  ThreadAsc = 'thread_ASC',
-  ThreadDesc = 'thread_DESC',
-}
-
-export type ProposalDiscussionThreadCreatedEventUpdateInput = {
-  inExtrinsic?: Maybe<Scalars['String']>
-  inBlock?: Maybe<Scalars['Float']>
-  network?: Maybe<Network>
-  indexInBlock?: Maybe<Scalars['Float']>
-  thread?: Maybe<Scalars['ID']>
-}
-
-export type ProposalDiscussionThreadCreatedEventWhereInput = {
-  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']>>
-  inExtrinsic_eq?: Maybe<Scalars['String']>
-  inExtrinsic_contains?: Maybe<Scalars['String']>
-  inExtrinsic_startsWith?: Maybe<Scalars['String']>
-  inExtrinsic_endsWith?: Maybe<Scalars['String']>
-  inExtrinsic_in?: Maybe<Array<Scalars['String']>>
-  inBlock_eq?: Maybe<Scalars['Int']>
-  inBlock_gt?: Maybe<Scalars['Int']>
-  inBlock_gte?: Maybe<Scalars['Int']>
-  inBlock_lt?: Maybe<Scalars['Int']>
-  inBlock_lte?: Maybe<Scalars['Int']>
-  inBlock_in?: Maybe<Array<Scalars['Int']>>
-  network_eq?: Maybe<Network>
-  network_in?: Maybe<Array<Network>>
-  indexInBlock_eq?: Maybe<Scalars['Int']>
-  indexInBlock_gt?: Maybe<Scalars['Int']>
-  indexInBlock_gte?: Maybe<Scalars['Int']>
-  indexInBlock_lt?: Maybe<Scalars['Int']>
-  indexInBlock_lte?: Maybe<Scalars['Int']>
-  indexInBlock_in?: Maybe<Array<Scalars['Int']>>
-  thread_eq?: Maybe<Scalars['ID']>
-  thread_in?: Maybe<Array<Scalars['ID']>>
-  thread?: Maybe<ProposalDiscussionThreadWhereInput>
-  AND?: Maybe<Array<ProposalDiscussionThreadCreatedEventWhereInput>>
-  OR?: Maybe<Array<ProposalDiscussionThreadCreatedEventWhereInput>>
-}
-
-export type ProposalDiscussionThreadCreatedEventWhereUniqueInput = {
-  id: Scalars['ID']
-}
-
 export type ProposalDiscussionThreadCreateInput = {
   proposal: Scalars['ID']
   mode: Scalars['JSONObject']
@@ -9731,12 +9614,12 @@ export type ProposalDiscussionThreadWhereInput = {
   proposal_in?: Maybe<Array<Scalars['ID']>>
   mode_json?: Maybe<Scalars['JSONObject']>
   proposal?: Maybe<ProposalWhereInput>
-  proposaldiscussionthreadcreatedeventthread_none?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadcreatedeventthread_some?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadcreatedeventthread_every?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_none?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_some?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
-  proposaldiscussionthreadmodechangedeventthread_every?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  posts_none?: Maybe<ProposalDiscussionPostWhereInput>
+  posts_some?: Maybe<ProposalDiscussionPostWhereInput>
+  posts_every?: Maybe<ProposalDiscussionPostWhereInput>
+  modeChanges_none?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  modeChanges_some?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
+  modeChanges_every?: Maybe<ProposalDiscussionThreadModeChangedEventWhereInput>
   AND?: Maybe<Array<ProposalDiscussionThreadWhereInput>>
   OR?: Maybe<Array<ProposalDiscussionThreadWhereInput>>
 }
@@ -10718,9 +10601,6 @@ export type Query = {
   proposalDiscussionPosts: Array<ProposalDiscussionPost>
   proposalDiscussionPostByUniqueInput?: Maybe<ProposalDiscussionPost>
   proposalDiscussionPostsConnection: ProposalDiscussionPostConnection
-  proposalDiscussionThreadCreatedEvents: Array<ProposalDiscussionThreadCreatedEvent>
-  proposalDiscussionThreadCreatedEventByUniqueInput?: Maybe<ProposalDiscussionThreadCreatedEvent>
-  proposalDiscussionThreadCreatedEventsConnection: ProposalDiscussionThreadCreatedEventConnection
   proposalDiscussionThreadModeChangedEvents: Array<ProposalDiscussionThreadModeChangedEvent>
   proposalDiscussionThreadModeChangedEventByUniqueInput?: Maybe<ProposalDiscussionThreadModeChangedEvent>
   proposalDiscussionThreadModeChangedEventsConnection: ProposalDiscussionThreadModeChangedEventConnection
@@ -12001,26 +11881,6 @@ export type QueryProposalDiscussionPostsConnectionArgs = {
   orderBy?: Maybe<Array<ProposalDiscussionPostOrderByInput>>
 }
 
-export type QueryProposalDiscussionThreadCreatedEventsArgs = {
-  offset?: Maybe<Scalars['Int']>
-  limit?: Maybe<Scalars['Int']>
-  where?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  orderBy?: Maybe<Array<ProposalDiscussionThreadCreatedEventOrderByInput>>
-}
-
-export type QueryProposalDiscussionThreadCreatedEventByUniqueInputArgs = {
-  where: ProposalDiscussionThreadCreatedEventWhereUniqueInput
-}
-
-export type QueryProposalDiscussionThreadCreatedEventsConnectionArgs = {
-  first?: Maybe<Scalars['Int']>
-  after?: Maybe<Scalars['String']>
-  last?: Maybe<Scalars['Int']>
-  before?: Maybe<Scalars['String']>
-  where?: Maybe<ProposalDiscussionThreadCreatedEventWhereInput>
-  orderBy?: Maybe<Array<ProposalDiscussionThreadCreatedEventOrderByInput>>
-}
-
 export type QueryProposalDiscussionThreadModeChangedEventsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>

+ 6 - 0
tests/integration-tests/src/graphql/queries/proposals.graphql

@@ -270,6 +270,12 @@ fragment ProposalFields on Proposal {
     inBlock
     inExtrinsic
   }
+  discussionThread {
+    id
+    mode {
+      ...ProposalDiscussionThreadModeFields
+    }
+  }
 }
 
 query getProposalsByIds($ids: [ID!]) {

+ 75 - 0
tests/integration-tests/src/graphql/queries/proposalsDiscussion.graphql

@@ -0,0 +1,75 @@
+fragment ProposalDiscussionThreadModeFields on ProposalDiscussionThreadMode {
+  __typename
+  ... on ProposalDiscussionThreadModeClosed {
+    whitelist {
+      members {
+        id
+      }
+    }
+  }
+}
+
+fragment ProposalDiscussionPostStatusFields on ProposalDiscussionPostStatus {
+  __typename
+  ... on ProposalDiscussionPostStatusLocked {
+    deletedInEvent {
+      id
+    }
+  }
+  ... on ProposalDiscussionPostStatusRemoved {
+    deletedInEvent {
+      id
+    }
+  }
+}
+
+fragment ProposalDiscussionThreadFields on ProposalDiscussionThread {
+  id
+  proposal {
+    id
+  }
+  posts {
+    id
+  }
+  mode {
+    ...ProposalDiscussionThreadModeFields
+  }
+  modeChanges {
+    id
+  }
+}
+
+query getProposalDiscussionThreadsByIds($ids: [ID!]) {
+  proposalDiscussionThreads(where: { id_in: $ids }) {
+    ...ProposalDiscussionThreadFields
+  }
+}
+
+fragment ProposalDiscussionPostFields on ProposalDiscussionPost {
+  id
+  discussionThread {
+    id
+  }
+  author {
+    id
+  }
+  status {
+    ...ProposalDiscussionPostStatusFields
+  }
+  text
+  repliesTo {
+    id
+  }
+  updates {
+    id
+  }
+  createdInEvent {
+    id
+  }
+}
+
+query getProposalDiscussionPostsByIds($ids: [ID!]) {
+  proposalDiscussionPosts(where: { id_in: $ids }) {
+    ...ProposalDiscussionPostFields
+  }
+}

+ 82 - 0
tests/integration-tests/src/graphql/queries/proposalsDiscussionEvents.graphql

@@ -0,0 +1,82 @@
+fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  text
+}
+
+query getProposalDiscussionPostCreatedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostCreatedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostCreatedEventFields
+  }
+}
+
+fragment ProposalDiscussionPostUpdatedEventFields on ProposalDiscussionPostUpdatedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  text
+}
+
+query getProposalDiscussionPostUpdatedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostUpdatedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostUpdatedEventFields
+  }
+}
+
+fragment ProposalDiscussionThreadModeChangedEventFields on ProposalDiscussionThreadModeChangedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  thread {
+    id
+  }
+  newMode {
+    ...ProposalDiscussionThreadModeFields
+  }
+  actor {
+    id
+  }
+}
+
+query getProposalDiscussionThreadModeChangedEvents($eventIds: [ID!]) {
+  proposalDiscussionThreadModeChangedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionThreadModeChangedEventFields
+  }
+}
+
+fragment ProposalDiscussionPostDeletedEventFields on ProposalDiscussionPostDeletedEvent {
+  id
+  createdAt
+  inExtrinsic
+  inBlock
+  network
+  indexInBlock
+  post {
+    id
+  }
+  actor {
+    id
+  }
+}
+
+query getProposalDiscussionPostDeletedEvents($eventIds: [ID!]) {
+  proposalDiscussionPostDeletedEvents(where: { id_in: $eventIds }) {
+    ...ProposalDiscussionPostDeletedEventFields
+  }
+}

+ 3 - 1
tests/integration-tests/src/scenarios/full.ts

@@ -24,6 +24,7 @@ import electCouncil from '../flows/council/elect'
 import runtimeUpgradeProposal from '../flows/proposals/runtimeUpgradeProposal'
 import exactExecutionBlock from '../flows/proposals/exactExecutionBlock'
 import expireProposal from '../flows/proposals/expireProposal'
+import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job, env }) => {
@@ -50,12 +51,13 @@ scenario(async ({ job, env }) => {
   job('managing staking accounts', managingStakingAccounts).after(membershipSystemJob)
 
   // Proposals:
-  const proposalsJob = job('proposals', [
+  const proposalsJob = job('proposals & proposal discussion', [
     proposals,
     cancellingProposals,
     vetoProposal,
     exactExecutionBlock,
     expireProposal,
+    proposalsDiscussion,
   ]).requires(membershipSystemJob)
 
   // Working groups

+ 9 - 3
tests/integration-tests/src/scenarios/proposals.ts

@@ -5,6 +5,7 @@ import electCouncil from '../flows/council/elect'
 import runtimeUpgradeProposal from '../flows/proposals/runtimeUpgradeProposal'
 import exactExecutionBlock from '../flows/proposals/exactExecutionBlock'
 import expireProposal from '../flows/proposals/expireProposal'
+import proposalsDiscussion from '../flows/proposalsDiscussion'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job, env }) => {
@@ -12,7 +13,12 @@ scenario(async ({ job, env }) => {
   const runtimeUpgradeProposalJob = env.RUNTIME_UPGRADE_TARGET_WASM_PATH
     ? job('runtime upgrade proposal', runtimeUpgradeProposal).requires(councilJob)
     : undefined
-  job('proposals', [proposals, cancellingProposals, vetoProposal, exactExecutionBlock, expireProposal]).requires(
-    runtimeUpgradeProposalJob || councilJob
-  )
+  job('proposals & proposal discussion', [
+    proposals,
+    cancellingProposals,
+    vetoProposal,
+    exactExecutionBlock,
+    expireProposal,
+    proposalsDiscussion,
+  ]).requires(runtimeUpgradeProposalJob || councilJob)
 })

+ 8 - 0
tests/integration-tests/src/scenarios/proposalsDiscussion.ts

@@ -0,0 +1,8 @@
+import electCouncil from '../flows/council/elect'
+import proposalsDiscussion from '../flows/proposalsDiscussion'
+import { scenario } from '../Scenario'
+
+scenario(async ({ job, env }) => {
+  const councilJob = job('electing council', electCouncil)
+  job('proposal discussion', [proposalsDiscussion]).requires(councilJob)
+})

+ 10 - 0
tests/integration-tests/src/types.ts

@@ -116,6 +116,16 @@ export type ProposalsEngineEventName =
   | 'ProposalCancelled'
 
 export type ProposalsCodexEventName = 'ProposalCreated'
+export type ProposalsDiscussionEventName =
+  | 'ThreadCreated'
+  | 'PostCreated'
+  | 'PostUpdated'
+  | 'ThreadModeChanged'
+  | 'PostDeleted'
+
+export interface ProposalDiscussionPostCreatedEventDetails extends EventDetails {
+  postId: PostId
+}
 
 export type ProposalType = keyof typeof ProposalDetails.typeDefinitions
 export type ProposalDetailsJsonByType<T extends ProposalType = ProposalType> = CreateInterface<

+ 4 - 0
tests/integration-tests/src/utils.ts

@@ -95,6 +95,10 @@ export class Utils {
     )
   }
 
+  public static asText(textOrHex: string): string {
+    return Utils.bytesToString(createType('Bytes', textOrHex))
+  }
+
   public static assert(condition: any, msg?: string): asserts condition {
     if (!condition) {
       throw new Error(msg || 'Assertion failed')

+ 35 - 95
yarn.lock

@@ -3130,7 +3130,7 @@ anymatch@^2.0.0:
     micromatch "^3.1.4"
     normalize-path "^2.1.1"
 
-anymatch@~3.1.1:
+anymatch@~3.1.1, anymatch@~3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
   integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
@@ -3632,9 +3632,9 @@ aws-credstash@^3.0.0:
     debug "^4.3.1"
 
 aws-sdk@^2.567.0:
-  version "2.894.0"
-  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.894.0.tgz#acf740256ee051ea2c075549f7a10929c324d70a"
-  integrity sha512-qzmxkZ1JOQ/sQPIlxE+aOpJ9OlPq640ab1Ot8p4VIuatkGSEvQvk8nWqAeLyrRwmNQuacGM7xWTI0k9pGhbDXA==
+  version "2.936.0"
+  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.936.0.tgz#b69f5db7c0c745f260b014415a8fbfb38c5e615d"
+  integrity sha512-X0kuyycck0fEPN5V0Vw1PmPIQ4BO0qupsL1El5jnXzXxNkf1cOmn5PMSxPXPsdcqua4w4h3sf143/yME0V9w8g==
   dependencies:
     buffer "4.9.2"
     events "1.1.1"
@@ -4234,14 +4234,6 @@ camel-case@4.1.2, camel-case@^4.1.2:
     pascal-case "^3.1.2"
     tslib "^2.0.3"
 
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
-  dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
 camelcase-keys@^4.0.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77"
@@ -4260,11 +4252,6 @@ camelcase-keys@^6.2.2:
     map-obj "^4.0.0"
     quick-lru "^4.0.1"
 
-camelcase@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-
 camelcase@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
@@ -4412,7 +4399,7 @@ check-type@^0.4.11:
   dependencies:
     underscore "1.6.0"
 
-chokidar@3.5.1, chokidar@^3.4.3, chokidar@^3.5.1:
+chokidar@3.5.1, chokidar@^3.4.3:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
   integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
@@ -4446,6 +4433,21 @@ chokidar@^2.1.8:
   optionalDependencies:
     fsevents "^1.2.7"
 
+chokidar@^3.5.1:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75"
+  integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 chownr@^1.0.1, chownr@^1.1.1:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
@@ -5232,14 +5234,6 @@ dateformat@^3.0.2:
   resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
   integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
 
-dateformat@~1.0.4-1.2.3:
-  version "1.0.12"
-  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9"
-  integrity sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk=
-  dependencies:
-    get-stdin "^4.0.1"
-    meow "^3.3.0"
-
 datejs@^1.0.0-rc3:
   version "1.0.0-rc3"
   resolved "https://registry.yarnpkg.com/datejs/-/datejs-1.0.0-rc3.tgz#bffa1efedefeb41fdd8a242af55afa01fb58de57"
@@ -5286,7 +5280,7 @@ decamelize-keys@^1.0.0, decamelize-keys@^1.1.0:
     decamelize "^1.1.0"
     map-obj "^1.0.0"
 
-decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0:
+decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -7144,7 +7138,7 @@ fsevents@^1.2.7:
     bindings "^1.5.0"
     nan "^2.12.1"
 
-fsevents@~2.3.1:
+fsevents@~2.3.1, fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
@@ -7220,11 +7214,6 @@ get-own-enumerable-property-symbols@^3.0.0:
   resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664"
   integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==
 
-get-stdin@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
-
 get-stdin@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
@@ -7281,7 +7270,7 @@ glob-parent@^3.1.0:
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0:
+glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -8029,13 +8018,6 @@ imurmurhash@^0.1.4:
   resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
   integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
 
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
-
 indent-string@^3.0.0, indent-string@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289"
@@ -8719,11 +8701,6 @@ is-extglob@^2.1.0, is-extglob@^2.1.1:
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-finite@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
-  integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
-
 is-fullwidth-code-point@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
@@ -10627,7 +10604,7 @@ map-cache@^0.2.0, map-cache@^0.2.2:
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
   integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
 
-map-obj@^1.0.0, map-obj@^1.0.1:
+map-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
@@ -10706,22 +10683,6 @@ memory-fs@^0.5.0:
     errno "^0.1.3"
     readable-stream "^2.0.1"
 
-meow@^3.3.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
-
 meow@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4"
@@ -10903,7 +10864,7 @@ minimist@0.0.8:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
   integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
 
-minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
+minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
@@ -13251,6 +13212,13 @@ readdirp@~3.5.0:
   dependencies:
     picomatch "^2.2.1"
 
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
 realpath-native@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c"
@@ -13265,14 +13233,6 @@ receptacle@^1.3.2:
   dependencies:
     ms "^2.1.1"
 
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
-  dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
-
 redent@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa"
@@ -13440,13 +13400,6 @@ repeat-string@^1.6.1:
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
-repeating@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
-  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
-  dependencies:
-    is-finite "^1.0.0"
-
 replace-last@^1.2.6:
   version "1.2.6"
   resolved "https://registry.yarnpkg.com/replace-last/-/replace-last-1.2.6.tgz#dbf879942340bec325de67527a8f6156044fdb06"
@@ -14482,13 +14435,6 @@ strip-final-newline@^2.0.0:
   resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
   integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
 
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
-  dependencies:
-    get-stdin "^4.0.1"
-
 strip-indent@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
@@ -14935,11 +14881,6 @@ treeify@^1.1.0:
   resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8"
   integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==
 
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
-
 trim-newlines@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20"
@@ -14986,12 +14927,11 @@ ts-log@^2.1.4, ts-log@^2.2.3:
   integrity sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w==
 
 ts-node-dev@^1.0.0-pre.60:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.1.6.tgz#ee2113718cb5a92c1c8f4229123ad6afbeba01f8"
-  integrity sha512-RTUi7mHMNQospArGz07KiraQcdgUVNXKsgO2HAi7FoiyPMdTDqdniB6K1dqyaIxT7c9v/VpSbfBZPS6uVpaFLQ==
+  version "1.1.7"
+  resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.1.7.tgz#f157c25235e86a9e8ce3470b1ec04b89bacd90ff"
+  integrity sha512-/YvByJdIw/p88RXmaRB3Kkk+PiUP7g/EAbBvQjDIG+kkm0CMvhdHSB21yEiws22Uls4uFAfCiuEZM4929yjWjg==
   dependencies:
     chokidar "^3.5.1"
-    dateformat "~1.0.4-1.2.3"
     dynamic-dedupe "^0.3.0"
     minimist "^1.2.5"
     mkdirp "^1.0.4"