Ver código fonte

Fix merge conflicts

iorveth 3 anos atrás
pai
commit
f80e66d781
100 arquivos alterados com 4628 adições e 794 exclusões
  1. 2 0
      .github/workflows/integration-tests.yml
  2. 1 1
      .github/workflows/joystream-node-benchmarks.yml
  3. 2 2
      README.md
  4. 2 2
      cli/package.json
  5. 1 1
      cli/src/Api.ts
  6. 2 2
      cli/src/base/ApiCommandBase.ts
  7. 4 5
      cli/src/base/ContentDirectoryCommandBase.ts
  8. 1 1
      cli/src/commands/api/inspect.ts
  9. 1 1
      cli/src/commands/content/curatorGroup.ts
  10. 1 1
      cli/src/commands/content/curatorGroups.ts
  11. 1 1
      cli/src/commands/content/removeCuratorFromGroup.ts
  12. 3 2
      cli/src/commands/working-groups/fillOpening.ts
  13. 3 3
      devops/git-hooks/pre-push
  14. 2 2
      docker-compose.yml
  15. 4 4
      joystream-node.Dockerfile
  16. 205 4
      metadata-protobuf/compiled/index.d.ts
  17. 498 15
      metadata-protobuf/compiled/index.js
  18. 17 0
      metadata-protobuf/doc/index.md
  19. 3 2
      metadata-protobuf/package.json
  20. 12 0
      metadata-protobuf/proto/Forum.proto
  21. 4 1
      metadata-protobuf/proto/Membership.proto
  22. 6 0
      metadata-protobuf/proto/ProposalsDiscussion.proto
  23. 1 0
      metadata-protobuf/src/consts.ts
  24. 13 0
      metadata-protobuf/test/forum-tags.ts
  25. 3 3
      node/README.md
  26. 11 13
      package.json
  27. 2 3
      pioneer/packages/joy-election/src/VoteForm.tsx
  28. 1 1
      pioneer/packages/react-api/package.json
  29. 5 2
      query-node/codegen/package.json
  30. 609 226
      query-node/codegen/yarn.lock
  31. 80 12
      query-node/manifest.yml
  32. 10 0
      query-node/mappings/.eslintrc.js
  33. 0 35
      query-node/mappings/genesis.ts
  34. 9 7
      query-node/mappings/package.json
  35. 0 24
      query-node/mappings/proposalsDiscussion.ts
  36. 23 0
      query-node/mappings/scripts/postHydraCLIInstall.ts
  37. 45 0
      query-node/mappings/scripts/postInstall.ts
  38. 19 0
      query-node/mappings/scripts/utils.ts
  39. 10 3
      query-node/mappings/src/common.ts
  40. 2 2
      query-node/mappings/src/content/channel.ts
  41. 2 2
      query-node/mappings/src/content/curatorGroup.ts
  42. 0 0
      query-node/mappings/src/content/index.ts
  43. 1 1
      query-node/mappings/src/content/utils.ts
  44. 2 2
      query-node/mappings/src/content/video.ts
  45. 985 0
      query-node/mappings/src/council.ts
  46. 174 38
      query-node/mappings/src/forum.ts
  47. 0 0
      query-node/mappings/src/genesis-data/index.ts
  48. 0 0
      query-node/mappings/src/genesis-data/members.json
  49. 0 0
      query-node/mappings/src/genesis-data/membershipSystem.json
  50. 0 0
      query-node/mappings/src/genesis-data/workers.json
  51. 0 0
      query-node/mappings/src/genesis-data/workingGroups.json
  52. 67 0
      query-node/mappings/src/genesis.ts
  53. 1 0
      query-node/mappings/src/index.ts
  54. 24 15
      query-node/mappings/src/membership.ts
  55. 26 23
      query-node/mappings/src/proposals.ts
  56. 186 0
      query-node/mappings/src/proposalsDiscussion.ts
  57. 2 2
      query-node/mappings/src/storage.ts
  58. 2 2
      query-node/mappings/src/workingGroups.ts
  59. 1 1
      query-node/mappings/tsconfig.json
  60. 2 2
      query-node/package.json
  61. 282 0
      query-node/schemas/council.graphql
  62. 563 0
      query-node/schemas/councilEvents.graphql
  63. 28 2
      query-node/schemas/forum.graphql
  64. 17 32
      query-node/schemas/forumEvents.graphql
  65. 27 1
      query-node/schemas/membership.graphql
  66. 8 2
      query-node/schemas/proposalDiscussion.graphql
  67. 0 24
      query-node/schemas/proposalDiscussionEvents.graphql
  68. 3 0
      query-node/schemas/proposals.graphql
  69. 0 155
      query-node/scripts/initializeDefaultSchemas.ts
  70. 38 12
      runtime-modules/council/src/lib.rs
  71. 52 1
      runtime-modules/council/src/tests.rs
  72. 2 1
      runtime-modules/proposals/discussion/src/benchmarking.rs
  73. 2 2
      runtime-modules/proposals/discussion/src/lib.rs
  74. 7 1
      runtime-modules/proposals/discussion/src/tests/mod.rs
  75. 9 9
      runtime-modules/proposals/engine/src/tests/mod.rs
  76. 2 2
      scripts/cargo-build.sh
  77. 2 2
      scripts/cargo-tests-with-networking.sh
  78. 1 1
      scripts/raspberry-cross-build.sh
  79. 4 4
      scripts/run-dev-chain.sh
  80. 1 0
      scripts/runtime-code-shasum.sh
  81. 2 4
      setup.sh
  82. 1 1
      storage-node/package.json
  83. 1 1
      storage-node/packages/runtime-api/package.json
  84. 6 5
      tests/integration-tests/package.json
  85. 48 4
      tests/integration-tests/src/Api.ts
  86. 135 19
      tests/integration-tests/src/QueryNodeApi.ts
  87. 1 1
      tests/integration-tests/src/Scenario.ts
  88. 2 0
      tests/integration-tests/src/consts.ts
  89. 17 1
      tests/integration-tests/src/fixtures/council/ElectCouncilFixture.ts
  90. 57 0
      tests/integration-tests/src/fixtures/council/NotEnoughCandidatesFixture.ts
  91. 82 0
      tests/integration-tests/src/fixtures/council/NotEnoughCandidatesWithVotesFixture.ts
  92. 67 0
      tests/integration-tests/src/fixtures/council/common.ts
  93. 2 0
      tests/integration-tests/src/fixtures/council/index.ts
  94. 5 4
      tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts
  95. 37 16
      tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts
  96. 14 11
      tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts
  97. 4 3
      tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts
  98. 1 1
      tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts
  99. 1 0
      tests/integration-tests/src/fixtures/forum/ModeratePostsFixture.ts
  100. 4 3
      tests/integration-tests/src/fixtures/forum/ModerateThreadsFixture.ts

+ 2 - 0
.github/workflows/integration-tests.yml

@@ -20,6 +20,7 @@ jobs:
         yarn workspace @joystream/types build
         yarn workspace @joystream/metadata-protobuf build
         yarn workspace integration-tests checks --quiet
+        yarn workspace query-node-root lint
 
   network_build_osx:
     name: MacOS Checks
@@ -39,3 +40,4 @@ jobs:
         yarn workspace @joystream/types build
         yarn workspace @joystream/metadata-protobuf build
         yarn workspace integration-tests checks --quiet
+        yarn workspace query-node-root lint

+ 1 - 1
.github/workflows/joystream-node-benchmarks.yml

@@ -35,7 +35,7 @@ jobs:
       - name: Build
         run: |
           pushd node
-          WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo build --release --features runtime-benchmarks
+          WASM_BUILD_TOOLCHAIN=nightly-2021-02-20 cargo +nightly-2021-02-20 build --release --features runtime-benchmarks
           popd
         if: env.GIT_DIFF
 

+ 2 - 2
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_
@@ -89,7 +89,7 @@ You can also run your our own joystream-node:
 
 ```sh
 git checkout master
-WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo build --release
+WASM_BUILD_TOOLCHAIN=nightly-2021-02-20 cargo +nightly-2021-02-20 build --release
 ./target/release/joystream-node -- --pruning archive --chain testnets/joy-testnet-5.json
 ```
 

+ 2 - 2
cli/package.json

@@ -19,7 +19,7 @@
     "@oclif/plugin-help": "^3.2.2",
     "@oclif/plugin-not-found": "^1.2.4",
     "@oclif/plugin-warn-if-update-available": "^1.7.0",
-    "@polkadot/api": "4.2.1",
+    "@polkadot/api": "5.3.2",
     "@types/cli-progress": "^3.9.1",
     "@types/fluent-ffmpeg": "^2.1.16",
     "@types/inquirer": "^6.5.0",
@@ -49,7 +49,7 @@
   "devDependencies": {
     "@oclif/dev-cli": "^1.22.2",
     "@oclif/test": "^1.2.5",
-    "@polkadot/ts": "^0.3.62",
+    "@polkadot/ts": "^0.4.4",
     "@types/chai": "^4.2.11",
     "@types/mocha": "^5.2.7",
     "@types/node": "^10.17.18",

+ 1 - 1
cli/src/Api.ts

@@ -42,7 +42,7 @@ import {
   VideoCategory,
 } from '@joystream/types/content'
 import { ContentId, DataObject } from '@joystream/types/storage'
-import { ApolloClient, InMemoryCache, HttpLink, NormalizedCacheObject, DocumentNode } from '@apollo/client'
+import { ApolloClient, InMemoryCache, HttpLink, NormalizedCacheObject, DocumentNode } from '@apollo/client/core'
 import fetch from 'cross-fetch'
 import { Maybe } from './graphql/generated/schema'
 import {

+ 2 - 2
cli/src/base/ApiCommandBase.ts

@@ -435,8 +435,8 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
                 let errorMsg = dispatchError.toString()
                 if (dispatchError.isModule) {
                   try {
-                    const { name, documentation } = this.getOriginalApi().registry.findMetaError(dispatchError.asModule)
-                    errorMsg = `${name} (${documentation})`
+                    const { name, docs } = this.getOriginalApi().registry.findMetaError(dispatchError.asModule)
+                    errorMsg = `${name} (${docs.join(', ')})`
                   } catch (e) {
                     // This probably means we don't have this error in the metadata
                     // In this case - continue (we'll just display dispatchError.toString())

+ 4 - 5
cli/src/base/ContentDirectoryCommandBase.ts

@@ -4,7 +4,6 @@ import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
-import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
 import { memberHandle } from '../helpers/display'
 
@@ -111,7 +110,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       if (!group.active.valueOf()) {
         this.error(`Curator group ${requiredGroupId.toString()} is no longer active`, { exit: ExitCodes.AccessDenied })
       }
-      if (!group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))) {
+      if (!Array.from(group.curators).some((curatorId) => curatorId.eq(curator.workerId))) {
         this.error(`You don't belong to required curator group (ID: ${requiredGroupId.toString()})`, {
           exit: ExitCodes.AccessDenied,
         })
@@ -122,7 +121,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       const availableGroupIds = groups
         .filter(
           ([, group]) =>
-            group.active.valueOf() && group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
+            group.active.valueOf() && Array.from(group.curators).some((curatorId) => curatorId.eq(curator.workerId))
         )
         .map(([id]) => id)
 
@@ -136,7 +135,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     }
 
     return [
-      createType('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] }),
+      this.createType('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] }),
       curator.roleAccount.toString(),
     ]
   }
@@ -149,7 +148,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         name:
           `Group ${id.toString()} (` +
           `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
-          `${group.curators.toArray().length} member(s)), `,
+          `${Array.from(group.curators).length} member(s)), `,
         value: id.toNumber(),
       }))
   }

+ 1 - 1
cli/src/commands/api/inspect.ts

@@ -86,7 +86,7 @@ export default class ApiInspect extends ApiCommandBase {
   }
 
   getMethodDescription(apiType: ApiType, apiModule: string, apiMethod: string): string {
-    const description: string = this.getMethodMeta(apiType, apiModule, apiMethod).documentation.join(' ')
+    const description: string = this.getMethodMeta(apiType, apiModule, apiMethod).docs.join(' ')
     return description || 'No description available.'
   }
 

+ 1 - 1
cli/src/commands/content/curatorGroup.ts

@@ -17,7 +17,7 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)
     const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
-      group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
+      Array.from(group.curators).some((groupCurator) => groupCurator.eq(curator.workerId))
     )
 
     displayCollapsedRow({

+ 1 - 1
cli/src/commands/content/curatorGroups.ts

@@ -13,7 +13,7 @@ export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
         groups.map(([id, group]) => ({
           'ID': id.toString(),
           'Status': group.active.valueOf() ? 'Active' : 'Inactive',
-          'Members': group.curators.toArray().length,
+          'Members': Array.from(group.curators).length,
         })),
         5
       )

+ 1 - 1
cli/src/commands/content/removeCuratorFromGroup.ts

@@ -26,7 +26,7 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
     }
 
     const group = await this.getCuratorGroup(groupId)
-    const groupCuratorIds = group.curators.toArray().map((id) => id.toNumber())
+    const groupCuratorIds = Array.from(group.curators).map((id) => id.toNumber())
 
     if (curatorId === undefined) {
       curatorId = await this.promptForCurator('Choose a Curator to remove', groupCuratorIds)

+ 3 - 2
cli/src/commands/working-groups/fillOpening.ts

@@ -1,8 +1,9 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { apiModuleByGroup } from '../../Api'
 import chalk from 'chalk'
-import { JoyBTreeSet } from '@joystream/types/common'
 import { ApplicationId } from '@joystream/types/working-group'
+import { BTreeSet } from '@polkadot/types'
+import { registry } from '@joystream/types'
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
   static description = "Allows filling working group opening that's currently in review. Requires lead access."
@@ -33,7 +34,7 @@ export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
       await this.getDecodedPair(lead.roleAccount.toString()),
       apiModuleByGroup[this.group],
       'fillOpening',
-      [openingId, new (JoyBTreeSet(ApplicationId))(this.getTypesRegistry(), applicationIds)]
+      [openingId, new (BTreeSet.with(ApplicationId))(registry, applicationIds)]
     )
 
     this.log(chalk.green(`Opening ${chalk.magentaBright(openingId.toString())} succesfully filled!`))

+ 3 - 3
devops/git-hooks/pre-push

@@ -1,13 +1,13 @@
 #!/bin/sh
 set -e
 
-export WASM_BUILD_TOOLCHAIN=nightly-2021-03-24
+export WASM_BUILD_TOOLCHAIN=nightly-2021-02-20
 
 echo 'running clippy (rust linter)'
 # When custom build.rs triggers wasm-build-runner-impl to build we get error:
 # "Rust WASM toolchain not installed, please install it!"
 # So we skip building the WASM binary by setting BUILD_DUMMY_WASM_BINARY=1
-BUILD_DUMMY_WASM_BINARY=1 cargo clippy --release --all -- -D warnings
+BUILD_DUMMY_WASM_BINARY=1 cargo +nightly-2021-02-20 clippy --release --all -- -D warnings
 
 echo 'running cargo unit tests'
-cargo test --release --all
+cargo +nightly-2021-02-20 test --release --all

+ 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

+ 4 - 4
joystream-node.Dockerfile

@@ -1,7 +1,7 @@
 FROM liuchong/rustup:nightly AS rustup
-RUN rustup install nightly-2021-03-24
-RUN rustup default nightly-2021-03-24
-RUN rustup target add wasm32-unknown-unknown --toolchain nightly-2021-03-24
+RUN rustup install nightly-2021-02-20
+RUN rustup default nightly-2021-02-20
+RUN rustup target add wasm32-unknown-unknown --toolchain nightly-2021-02-20
 RUN apt-get update && \
   apt-get install -y curl git gcc xz-utils sudo pkg-config unzip clang llvm libc6-dev
 
@@ -12,7 +12,7 @@ COPY . /joystream
 
 # Build all cargo crates
 # Ensure our tests and linter pass before actual build
-ENV WASM_BUILD_TOOLCHAIN=nightly-2021-03-24
+ENV WASM_BUILD_TOOLCHAIN=nightly-2021-02-20
 ARG TEST_NODE
 RUN echo "TEST_NODE=$TEST_NODE"
 RUN test -n "$TEST_NODE" && sed -i 's/MILLISECS_PER_BLOCK: Moment = 6000/MILLISECS_PER_BLOCK: Moment = 1000/' ./runtime/src/constants.rs; exit 0

+ 205 - 4
metadata-protobuf/compiled/index.d.ts

@@ -513,14 +513,113 @@ export class ForumPostMetadata implements IForumPostMetadata {
     public toJSON(): { [k: string]: any };
 }
 
+/** Properties of a ForumThreadMetadata. */
+export interface IForumThreadMetadata {
+
+    /** ForumThreadMetadata title */
+    title?: (string|null);
+
+    /** ForumThreadMetadata tags */
+    tags?: (string[]|null);
+}
+
+/** Represents a ForumThreadMetadata. */
+export class ForumThreadMetadata implements IForumThreadMetadata {
+
+    /**
+     * Constructs a new ForumThreadMetadata.
+     * @param [properties] Properties to set
+     */
+    constructor(properties?: IForumThreadMetadata);
+
+    /** ForumThreadMetadata title. */
+    public title: string;
+
+    /** ForumThreadMetadata tags. */
+    public tags: string[];
+
+    /**
+     * Creates a new ForumThreadMetadata instance using the specified properties.
+     * @param [properties] Properties to set
+     * @returns ForumThreadMetadata instance
+     */
+    public static create(properties?: IForumThreadMetadata): ForumThreadMetadata;
+
+    /**
+     * Encodes the specified ForumThreadMetadata message. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @param message ForumThreadMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encode(message: IForumThreadMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Encodes the specified ForumThreadMetadata message, length delimited. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @param message ForumThreadMetadata message or plain object to encode
+     * @param [writer] Writer to encode to
+     * @returns Writer
+     */
+    public static encodeDelimited(message: IForumThreadMetadata, writer?: $protobuf.Writer): $protobuf.Writer;
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer.
+     * @param reader Reader or buffer to decode from
+     * @param [length] Message length if known beforehand
+     * @returns ForumThreadMetadata
+     * @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): ForumThreadMetadata;
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer, length delimited.
+     * @param reader Reader or buffer to decode from
+     * @returns ForumThreadMetadata
+     * @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)): ForumThreadMetadata;
+
+    /**
+     * Verifies a ForumThreadMetadata 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 ForumThreadMetadata message from a plain object. Also converts values to their respective internal types.
+     * @param object Plain object
+     * @returns ForumThreadMetadata
+     */
+    public static fromObject(object: { [k: string]: any }): ForumThreadMetadata;
+
+    /**
+     * Creates a plain object from a ForumThreadMetadata message. Also converts values to other types if specified.
+     * @param message ForumThreadMetadata
+     * @param [options] Conversion options
+     * @returns Plain object
+     */
+    public static toObject(message: ForumThreadMetadata, options?: $protobuf.IConversionOptions): { [k: string]: any };
+
+    /**
+     * Converts this ForumThreadMetadata to JSON.
+     * @returns JSON object
+     */
+    public toJSON(): { [k: string]: any };
+}
+
 /** Properties of a MembershipMetadata. */
 export interface IMembershipMetadata {
 
     /** MembershipMetadata name */
     name?: (string|null);
 
-    /** MembershipMetadata avatar */
-    avatar?: (number|null);
+    /** MembershipMetadata avatarObject */
+    avatarObject?: (number|null);
+
+    /** MembershipMetadata avatarUri */
+    avatarUri?: (string|null);
 
     /** MembershipMetadata about */
     about?: (string|null);
@@ -538,12 +637,18 @@ export class MembershipMetadata implements IMembershipMetadata {
     /** MembershipMetadata name. */
     public name: string;
 
-    /** MembershipMetadata avatar. */
-    public avatar: number;
+    /** MembershipMetadata avatarObject. */
+    public avatarObject?: (number|null);
+
+    /** MembershipMetadata avatarUri. */
+    public avatarUri?: (string|null);
 
     /** MembershipMetadata about. */
     public about: string;
 
+    /** MembershipMetadata avatar. */
+    public avatar?: ("avatarObject"|"avatarUri");
+
     /**
      * Creates a new MembershipMetadata instance using the specified properties.
      * @param [properties] Properties to set
@@ -831,6 +936,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 {
 

+ 498 - 15
metadata-protobuf/compiled/index.js

@@ -1185,6 +1185,232 @@ $root.ForumPostMetadata = (function() {
     return ForumPostMetadata;
 })();
 
+$root.ForumThreadMetadata = (function() {
+
+    /**
+     * Properties of a ForumThreadMetadata.
+     * @exports IForumThreadMetadata
+     * @interface IForumThreadMetadata
+     * @property {string|null} [title] ForumThreadMetadata title
+     * @property {Array.<string>|null} [tags] ForumThreadMetadata tags
+     */
+
+    /**
+     * Constructs a new ForumThreadMetadata.
+     * @exports ForumThreadMetadata
+     * @classdesc Represents a ForumThreadMetadata.
+     * @implements IForumThreadMetadata
+     * @constructor
+     * @param {IForumThreadMetadata=} [properties] Properties to set
+     */
+    function ForumThreadMetadata(properties) {
+        this.tags = [];
+        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]];
+    }
+
+    /**
+     * ForumThreadMetadata title.
+     * @member {string} title
+     * @memberof ForumThreadMetadata
+     * @instance
+     */
+    ForumThreadMetadata.prototype.title = "";
+
+    /**
+     * ForumThreadMetadata tags.
+     * @member {Array.<string>} tags
+     * @memberof ForumThreadMetadata
+     * @instance
+     */
+    ForumThreadMetadata.prototype.tags = $util.emptyArray;
+
+    /**
+     * Creates a new ForumThreadMetadata instance using the specified properties.
+     * @function create
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata=} [properties] Properties to set
+     * @returns {ForumThreadMetadata} ForumThreadMetadata instance
+     */
+    ForumThreadMetadata.create = function create(properties) {
+        return new ForumThreadMetadata(properties);
+    };
+
+    /**
+     * Encodes the specified ForumThreadMetadata message. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @function encode
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata} message ForumThreadMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumThreadMetadata.encode = function encode(message, writer) {
+        if (!writer)
+            writer = $Writer.create();
+        if (message.title != null && Object.hasOwnProperty.call(message, "title"))
+            writer.uint32(/* id 1, wireType 2 =*/10).string(message.title);
+        if (message.tags != null && message.tags.length)
+            for (var i = 0; i < message.tags.length; ++i)
+                writer.uint32(/* id 2, wireType 2 =*/18).string(message.tags[i]);
+        return writer;
+    };
+
+    /**
+     * Encodes the specified ForumThreadMetadata message, length delimited. Does not implicitly {@link ForumThreadMetadata.verify|verify} messages.
+     * @function encodeDelimited
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {IForumThreadMetadata} message ForumThreadMetadata message or plain object to encode
+     * @param {$protobuf.Writer} [writer] Writer to encode to
+     * @returns {$protobuf.Writer} Writer
+     */
+    ForumThreadMetadata.encodeDelimited = function encodeDelimited(message, writer) {
+        return this.encode(message, writer).ldelim();
+    };
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer.
+     * @function decode
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @param {number} [length] Message length if known beforehand
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumThreadMetadata.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.ForumThreadMetadata();
+        while (reader.pos < end) {
+            var tag = reader.uint32();
+            switch (tag >>> 3) {
+            case 1:
+                message.title = reader.string();
+                break;
+            case 2:
+                if (!(message.tags && message.tags.length))
+                    message.tags = [];
+                message.tags.push(reader.string());
+                break;
+            default:
+                reader.skipType(tag & 7);
+                break;
+            }
+        }
+        return message;
+    };
+
+    /**
+     * Decodes a ForumThreadMetadata message from the specified reader or buffer, length delimited.
+     * @function decodeDelimited
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     * @throws {Error} If the payload is not a reader or valid buffer
+     * @throws {$protobuf.util.ProtocolError} If required fields are missing
+     */
+    ForumThreadMetadata.decodeDelimited = function decodeDelimited(reader) {
+        if (!(reader instanceof $Reader))
+            reader = new $Reader(reader);
+        return this.decode(reader, reader.uint32());
+    };
+
+    /**
+     * Verifies a ForumThreadMetadata message.
+     * @function verify
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {Object.<string,*>} message Plain object to verify
+     * @returns {string|null} `null` if valid, otherwise the reason why it is not
+     */
+    ForumThreadMetadata.verify = function verify(message) {
+        if (typeof message !== "object" || message === null)
+            return "object expected";
+        if (message.title != null && message.hasOwnProperty("title"))
+            if (!$util.isString(message.title))
+                return "title: string expected";
+        if (message.tags != null && message.hasOwnProperty("tags")) {
+            if (!Array.isArray(message.tags))
+                return "tags: array expected";
+            for (var i = 0; i < message.tags.length; ++i)
+                if (!$util.isString(message.tags[i]))
+                    return "tags: string[] expected";
+        }
+        return null;
+    };
+
+    /**
+     * Creates a ForumThreadMetadata message from a plain object. Also converts values to their respective internal types.
+     * @function fromObject
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {Object.<string,*>} object Plain object
+     * @returns {ForumThreadMetadata} ForumThreadMetadata
+     */
+    ForumThreadMetadata.fromObject = function fromObject(object) {
+        if (object instanceof $root.ForumThreadMetadata)
+            return object;
+        var message = new $root.ForumThreadMetadata();
+        if (object.title != null)
+            message.title = String(object.title);
+        if (object.tags) {
+            if (!Array.isArray(object.tags))
+                throw TypeError(".ForumThreadMetadata.tags: array expected");
+            message.tags = [];
+            for (var i = 0; i < object.tags.length; ++i)
+                message.tags[i] = String(object.tags[i]);
+        }
+        return message;
+    };
+
+    /**
+     * Creates a plain object from a ForumThreadMetadata message. Also converts values to other types if specified.
+     * @function toObject
+     * @memberof ForumThreadMetadata
+     * @static
+     * @param {ForumThreadMetadata} message ForumThreadMetadata
+     * @param {$protobuf.IConversionOptions} [options] Conversion options
+     * @returns {Object.<string,*>} Plain object
+     */
+    ForumThreadMetadata.toObject = function toObject(message, options) {
+        if (!options)
+            options = {};
+        var object = {};
+        if (options.arrays || options.defaults)
+            object.tags = [];
+        if (options.defaults)
+            object.title = "";
+        if (message.title != null && message.hasOwnProperty("title"))
+            object.title = message.title;
+        if (message.tags && message.tags.length) {
+            object.tags = [];
+            for (var j = 0; j < message.tags.length; ++j)
+                object.tags[j] = message.tags[j];
+        }
+        return object;
+    };
+
+    /**
+     * Converts this ForumThreadMetadata to JSON.
+     * @function toJSON
+     * @memberof ForumThreadMetadata
+     * @instance
+     * @returns {Object.<string,*>} JSON object
+     */
+    ForumThreadMetadata.prototype.toJSON = function toJSON() {
+        return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+    };
+
+    return ForumThreadMetadata;
+})();
+
 $root.MembershipMetadata = (function() {
 
     /**
@@ -1192,7 +1418,8 @@ $root.MembershipMetadata = (function() {
      * @exports IMembershipMetadata
      * @interface IMembershipMetadata
      * @property {string|null} [name] MembershipMetadata name
-     * @property {number|null} [avatar] MembershipMetadata avatar
+     * @property {number|null} [avatarObject] MembershipMetadata avatarObject
+     * @property {string|null} [avatarUri] MembershipMetadata avatarUri
      * @property {string|null} [about] MembershipMetadata about
      */
 
@@ -1220,12 +1447,20 @@ $root.MembershipMetadata = (function() {
     MembershipMetadata.prototype.name = "";
 
     /**
-     * MembershipMetadata avatar.
-     * @member {number} avatar
+     * MembershipMetadata avatarObject.
+     * @member {number|null|undefined} avatarObject
+     * @memberof MembershipMetadata
+     * @instance
+     */
+    MembershipMetadata.prototype.avatarObject = null;
+
+    /**
+     * MembershipMetadata avatarUri.
+     * @member {string|null|undefined} avatarUri
      * @memberof MembershipMetadata
      * @instance
      */
-    MembershipMetadata.prototype.avatar = 0;
+    MembershipMetadata.prototype.avatarUri = null;
 
     /**
      * MembershipMetadata about.
@@ -1235,6 +1470,20 @@ $root.MembershipMetadata = (function() {
      */
     MembershipMetadata.prototype.about = "";
 
+    // OneOf field names bound to virtual getters and setters
+    var $oneOfFields;
+
+    /**
+     * MembershipMetadata avatar.
+     * @member {"avatarObject"|"avatarUri"|undefined} avatar
+     * @memberof MembershipMetadata
+     * @instance
+     */
+    Object.defineProperty(MembershipMetadata.prototype, "avatar", {
+        get: $util.oneOfGetter($oneOfFields = ["avatarObject", "avatarUri"]),
+        set: $util.oneOfSetter($oneOfFields)
+    });
+
     /**
      * Creates a new MembershipMetadata instance using the specified properties.
      * @function create
@@ -1261,10 +1510,12 @@ $root.MembershipMetadata = (function() {
             writer = $Writer.create();
         if (message.name != null && Object.hasOwnProperty.call(message, "name"))
             writer.uint32(/* id 1, wireType 2 =*/10).string(message.name);
-        if (message.avatar != null && Object.hasOwnProperty.call(message, "avatar"))
-            writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.avatar);
+        if (message.avatarObject != null && Object.hasOwnProperty.call(message, "avatarObject"))
+            writer.uint32(/* id 2, wireType 0 =*/16).uint32(message.avatarObject);
         if (message.about != null && Object.hasOwnProperty.call(message, "about"))
             writer.uint32(/* id 3, wireType 2 =*/26).string(message.about);
+        if (message.avatarUri != null && Object.hasOwnProperty.call(message, "avatarUri"))
+            writer.uint32(/* id 4, wireType 2 =*/34).string(message.avatarUri);
         return writer;
     };
 
@@ -1303,7 +1554,10 @@ $root.MembershipMetadata = (function() {
                 message.name = reader.string();
                 break;
             case 2:
-                message.avatar = reader.uint32();
+                message.avatarObject = reader.uint32();
+                break;
+            case 4:
+                message.avatarUri = reader.string();
                 break;
             case 3:
                 message.about = reader.string();
@@ -1343,12 +1597,22 @@ $root.MembershipMetadata = (function() {
     MembershipMetadata.verify = function verify(message) {
         if (typeof message !== "object" || message === null)
             return "object expected";
+        var properties = {};
         if (message.name != null && message.hasOwnProperty("name"))
             if (!$util.isString(message.name))
                 return "name: string expected";
-        if (message.avatar != null && message.hasOwnProperty("avatar"))
-            if (!$util.isInteger(message.avatar))
-                return "avatar: integer expected";
+        if (message.avatarObject != null && message.hasOwnProperty("avatarObject")) {
+            properties.avatar = 1;
+            if (!$util.isInteger(message.avatarObject))
+                return "avatarObject: integer expected";
+        }
+        if (message.avatarUri != null && message.hasOwnProperty("avatarUri")) {
+            if (properties.avatar === 1)
+                return "avatar: multiple values";
+            properties.avatar = 1;
+            if (!$util.isString(message.avatarUri))
+                return "avatarUri: string expected";
+        }
         if (message.about != null && message.hasOwnProperty("about"))
             if (!$util.isString(message.about))
                 return "about: string expected";
@@ -1369,8 +1633,10 @@ $root.MembershipMetadata = (function() {
         var message = new $root.MembershipMetadata();
         if (object.name != null)
             message.name = String(object.name);
-        if (object.avatar != null)
-            message.avatar = object.avatar >>> 0;
+        if (object.avatarObject != null)
+            message.avatarObject = object.avatarObject >>> 0;
+        if (object.avatarUri != null)
+            message.avatarUri = String(object.avatarUri);
         if (object.about != null)
             message.about = String(object.about);
         return message;
@@ -1391,15 +1657,22 @@ $root.MembershipMetadata = (function() {
         var object = {};
         if (options.defaults) {
             object.name = "";
-            object.avatar = 0;
             object.about = "";
         }
         if (message.name != null && message.hasOwnProperty("name"))
             object.name = message.name;
-        if (message.avatar != null && message.hasOwnProperty("avatar"))
-            object.avatar = message.avatar;
+        if (message.avatarObject != null && message.hasOwnProperty("avatarObject")) {
+            object.avatarObject = message.avatarObject;
+            if (options.oneofs)
+                object.avatar = "avatarObject";
+        }
         if (message.about != null && message.hasOwnProperty("about"))
             object.about = message.about;
+        if (message.avatarUri != null && message.hasOwnProperty("avatarUri")) {
+            object.avatarUri = message.avatarUri;
+            if (options.oneofs)
+                object.avatar = "avatarUri";
+        }
         return object;
     };
 
@@ -1956,6 +2229,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() {
 
     /**

+ 17 - 0
metadata-protobuf/doc/index.md

@@ -13,6 +13,7 @@
 - [proto/Forum.proto](#proto/Forum.proto)
     - [ForumPostMetadata](#.ForumPostMetadata)
     - [ForumPostReaction](#.ForumPostReaction)
+    - [ForumThreadMetadata](#.ForumThreadMetadata)
   
     - [ForumPostReaction.Reaction](#.ForumPostReaction.Reaction)
   
@@ -171,6 +172,22 @@ The enum must be wrapped inside &#34;message&#34;, otherwide it breaks protobufj
 
 
 
+
+<a name=".ForumThreadMetadata"></a>
+
+### ForumThreadMetadata
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| title | [string](#string) | optional | Thread title |
+| tags | [string](#string) | repeated | Tags accociated with the thread. Any update overrides all current tags. Only the first {MAX_TAGS_PER_FORUM_THREAD} (const exposed via @joystream/metadata-protobuf/consts) tags are taken into account. In order to unset current tags, [&#39;&#39;] (array with empty string) must be provided as value. |
+
+
+
+
+
  
 
 

+ 3 - 2
metadata-protobuf/package.json

@@ -7,7 +7,8 @@
   "exports": {
     ".": "./lib/index.js",
     "./utils": "./lib/utils.js",
-    "./licenses": "./lib/licenses.js"
+    "./licenses": "./lib/licenses.js",
+    "./consts": "./lib/consts.js"
   },
   "typesVersions": {
     "*": {
@@ -48,7 +49,7 @@
     "mocha": "^8.2.1",
     "prettier": "2.0.2",
     "ts-node": "^8.8.1",
-    "typescript": "^4.1.3",
+    "typescript": "^4.3.5",
     "protobufjs": "^6.11.2"
   }
 }

+ 12 - 0
metadata-protobuf/proto/Forum.proto

@@ -12,3 +12,15 @@ message ForumPostMetadata {
   optional string text = 1; // Post text content (md-formatted)
   optional uint32 repliesTo = 2; // Id of the post that given post replies to (if any)
 }
+
+
+message ForumThreadMetadata {
+  optional string title = 1; // Thread title
+  /**
+   * Tags accociated with the thread.
+   * Any update overrides all current tags.
+   * Only the first {MAX_TAGS_PER_FORUM_THREAD} (const exposed via @joystream/metadata-protobuf/consts) tags are taken into account.
+   * In order to unset current tags, [''] (array with empty string) must be provided as value.
+   */
+  repeated string tags = 2;
+}

+ 4 - 1
metadata-protobuf/proto/Membership.proto

@@ -2,6 +2,9 @@ syntax = "proto2";
 
 message MembershipMetadata {
   optional string name = 1; // Member's real name
-  optional uint32 avatar = 2; // Member's avatar - index into external [assets array](#.Assets)
+  oneof avatar {
+    uint32 avatar_object = 2; // Member's avatar - index into external [assets array](#.Assets)
+    string avatar_uri = 4; // Url to member's avatar
+  }
   optional string about = 3; // Member's md-formatted about text
 }

+ 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)
+}

+ 1 - 0
metadata-protobuf/src/consts.ts

@@ -0,0 +1 @@
+export const MAX_TAGS_PER_FORUM_THREAD = 5

+ 13 - 0
metadata-protobuf/test/forum-tags.ts

@@ -0,0 +1,13 @@
+import { ForumThreadMetadata } from '../src'
+import { assert } from 'chai'
+import { encodeDecode, metaToObject } from '../src/utils'
+
+describe('Forum tags', () => {
+  it('Skip vs unsetting', () => {
+    const messageSkip = new ForumThreadMetadata()
+    const messageUnset = new ForumThreadMetadata({ tags: [''] })
+
+    assert.equal(metaToObject(ForumThreadMetadata, messageSkip).tags, undefined)
+    assert.deepEqual(encodeDecode(ForumThreadMetadata, messageUnset).tags, [''])
+  })
+})

+ 3 - 3
node/README.md

@@ -26,7 +26,7 @@ cd joystream/
 Compile the node and runtime:
 
 ```bash
-WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo build --release
+WASM_BUILD_TOOLCHAIN=nightly-2021-02-20 cargo +nightly-2021-02-20 build --release
 ```
 
 This produces the binary in `./target/release/joystream-node`
@@ -57,7 +57,7 @@ Use the `--chain` argument, and specify the path to the genesis `chain.json` fil
 Running unit tests:
 
 ```bash
-cargo test --release --all
+cargo +nightly-2021-02-20 test --release --all
 ```
 
 Running full suite of checks, tests, formatting and linting:
@@ -79,7 +79,7 @@ If you are building a tagged release from `master` branch and want to install th
 This will install the executable `joystream-node` to your `~/.cargo/bin` folder, which you would normally have in your `$PATH` environment.
 
 ```bash
-WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 cargo install joystream-node --path node/ --locked
+WASM_BUILD_TOOLCHAIN=nightly-2021-02-20 cargo +nightly-2021-02-20 install joystream-node --path node/ --locked
 ```
 
 Now you can run and connect to the testnet:

+ 11 - 13
package.json

@@ -20,8 +20,6 @@
     "storage-node/packages/*",
     "devops/eslint-config",
     "devops/prettier-config",
-    "pioneer",
-    "pioneer/packages/*",
     "utils/api-scripts",
     "query-node",
     "query-node/mappings",
@@ -29,19 +27,19 @@
     "metadata-protobuf"
   ],
   "resolutions": {
-    "@polkadot/api": "4.2.1",
-    "@polkadot/api-contract": "4.2.1",
-    "@polkadot/keyring": "^6.0.5",
-    "@polkadot/metadata": "4.2.1",
-    "@polkadot/types": "4.2.1",
-    "@polkadot/util": "^6.0.5",
-    "@polkadot/util-crypto": "^6.0.5",
-    "@polkadot/wasm-crypto": "^4.0.2",
+    "@polkadot/api": "5.3.2",
+    "@polkadot/api-contract": "5.3.2",
+    "@polkadot/types": "5.3.2",
+    "@polkadot/keyring": "^7.1.1",
+    "@polkadot/util": "^7.1.1",
+    "@polkadot/util-crypto": "^7.1.1",
+    "@polkadot/wasm-crypto": "^4.1.2",
     "babel-core": "^7.0.0-bridge.0",
-    "typescript": "3.8.*",
+    "typescript": "^4.3.5",
     "bn.js": "^5.1.2",
-    "rxjs": "^6.6.2",
-    "typeorm": "^0.2.31",
+    "rxjs": "^7.2.0",
+    "typeorm": "0.2.34",
+    "@joystream/warthog": "2.39.0",
     "pg": "^8.4.0",
     "chalk": "^4.0.0"
   },

+ 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.

+ 1 - 1
pioneer/packages/react-api/package.json

@@ -31,7 +31,7 @@
   "homepage": "https://github.com/polkadot-js/ui/tree/master/packages/ui-reactive#readme",
   "dependencies": {
     "@babel/runtime": "^7.10.5",
-    "@polkadot/api": "4.2.1",
+    "@polkadot/api": "5.3.2",
     "@polkadot/extension-dapp": "^0.32.0-beta.10",
     "rxjs-compat": "^6.6.0"
   }

+ 5 - 2
query-node/codegen/package.json

@@ -4,8 +4,11 @@
   "description": "Hydra codegen tools for Joystream Query Node",
   "author": "",
   "license": "ISC",
+  "scripts": {
+    "postinstall": "cd .. && yarn workspace query-node-mappings postHydraCLIInstall"
+  },
   "dependencies": {
-    "@dzlzv/hydra-cli": "3.1.0-alpha.0",
-    "@dzlzv/hydra-typegen": "3.1.0-alpha.0"
+    "@joystream/hydra-cli": "3.1.0-alpha.13",
+    "@joystream/hydra-typegen": "3.1.0-alpha.13"
   }
 }

Diferenças do arquivo suprimidas por serem muito extensas
+ 609 - 226
query-node/codegen/yarn.lock


+ 80 - 12
query-node/manifest.yml

@@ -51,7 +51,6 @@ typegen:
     - storageWorkingGroup.NewMissedRewardLevelReached
     # Proposals
     - proposalsCodex.ProposalCreated
-    - proposalsEngine.ProposalCreated
     - proposalsEngine.ProposalStatusUpdated
     - proposalsEngine.ProposalDecisionMade
     - proposalsEngine.ProposalExecuted
@@ -69,8 +68,7 @@ typegen:
     - forum.CategoryDeleted
     - forum.ThreadCreated
     - forum.ThreadModerated
-    # - forum.ThreadUpdated FIXME: Not emitted by the runtime
-    - forum.ThreadTitleUpdated
+    - forum.ThreadMetadataUpdated
     - forum.ThreadDeleted
     - forum.ThreadMoved
     - forum.VoteOnPoll
@@ -110,11 +108,32 @@ typegen:
     - data_directory.ContentAccepted
     - data_directory.ContentRejected
     - data_directory.ContentUploadingStatusUpdated
+    # Council
+    - council.AnnouncingPeriodStarted
+    - council.NotEnoughCandidates
+    - council.VotingPeriodStarted
+    - council.NewCandidate
+    - council.NewCouncilElected
+    - council.NewCouncilNotElected
+    - council.CandidacyStakeRelease
+    - council.CandidacyWithdraw
+    - council.CandidacyNoteSet
+    - council.RewardPayment
+    - council.BudgetBalanceSet
+    - council.BudgetRefill
+    - council.BudgetRefillPlanned
+    - council.BudgetIncrementUpdated
+    - council.CouncilorRewardUpdated
+    - council.RequestFunded
+    # Referendum
+    - referendum.ReferendumStarted
+    - referendum.ReferendumStartedForcefully
+    - referendum.RevealingStageStarted
+    - referendum.ReferendumFinished
+    - referendum.VoteCast
+    - referendum.VoteRevealed
+    - referendum.StakeReleased
   calls:
-    # Memberships
-    - members.updateProfile
-    - members.updateAccounts
-    - forum.createThread
     # Content directory
     - content.create_curator_group
     - content.set_curator_group_status
@@ -147,13 +166,15 @@ 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'
     typedefsLoc: '../types/augment/all/defs.json'
 mappings:
   # js module that exports the handler functions
-  mappingsModule: mappings/lib
+  mappingsModule: mappings/lib/src
   # additinal libraries the processor loads
   # typically it is a module with event and extrinsic types generated by hydra-typegen
   imports:
@@ -461,8 +482,6 @@ mappings:
     # Proposals
     - event: proposalsCodex.ProposalCreated
       handler: proposalsCodex_ProposalCreated
-    - event: proposalsEngine.ProposalCreated
-      handler: proposalsEngine_ProposalCreated
     - event: proposalsEngine.ProposalStatusUpdated
       handler: proposalsEngine_ProposalStatusUpdated
     - event: proposalsEngine.ProposalDecisionMade
@@ -495,8 +514,8 @@ mappings:
       handler: forum_ThreadCreated
     - event: forum.ThreadModerated
       handler: forum_ThreadModerated
-    - event: forum.ThreadTitleUpdated
-      handler: forum_ThreadTitleUpdated
+    - event: forum.ThreadMetadataUpdated
+      handler: forum_ThreadMetadataUpdated
     - event: forum.ThreadDeleted
       handler: forum_ThreadDeleted
     - event: forum.ThreadMoved
@@ -573,6 +592,55 @@ mappings:
     # not handled at the moment
     #- event: dataDirectory.ContentUploadingStatusUpdated
     #  handler: data_directory_ContentUploadingStatusUpdated
+
+    # Council
+    - event: council.AnnouncingPeriodStarted
+      handler: council_AnnouncingPeriodStarted
+    - event: council.NotEnoughCandidates
+      handler: council_NotEnoughCandidates
+    - event: council.VotingPeriodStarted
+      handler: council_VotingPeriodStarted
+    - event: council.NewCandidate
+      handler: council_NewCandidate
+    - event: council.NewCouncilElected
+      handler: council_NewCouncilElected
+    - event: council.NewCouncilNotElected
+      handler: council_NewCouncilNotElected
+    - event: council.CandidacyStakeRelease
+      handler: council_CandidacyStakeRelease
+    - event: council.CandidacyWithdraw
+      handler: council_CandidacyWithdraw
+    - event: council.CandidacyNoteSet
+      handler: council_CandidacyNoteSet
+    - event: council.RewardPayment
+      handler: council_RewardPayment
+    - event: council.BudgetBalanceSet
+      handler: council_BudgetBalanceSet
+    - event: council.BudgetRefill
+      handler: council_BudgetRefill
+    - event: council.BudgetRefillPlanned
+      handler: council_BudgetRefillPlanned
+    - event: council.BudgetIncrementUpdated
+      handler: council_BudgetIncrementUpdated
+    - event: council.CouncilorRewardUpdated
+      handler: council_CouncilorRewardUpdated
+    - event: council.RequestFunded
+      handler: council_RequestFunded
+    # Referendum
+    - event: referendum.ReferendumStarted
+      handler: referendum_ReferendumStarted
+    - event: referendum.ReferendumStartedForcefully
+      handler: referendum_ReferendumStartedForcefully
+    - event: referendum.RevealingStageStarted
+      handler: referendum_RevealingStageStarted
+    - event: referendum.ReferendumFinished
+      handler: referendum_ReferendumFinished
+    - event: referendum.VoteCast
+      handler: referendum_VoteCast
+    - event: referendum.VoteRevealed
+      handler: referendum_VoteRevealed
+    - event: referendum.StakeReleased
+      handler: referendum_StakeReleased
   extrinsicHandlers:
     # infer defaults here
     #- extrinsic: Balances.Transfer

+ 10 - 0
query-node/mappings/.eslintrc.js

@@ -0,0 +1,10 @@
+module.exports = {
+  env: {
+    node: true,
+  },
+  rules: {
+    '@typescript-eslint/naming-convention': 'off',
+    '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-non-null-assertion': 'off',
+  },
+}

+ 0 - 35
query-node/mappings/genesis.ts

@@ -1,35 +0,0 @@
-import { StoreContext } from '@dzlzv/hydra-common'
-import BN from 'bn.js'
-import { MembershipSystemSnapshot, WorkingGroup } from 'query-node/dist/model'
-import { membershipSystem, workingGroups } from './genesis-data'
-
-export async function loadGenesisData({ store }: StoreContext): Promise<void> {
-  // Membership system
-  await store.save<MembershipSystemSnapshot>(
-    new MembershipSystemSnapshot({
-      createdAt: new Date(0),
-      updatedAt: new Date(0),
-      snapshotBlock: 0,
-      ...membershipSystem,
-      membershipPrice: new BN(membershipSystem.membershipPrice),
-      invitedInitialBalance: new BN(membershipSystem.invitedInitialBalance),
-    })
-  )
-
-  // Working groups
-  await Promise.all(
-    workingGroups.map(async (group) =>
-      store.save<WorkingGroup>(
-        new WorkingGroup({
-          createdAt: new Date(0),
-          updatedAt: new Date(0),
-          id: group.name,
-          name: group.name,
-          budget: new BN(group.budget),
-        })
-      )
-    )
-  )
-
-  // TODO: members, workers
-}

+ 9 - 7
query-node/mappings/package.json

@@ -2,23 +2,25 @@
   "name": "query-node-mappings",
   "version": "0.1.0",
   "description": "Mappings for hydra-processor",
-  "main": "lib/mappings/index.js",
+  "main": "lib/src/index.js",
   "license": "MIT",
   "scripts": {
     "build": "rm -rf lib && tsc --build tsconfig.json && cp ./generated/types/typedefs.json ./lib/generated/types/typedefs.json",
-    "lint": "echo \"Skippinng\"",
-    "clean": "rm -rf lib"
+    "lint": "eslint ./src --ext .ts",
+    "clean": "rm -rf lib",
+    "postinstall": "yarn ts-node ./scripts/postInstall.ts",
+    "postHydraCLIInstall": "yarn ts-node ./scripts/postHydraCLIInstall.ts"
   },
   "dependencies": {
-    "@dzlzv/hydra-common": "3.1.0-alpha.0",
-    "@dzlzv/hydra-db-utils": "3.1.0-alpha.0",
+    "@joystream/hydra-common": "3.1.0-alpha.13",
+    "@joystream/hydra-db-utils": "3.1.0-alpha.13",
     "@joystream/types": "^0.17.0",
-    "warthog": "https://github.com/metmirr/warthog/releases/download/v2.30.0/warthog-v2.30.0.tgz",
+    "@joystream/warthog": "2.39.0",
     "@joystream/metadata-protobuf": "^1.0.0",
     "iso-639-1": "^2.1.8"
   },
   "devDependencies": {
     "ts-node": "^9.0.0",
-    "typescript": "^3.8"
+    "typescript": "^4.3.5"
   }
 }

+ 0 - 24
query-node/mappings/proposalsDiscussion.ts

@@ -1,24 +0,0 @@
-/*
-eslint-disable @typescript-eslint/naming-convention
-*/
-import { SubstrateEvent, DatabaseManager } from '@dzlzv/hydra-common'
-
-export async function proposalsDiscussion_ThreadCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
-}
-export async function proposalsDiscussion_PostCreated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
-}
-export async function proposalsDiscussion_PostUpdated(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
-}
-export async function proposalsDiscussion_ThreadModeChanged(
-  db: DatabaseManager,
-  event_: SubstrateEvent
-): Promise<void> {
-  // TODO
-}
-
-export async function proposalsDiscussion_PostDeleted(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TODO
-}

+ 23 - 0
query-node/mappings/scripts/postHydraCLIInstall.ts

@@ -0,0 +1,23 @@
+// A script to be executed post hydra-cli install, that may include patches for Hydra CLI
+import path from 'path'
+import { replaceInFile } from './utils'
+
+// FIXME: Temporary fix for missing JOIN and HAVING conditions in search queries (Hydra)
+const searchServiceTemplatePath = path.resolve(
+  __dirname,
+  '../../codegen/node_modules/@joystream/hydra-cli/lib/src/templates/textsearch/service.ts.mst'
+)
+
+replaceInFile({
+  filePath: searchServiceTemplatePath,
+  regex: /queries = queries\.concat\(generateSqlQuery\(repositories\[index\]\.metadata\.tableName, WHERE\)\);/,
+  newContent:
+    'queries = queries.concat(generateSqlQuery(repositories[index].metadata.tableName, qb.createJoinExpression(), WHERE, qb.createHavingExpression()));',
+})
+
+replaceInFile({
+  filePath: searchServiceTemplatePath,
+  regex: /const generateSqlQuery =[\s\S]+\+ where;/,
+  newContent: `const generateSqlQuery = (table: string, joins: string, where: string, having: string) =>
+  \`SELECT '\${table}_' || "\${table}"."id" AS unique_id FROM "\${table}" \` + joins + ' ' + where + ' ' + having;`,
+})

+ 45 - 0
query-node/mappings/scripts/postInstall.ts

@@ -0,0 +1,45 @@
+// A script to be executed post query-node install, that may include workarounds in Hydra node_modules
+import path from 'path'
+import { replaceInFile } from './utils'
+
+// FIXME: Temporarly remove broken sanitizeNullCharacter call
+const subscribersJsPath = path.resolve(
+  __dirname,
+  '../../../node_modules/@joystream/hydra-processor/lib/db/subscribers.js'
+)
+replaceInFile({
+  filePath: subscribersJsPath,
+  regex: /sanitizeNullCharacter\(entity, field\);/g,
+  newContent: '//sanitizeNullCharacter(entity, field)',
+})
+
+// FIXME: Temporarly replace broken relations resolution in @joystream/warthog
+const dataLoaderJsPath = path.resolve(
+  __dirname,
+  '../../../node_modules/@joystream/warthog/dist/middleware/DataLoaderMiddleware.js'
+)
+replaceInFile({
+  filePath: dataLoaderJsPath,
+  regex: /return context\.connection\.relationIdLoader[\s\S]+return group\.related;\s+\}\);\s+\}\)/,
+  newContent: `return Promise.all(
+    entities.map(entity => context.connection.relationLoader.load(relation, entity))
+  ).then(function (results) {
+    return results.map(function (related) {
+      return (relation.isManyToOne || relation.isOneToOne) ? related[0] : related
+    })
+  })`,
+})
+
+// FIXME: Temporary fix for "table name x specified more than once"
+const baseServiceJsPath = path.resolve(__dirname, '../../../node_modules/@joystream/warthog/dist/core/BaseService.js')
+replaceInFile({
+  filePath: baseServiceJsPath,
+  regex: /function common\(parameters, localIdColumn, foreignTableName, foreignColumnMap, foreignColumnName\) \{[^}]+\}/,
+  newContent: `function common(parameters, localIdColumn, foreignTableName, foreignColumnMap, foreignColumnName) {
+    const uuid = require('uuid/v4')
+    const foreignTableAlias = uuid().replace('-', '')
+    var foreingIdColumn = "\\"" + foreignTableAlias + "\\".\\"" + foreignColumnMap[foreignColumnName] + "\\"";
+    parameters.topLevelQb.leftJoin(foreignTableName, foreignTableAlias, localIdColumn + " = " + foreingIdColumn);
+    addWhereCondition(parameters, foreignTableAlias, foreignColumnMap);
+  }`,
+})

+ 19 - 0
query-node/mappings/scripts/utils.ts

@@ -0,0 +1,19 @@
+import fs from 'fs'
+import { blake2AsHex } from '@polkadot/util-crypto'
+
+type ReplaceLinesInFileParams = {
+  filePath: string
+  regex: RegExp
+  newContent: string
+}
+
+export function replaceInFile({ filePath, regex, newContent }: ReplaceLinesInFileParams): void {
+  const paramsHash = blake2AsHex(filePath + '|' + regex.source + '|' + newContent)
+  const startMark = `/* BEGIN REPLACED CONTENT ${paramsHash} */`
+  const endMark = `/* END REPLACED CONTENT ${paramsHash} */`
+  const fileContent = fs.readFileSync(filePath).toString()
+  if (fileContent.includes(startMark)) {
+    return
+  }
+  fs.writeFileSync(filePath, fileContent.replace(regex, `${startMark}\n${newContent}\n${endMark}`))
+}

+ 10 - 3
query-node/mappings/common.ts → query-node/mappings/src/common.ts

@@ -5,11 +5,11 @@ import {
   ExtrinsicArg,
   EventContext,
   StoreContext,
-} from '@dzlzv/hydra-common'
+} from '@joystream/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 { BaseModel } from '@joystream/warthog'
 import { ContentParameters as Custom_ContentParameters } from '@joystream/types/storage'
 import { registry } from '@joystream/types'
 import { metaToObject } from '@joystream/metadata-protobuf/utils'
@@ -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)

+ 2 - 2
query-node/mappings/content/channel.ts → query-node/mappings/src/content/channel.ts

@@ -1,11 +1,11 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { In } from 'typeorm'
 import { AccountId } from '@polkadot/types/interfaces'
 import { Option } from '@polkadot/types/codec'
-import { Content } from '../generated/types'
+import { Content } from '../../generated/types'
 import { convertContentActorToChannelOwner, processChannelMetadata } from './utils'
 import { AssetNone, Channel, ChannelCategory, DataObject } from 'query-node/dist/model'
 import { deserializeMetadata, inconsistentState, logger } from '../common'

+ 2 - 2
query-node/mappings/content/curatorGroup.ts → query-node/mappings/src/content/curatorGroup.ts

@@ -1,10 +1,10 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { FindConditions } from 'typeorm'
 import { CuratorGroup } from 'query-node/dist/model'
-import { Content } from '../generated/types'
+import { Content } from '../../generated/types'
 import { inconsistentState, logger } from '../common'
 
 export async function content_CuratorGroupCreated({ store, event }: EventContext & StoreContext): Promise<void> {

+ 0 - 0
query-node/mappings/content/index.ts → query-node/mappings/src/content/index.ts


+ 1 - 1
query-node/mappings/content/utils.ts → query-node/mappings/src/content/utils.ts

@@ -6,7 +6,7 @@
 //         every time query node codegen is run (that will overwrite said manual changes)
 //       - verify in integration tests that the records are trully created/updated/removed as expected
 
-import { DatabaseManager, EventContext, StoreContext } from '@dzlzv/hydra-common'
+import { DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common'
 import ISO6391 from 'iso-639-1'
 import { FindConditions } from 'typeorm'
 import {

+ 2 - 2
query-node/mappings/content/video.ts → query-node/mappings/src/content/video.ts

@@ -1,9 +1,9 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext } from '@joystream/hydra-common'
 import { In } from 'typeorm'
-import { Content } from '../generated/types'
+import { Content } from '../../generated/types'
 import { deserializeMetadata, inconsistentState, logger } from '../common'
 import { processVideoMetadata } from './utils'
 import { AssetNone, Channel, Video, VideoCategory } from 'query-node/dist/model'

+ 985 - 0
query-node/mappings/src/council.ts

@@ -0,0 +1,985 @@
+import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@joystream/hydra-common'
+import { CURRENT_NETWORK, deserializeMetadata, genericEventFields } from './common'
+import BN from 'bn.js'
+import { FindConditions, SelectQueryBuilder } from 'typeorm'
+
+import {
+  // Council events
+  AnnouncingPeriodStartedEvent,
+  NotEnoughCandidatesEvent,
+  VotingPeriodStartedEvent,
+  NewCandidateEvent,
+  NewCouncilElectedEvent,
+  NewCouncilNotElectedEvent,
+  CandidacyStakeReleaseEvent,
+  CandidacyWithdrawEvent,
+  CandidacyNoteSetEvent,
+  RewardPaymentEvent,
+  BudgetBalanceSetEvent,
+  BudgetRefillEvent,
+  BudgetRefillPlannedEvent,
+  BudgetIncrementUpdatedEvent,
+  CouncilorRewardUpdatedEvent,
+  RequestFundedEvent,
+
+  // Referendum events
+  ReferendumStartedEvent,
+  ReferendumStartedForcefullyEvent,
+  RevealingStageStartedEvent,
+  ReferendumFinishedEvent,
+  VoteCastEvent,
+  VoteRevealedEvent,
+  StakeReleasedEvent,
+
+  // Council & referendum structures
+  ReferendumStageVoting,
+  ReferendumStageRevealing,
+
+  // Council & referendum schema types
+  CouncilStageUpdate,
+  CouncilStageAnnouncing,
+  CouncilStageIdle,
+  CouncilStageElection,
+  CouncilStage,
+  ElectionProblem,
+  Candidate,
+  CouncilMember,
+  ElectionRound,
+  ElectedCouncil,
+  CastVote,
+  CandidacyNoteMetadata,
+  CandidacyStatus,
+
+  // Misc
+  Membership,
+} from 'query-node/dist/model'
+import { Council, Referendum } from '../generated/types'
+import { CouncilCandidacyNoteMetadata } from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
+
+/// /////////////// Common - Gets //////////////////////////////////////////////
+
+/*
+  Retrieves the member record by its id.
+*/
+async function getMembership(store: DatabaseManager, memberId: string): Promise<Membership | undefined> {
+  const member = await store.get(Membership, { where: { id: memberId } })
+
+  if (!member) {
+    throw new Error(`Membership not found. memberId '${memberId}'`)
+  }
+
+  return member
+}
+
+/*
+  Retrieves the council candidate by its member id. Returns the last record for the member
+  if the election round isn't explicitly set.
+*/
+async function getCandidate(
+  store: DatabaseManager,
+  memberId: string,
+  electionRound?: ElectionRound,
+  relations: string[] = []
+): Promise<Candidate> {
+  const event = await store.get(NewCandidateEvent, {
+    join: { alias: 'event', innerJoin: { candidate: 'event.candidate' } },
+    where: (qb: SelectQueryBuilder<NewCandidateEvent>) => {
+      qb.where('candidate.memberId = :memberId', { memberId })
+      if (electionRound) {
+        qb.andWhere('candidate.electionRoundId = :electionRoundId', { electionRoundId: electionRound.id })
+      }
+    },
+    order: { inBlock: 'DESC', indexInBlock: 'DESC' },
+    relations: ['candidate'].concat(relations.map((r) => `candidate.${r}`)),
+  })
+
+  if (!event) {
+    throw new Error(`Candidate not found. memberId '${memberId}' electionRound '${electionRound?.id}'`)
+  }
+
+  return event.candidate
+}
+
+/*
+  Retrieves the member's last council member record.
+*/
+async function getCouncilMember(store: DatabaseManager, memberId: string): Promise<CouncilMember> {
+  const councilMember = await store.get(CouncilMember, {
+    where: { memberId: memberId },
+    order: { createdAt: 'DESC' },
+  })
+
+  if (!councilMember) {
+    throw new Error(`Council member not found. memberId '${memberId}'`)
+  }
+
+  return councilMember
+}
+
+/*
+  Returns the current election round record.
+*/
+async function getCurrentElectionRound(store: DatabaseManager, relations: string[] = []): Promise<ElectionRound> {
+  const electionRound = await store.get(ElectionRound, { order: { cycleId: 'DESC' }, relations: relations })
+
+  if (!electionRound) {
+    throw new Error(`No election round found`)
+  }
+
+  return electionRound
+}
+
+/*
+  Returns the last council stage update.
+*/
+async function getCurrentStageUpdate(store: DatabaseManager): Promise<CouncilStageUpdate> {
+  const stageUpdate = await store.get(CouncilStageUpdate, { order: { changedAt: 'DESC' } })
+
+  if (!stageUpdate) {
+    throw new Error('No stage update found.')
+  }
+
+  return stageUpdate
+}
+
+/*
+  Returns current elected council record.
+*/
+async function getCurrentElectedCouncil(store: DatabaseManager): Promise<ElectedCouncil> {
+  const electedCouncil = await store.get(ElectedCouncil, { order: { electedAtBlock: 'DESC' } })
+
+  // elected council's existence is guaranteed because one is inserted in `genesis.ts`
+  return electedCouncil as ElectedCouncil
+}
+
+/*
+  Returns the last vote cast in an election by the given account. Returns the last record for the account
+  if the election round isn't explicitly set.
+*/
+async function getAccountCastVote(
+  store: DatabaseManager,
+  account: string,
+  electionRound?: ElectionRound
+): Promise<CastVote> {
+  const where = { castBy: account } as FindConditions<Candidate>
+  if (electionRound) {
+    where.electionRound = electionRound
+  }
+
+  const castVote = await store.get(CastVote, { where, order: { createdAt: 'DESC' } })
+
+  if (!castVote) {
+    throw new Error(
+      `No vote cast by the given account in the current election round. accountId '${account}', cycleId '${electionRound?.cycleId}'`
+    )
+  }
+
+  return castVote
+}
+
+/*
+  Vote power calculation should correspond to implementation of `referendum::Trait<ReferendumInstance>`
+  in `runtime/src/lib.rs`.
+*/
+function calculateVotePower(accountId: string, stake: BN): BN {
+  return stake
+}
+
+/*
+  Custom typeguard for council stage - announcing candidacy.
+*/
+function isCouncilStageAnnouncing(councilStage: typeof CouncilStage): councilStage is CouncilStageAnnouncing {
+  return councilStage.isTypeOf === 'CouncilStageAnnouncing'
+}
+
+/// /////////////// Common /////////////////////////////////////////////////////
+
+/*
+  Creates new council stage update record.
+*/
+async function updateCouncilStage(
+  store: DatabaseManager,
+  councilStage: typeof CouncilStage,
+  blockNumber: number,
+  electionProblem?: ElectionProblem
+): Promise<void> {
+  const electedCouncil = await getCurrentElectedCouncil(store)
+
+  const councilStageUpdate = new CouncilStageUpdate({
+    stage: councilStage,
+    changedAt: new BN(blockNumber),
+    electionProblem,
+    electedCouncil,
+  })
+
+  await store.save<CouncilStageUpdate>(councilStageUpdate)
+}
+
+/*
+  Concludes current election round and starts the next one.
+*/
+async function startNextElectionRound(
+  store: DatabaseManager,
+  electedCouncil: ElectedCouncil,
+  event: SubstrateEvent,
+  electionProblem?: ElectionProblem
+): Promise<ElectionRound> {
+  // finish last election round
+  const lastElectionRound = await getCurrentElectionRound(store)
+  lastElectionRound.isFinished = true
+  lastElectionRound.endedAtBlock = event.blockNumber
+  lastElectionRound.endedAtTime = new Date(event.blockTimestamp)
+  lastElectionRound.endedAtNetwork = CURRENT_NETWORK
+
+  lastElectionRound.nextElectedCouncil = electedCouncil
+
+  // save last election
+  await store.save<ElectionRound>(lastElectionRound)
+
+  // create election round record
+  const electionRound = new ElectionRound({
+    cycleId: lastElectionRound.cycleId + 1,
+    isFinished: false,
+    castVotes: [],
+    electedCouncil,
+    candidates: [],
+  })
+
+  // save new election
+  await store.save<ElectionRound>(electionRound)
+
+  // update council stage
+
+  const stage = new CouncilStageAnnouncing()
+  stage.candidatesCount = new BN(0)
+  await updateCouncilStage(store, stage, event.blockNumber, electionProblem)
+
+  return electionRound
+}
+
+/*
+  Converts successful council candidate records to council member records.
+*/
+async function convertCandidatesToCouncilMembers(
+  candidates: Array<Candidate & { memberId: string }>,
+  blockNumber: number
+): Promise<CouncilMember[]> {
+  return candidates.map((candidate) => {
+    const member = new Membership({ id: candidate.memberId })
+
+    const councilMember = new CouncilMember({
+      // id: candidate.id // TODO: are ids needed?
+      stakingAccountId: candidate.stakingAccountId,
+      rewardAccountId: candidate.rewardAccountId,
+      member,
+      stake: candidate.stake,
+
+      lastPaymentBlock: new BN(blockNumber),
+
+      unpaidReward: new BN(0),
+      accumulatedReward: new BN(0),
+    })
+
+    return councilMember
+  })
+}
+
+/**
+  Mark the candidacies as aborted when there is not enough candidates or elected councilor
+ */
+async function abortCandidacies(store: DatabaseManager) {
+  const electionRound = await getCurrentElectionRound(store)
+  const candidates = await store.getMany(Candidate, {
+    where: { electionRoundId: electionRound.id, status: CandidacyStatus.ACTIVE },
+  })
+
+  await Promise.all(
+    candidates.map((candidate) => {
+      if (candidate.status === CandidacyStatus.ACTIVE) {
+        candidate.status = CandidacyStatus.FAILED
+      }
+      return store.save<Candidate>(candidate)
+    })
+  )
+}
+
+/// /////////////// Council events /////////////////////////////////////////////
+
+/*
+  The event is emitted when a new round of elections begins (can be caused by multiple reasons) and candidates can announce
+  their candidacies.
+*/
+export async function council_AnnouncingPeriodStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // const [] = new Council.AnnouncingPeriodStartedEvent(event).params
+
+  const announcingPeriodStartedEvent = new AnnouncingPeriodStartedEvent({
+    ...genericEventFields(event),
+  })
+
+  await store.save<AnnouncingPeriodStartedEvent>(announcingPeriodStartedEvent)
+
+  // specific event processing
+
+  // restart elections
+  const electedCouncil = await getCurrentElectedCouncil(store)
+  await startNextElectionRound(store, electedCouncil, event)
+}
+
+/*
+  The event is emitted when a candidacy announcement period has ended, but not enough members announced.
+*/
+export async function council_NotEnoughCandidates({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // const [] = new Council.NotEnoughCandidatesEvent(event).params
+
+  const notEnoughCandidatesEvent = new NotEnoughCandidatesEvent({
+    ...genericEventFields(event),
+  })
+
+  await store.save<NotEnoughCandidatesEvent>(notEnoughCandidatesEvent)
+
+  await abortCandidacies(store)
+
+  // specific event processing
+
+  // restart elections
+  const electedCouncil = await getCurrentElectedCouncil(store)
+  await startNextElectionRound(store, electedCouncil, event, ElectionProblem.NOT_ENOUGH_CANDIDATES)
+}
+
+/*
+  The event is emitted when a new round of elections begins (can be caused by multiple reasons).
+*/
+export async function council_VotingPeriodStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [numOfCandidates] = new Council.VotingPeriodStartedEvent(event).params
+
+  const votingPeriodStartedEvent = new VotingPeriodStartedEvent({
+    ...genericEventFields(event),
+    numOfCandidates,
+  })
+
+  await store.save<VotingPeriodStartedEvent>(votingPeriodStartedEvent)
+
+  // specific event processing
+
+  // add stage update record
+  const stage = new CouncilStageElection()
+  stage.candidatesCount = new BN(numOfCandidates.toString()) // toString() is needed to duplicate BN
+
+  await updateCouncilStage(store, stage, event.blockNumber)
+}
+
+/*
+  The event is emitted when a member announces candidacy to the council.
+*/
+export async function council_NewCandidate({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing - init
+
+  const [memberId, stakingAccount, rewardAccount, balance] = new Council.NewCandidateEvent(event).params
+  const member = await getMembership(store, memberId.toString())
+
+  // specific event processing
+
+  // increase candidate count in stage update record
+  const lastStageUpdate = await getCurrentStageUpdate(store)
+  if (!isCouncilStageAnnouncing(lastStageUpdate.stage)) {
+    throw new Error(`Unexpected council stage "${lastStageUpdate.stage.isTypeOf}"`)
+  }
+
+  lastStageUpdate.stage.candidatesCount = new BN(lastStageUpdate.stage.candidatesCount).add(new BN(1))
+  await store.save<CouncilStageUpdate>(lastStageUpdate)
+
+  const electionRound = await getCurrentElectionRound(store)
+
+  // prepare note metadata record (empty until explicitly set via different extrinsic)
+  const noteMetadata = new CandidacyNoteMetadata({
+    bulletPoints: [],
+  })
+  await store.save<CandidacyNoteMetadata>(noteMetadata)
+
+  // save candidate record
+  const candidate = new Candidate({
+    stakingAccountId: stakingAccount.toString(),
+    rewardAccountId: rewardAccount.toString(),
+    member,
+    status: CandidacyStatus.ACTIVE,
+    electionRound,
+    stake: balance,
+    stakeLocked: true,
+    votePower: new BN(0),
+    noteMetadata,
+    votesReceived: [],
+  })
+  await store.save<Candidate>(candidate)
+
+  // common event processing - save
+
+  const newCandidateEvent = new NewCandidateEvent({
+    ...genericEventFields(event),
+    candidate,
+    stakingAccount: stakingAccount.toString(),
+    rewardAccount: rewardAccount.toString(),
+    balance,
+  })
+
+  await store.save<NewCandidateEvent>(newCandidateEvent)
+}
+
+/*
+  The event is emitted when the new council is elected. Sufficient members were elected and there is no other problem.
+*/
+export async function council_NewCouncilElected({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing - init
+
+  const [memberIds] = new Council.NewCouncilElectedEvent(event).params
+  const electedMemberIds = memberIds.map((item) => item.toString())
+
+  // specific event processing
+
+  // mark old council as resigned
+  const oldElectedCouncil = await getCurrentElectedCouncil(store)
+  oldElectedCouncil.isResigned = true
+  oldElectedCouncil.endedAtBlock = event.blockNumber
+  oldElectedCouncil.endedAtTime = new Date(event.blockTimestamp)
+  oldElectedCouncil.endedAtNetwork = CURRENT_NETWORK
+  await store.save<ElectedCouncil>(oldElectedCouncil)
+
+  // get election round and its candidates
+  const electionRound = await getCurrentElectionRound(store)
+  const candidates = (await store.getMany(Candidate, {
+    where: { electionRoundId: electionRound.id, status: CandidacyStatus.ACTIVE },
+  })) as Array<Candidate & { memberId: string }>
+
+  const electedCandidates = candidates.filter((candidate) => electedMemberIds.includes(candidate.memberId))
+
+  // Set elected candidates status
+  electedCandidates.forEach((candidate) => {
+    candidate.status = CandidacyStatus.ELECTED
+  })
+  // Store candidates new statuses
+  await Promise.all(
+    candidates.map((candidate) => {
+      if (candidate.status === CandidacyStatus.ACTIVE) {
+        candidate.status = CandidacyStatus.FAILED
+      }
+      return store.save<Candidate>(candidate)
+    })
+  )
+
+  // create new council record
+  const electedCouncil = new ElectedCouncil({
+    councilMembers: await convertCandidatesToCouncilMembers(electedCandidates, event.blockNumber),
+    updates: [],
+    electedAtBlock: event.blockNumber,
+    electedAtTime: new Date(event.blockTimestamp),
+    electedAtNetwork: CURRENT_NETWORK,
+    councilElections: oldElectedCouncil?.nextCouncilElections || [],
+    nextCouncilElections: [],
+    isResigned: false,
+  })
+
+  await store.save<ElectedCouncil>(electedCouncil)
+
+  // save new council members
+  await Promise.all(
+    (electedCouncil.councilMembers || []).map(async (councilMember) => {
+      councilMember.electedInCouncil = electedCouncil
+
+      await store.save<CouncilMember>(councilMember)
+    })
+  )
+
+  // add council stage update
+  const stage = new CouncilStageIdle()
+  await updateCouncilStage(store, stage, event.blockNumber)
+
+  // unset `isCouncilMember` sign for old council's members
+  const oldElectedMembers = await store.getMany(Membership, { where: { isCouncilMember: true } })
+  await Promise.all(
+    oldElectedMembers.map(async (member) => {
+      member.isCouncilMember = false
+
+      await store.save<Membership>(member)
+    })
+  )
+
+  // set `isCouncilMember` sign for new council's members
+  await Promise.all(
+    (electedCouncil.councilMembers || []).map(async (councilMember) => {
+      const member = councilMember.member
+      member.isCouncilMember = true
+
+      await store.save<Membership>(member)
+    })
+  )
+
+  // common event processing - save
+
+  const newCouncilElectedEvent = new NewCouncilElectedEvent({
+    ...genericEventFields(event),
+    electedCouncil,
+  })
+
+  await store.save<NewCouncilElectedEvent>(newCouncilElectedEvent)
+}
+
+/*
+  The event is emitted when the new council couldn't be elected because not enough candidates received some votes.
+  This can be vaguely translated as the public not having enough interest in the candidates.
+*/
+export async function council_NewCouncilNotElected({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // const [] = new Council.NewCouncilNotElectedEvent(event).params
+
+  const newCouncilNotElectedEvent = new NewCouncilNotElectedEvent({
+    ...genericEventFields(event),
+  })
+
+  await store.save<NewCouncilNotElectedEvent>(newCouncilNotElectedEvent)
+
+  await abortCandidacies(store)
+
+  // specific event processing
+
+  // restart elections
+  const electedCouncil = await getCurrentElectedCouncil(store)
+  await startNextElectionRound(store, electedCouncil, event, ElectionProblem.NEW_COUNCIL_NOT_ELECTED)
+}
+
+/*
+  The event is emitted when the member is releasing it's candidacy stake that is no longer needed.
+*/
+export async function council_CandidacyStakeRelease({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId] = new Council.CandidacyStakeReleaseEvent(event).params
+  const candidate = await getCandidate(store, memberId.toString()) // get last member's candidacy record
+
+  const candidacyStakeReleaseEvent = new CandidacyStakeReleaseEvent({
+    ...genericEventFields(event),
+    candidate,
+  })
+
+  await store.save<CandidacyStakeReleaseEvent>(candidacyStakeReleaseEvent)
+
+  // specific event processing
+
+  // update candidate info about stake lock
+  candidate.stakeLocked = false
+  await store.save<Candidate>(candidate)
+}
+
+/*
+  The event is emitted when the member is revoking its candidacy during a candidacy announcement stage.
+*/
+export async function council_CandidacyWithdraw({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId] = new Council.CandidacyWithdrawEvent(event).params
+  const candidate = await getCandidate(store, memberId.toString())
+
+  const candidacyWithdrawEvent = new CandidacyWithdrawEvent({
+    ...genericEventFields(event),
+    candidate,
+  })
+
+  await store.save<CandidacyWithdrawEvent>(candidacyWithdrawEvent)
+
+  // specific event processing
+
+  // mark candidacy as withdrawn
+  candidate.status = CandidacyStatus.WITHDRAWN
+  await store.save<Candidate>(candidate)
+}
+
+/*
+  The event is emitted when the candidate changes its candidacy note.
+*/
+export async function council_CandidacyNoteSet({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, note] = new Council.CandidacyNoteSetEvent(event).params
+
+  // load candidate recorded
+  const electionRound = await getCurrentElectionRound(store)
+  const candidate = await getCandidate(store, memberId.toString(), electionRound, ['noteMetadata'])
+
+  const areBulletPointsSet = (metadataBulletPoints: string[] | null | undefined) => !!metadataBulletPoints
+  const areBulletPointsBeingUnset = (metadataBulletPoints: string[]) => {
+    // assumes areBulletPointsSet() were checked before
+
+    return metadataBulletPoints.length && metadataBulletPoints[0] === ''
+  }
+
+  // unpack note's metadata and save it to db
+  const metadata = deserializeMetadata(CouncilCandidacyNoteMetadata, note)
+  const noteMetadata = candidate.noteMetadata
+  // `XXX || (null as any)` construct clears metadata if requested (see https://github.com/Joystream/hydra/issues/435)
+  noteMetadata.header = isSet(metadata?.header) ? metadata?.header || (null as any) : noteMetadata.header
+  noteMetadata.bulletPoints = areBulletPointsSet(metadata?.bulletPoints)
+    ? areBulletPointsBeingUnset(metadata?.bulletPoints as string[]) // check deletion request
+      ? [] // empty bullet points
+      : (metadata?.bulletPoints as string[]) // set new value
+    : noteMetadata.bulletPoints // keep previous value
+  noteMetadata.bannerImageUri = isSet(metadata?.bannerImageUri)
+    ? metadata?.bannerImageUri || (null as any)
+    : noteMetadata.bannerImageUri
+  noteMetadata.description = isSet(metadata?.description)
+    ? metadata?.description || (null as any)
+    : noteMetadata.description
+  await store.save<CandidacyNoteMetadata>(noteMetadata)
+
+  // save metadata set by this event
+  const noteMetadataSnapshot = new CandidacyNoteMetadata({
+    header: metadata?.header ?? undefined,
+    bulletPoints: areBulletPointsSet(metadata?.bulletPoints) ? (metadata?.bulletPoints as string[]) : [],
+    bannerImageUri: metadata?.bannerImageUri ?? undefined,
+    description: metadata?.description ?? undefined,
+  })
+
+  await store.save<CandidacyNoteMetadata>(noteMetadataSnapshot)
+
+  const candidacyNoteSetEvent = new CandidacyNoteSetEvent({
+    ...genericEventFields(event),
+    candidate,
+    noteMetadata: noteMetadataSnapshot,
+  })
+
+  await store.save<CandidacyNoteSetEvent>(candidacyNoteSetEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when the council member receives its reward.
+*/
+export async function council_RewardPayment({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [memberId, rewardAccount, paidBalance, missingBalance] = new Council.RewardPaymentEvent(event).params
+  const councilMember = await getCouncilMember(store, memberId.toString())
+
+  const rewardPaymentEvent = new RewardPaymentEvent({
+    ...genericEventFields(event),
+    councilMember,
+    rewardAccount: rewardAccount.toString(),
+    paidBalance,
+    missingBalance,
+  })
+
+  await store.save<RewardPaymentEvent>(rewardPaymentEvent)
+
+  // specific event processing
+
+  // update (un)paid reward info
+  councilMember.accumulatedReward = councilMember.accumulatedReward.add(paidBalance)
+  councilMember.unpaidReward = missingBalance
+  councilMember.lastPaymentBlock = new BN(event.blockNumber)
+  await store.save<CouncilMember>(councilMember)
+}
+
+/*
+  The event is emitted when a new budget balance is set.
+*/
+export async function council_BudgetBalanceSet({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [balance] = new Council.BudgetBalanceSetEvent(event).params
+
+  const budgetBalanceSetEvent = new BudgetBalanceSetEvent({
+    ...genericEventFields(event),
+    balance,
+  })
+
+  await store.save<BudgetBalanceSetEvent>(budgetBalanceSetEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when a planned budget refill occurs.
+*/
+export async function council_BudgetRefill({ event, store }: EventContext & StoreContext): Promise<void> {
+  const [balance] = new Council.BudgetRefillEvent(event).params
+
+  const budgetRefillEvent = new BudgetRefillEvent({
+    ...genericEventFields(event),
+    balance,
+  })
+
+  await store.save<BudgetRefillEvent>(budgetRefillEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when a new budget refill is planned.
+*/
+export async function council_BudgetRefillPlanned({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [nextRefillInBlock] = new Council.BudgetRefillPlannedEvent(event).params
+
+  const budgetRefillPlannedEvent = new BudgetRefillPlannedEvent({
+    ...genericEventFields(event),
+    nextRefillInBlock: nextRefillInBlock.toNumber(),
+  })
+
+  await store.save<BudgetRefillPlannedEvent>(budgetRefillPlannedEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when a regular budget increment amount is updated.
+*/
+export async function council_BudgetIncrementUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [amount] = new Council.BudgetIncrementUpdatedEvent(event).params
+
+  const budgetIncrementUpdatedEvent = new BudgetIncrementUpdatedEvent({
+    ...genericEventFields(event),
+    amount,
+  })
+
+  await store.save<BudgetIncrementUpdatedEvent>(budgetIncrementUpdatedEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when the reward amount for council members is updated.
+*/
+export async function council_CouncilorRewardUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [rewardAmount] = new Council.CouncilorRewardUpdatedEvent(event).params
+
+  const councilorRewardUpdatedEvent = new CouncilorRewardUpdatedEvent({
+    ...genericEventFields(event),
+    rewardAmount,
+  })
+
+  await store.save<CouncilorRewardUpdatedEvent>(councilorRewardUpdatedEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when funds are transferred from the council budget to an account.
+*/
+export async function council_RequestFunded({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [account, amount] = new Council.RequestFundedEvent(event).params
+
+  const requestFundedEvent = new RequestFundedEvent({
+    ...genericEventFields(event),
+    account: account.toString(),
+    amount,
+  })
+
+  await store.save<RequestFundedEvent>(requestFundedEvent)
+
+  // no specific event processing
+}
+
+/// /////////////// Referendum events //////////////////////////////////////////
+
+/*
+  The event is emitted when the voting stage of elections starts.
+*/
+export async function referendum_ReferendumStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+  const [winningTargetCount] = new Referendum.ReferendumStartedEvent(event).params
+
+  const referendumStartedEvent = new ReferendumStartedEvent({
+    ...genericEventFields(event),
+    winningTargetCount,
+  })
+
+  await store.save<ReferendumStartedEvent>(referendumStartedEvent)
+
+  // specific event processing
+
+  await recordReferendumVotingStart(store, event.blockNumber, winningTargetCount.toNumber())
+}
+
+/*
+  The event is emitted when the voting stage of elections starts (in a fail-safe way).
+*/
+export async function referendum_ReferendumStartedForcefully({
+  event,
+  store,
+}: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [winningTargetCount] = new Referendum.ReferendumStartedForcefullyEvent(event).params
+
+  const referendumStartedForcefullyEvent = new ReferendumStartedForcefullyEvent({
+    ...genericEventFields(event),
+    winningTargetCount,
+  })
+
+  await store.save<ReferendumStartedForcefullyEvent>(referendumStartedForcefullyEvent)
+
+  // specific event processing
+
+  await recordReferendumVotingStart(store, event.blockNumber, winningTargetCount.toNumber())
+}
+
+/*
+  Adds record about referendum voting start to the current election round.
+*/
+async function recordReferendumVotingStart(store: DatabaseManager, blockNumber: number, winningTargetCount: number) {
+  const electionRound = await getCurrentElectionRound(store)
+
+  // add referendum voting stage record to election round
+  const referendumStage = new ReferendumStageVoting()
+  referendumStage.startedAtBlock = new BN(blockNumber)
+  referendumStage.winningTargetCount = new BN(winningTargetCount)
+  referendumStage.electionRound = electionRound
+  await store.save<ReferendumStageVoting>(referendumStage)
+}
+
+/*
+  The event is emitted when the vote revealing stage of elections starts.
+*/
+export async function referendum_RevealingStageStarted({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // const [] = new Referendum.RevealingStageStartedEvent(event).params
+
+  const revealingStageStartedEvent = new RevealingStageStartedEvent({
+    ...genericEventFields(event),
+  })
+
+  await store.save<RevealingStageStartedEvent>(revealingStageStartedEvent)
+
+  // specific event processing
+
+  const electionRound = await getCurrentElectionRound(store, ['referendumStageVoting'])
+
+  // add referendum revealing stage record to election round
+  const referendumStage = new ReferendumStageRevealing()
+  referendumStage.startedAtBlock = new BN(event.blockNumber)
+  referendumStage.winningTargetCount = (electionRound.referendumStageVoting as ReferendumStageVoting).winningTargetCount
+  referendumStage.electionRound = electionRound
+  await store.save<ReferendumStageRevealing>(referendumStage)
+}
+
+/*
+  The event is emitted when referendum finished and all revealed votes were counted.
+*/
+export async function referendum_ReferendumFinished({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  // const [optionResultsRaw] = new Referendum.ReferendumFinishedEvent(event).params
+
+  const referendumFinishedEvent = new ReferendumFinishedEvent({
+    ...genericEventFields(event),
+  })
+
+  await store.save<ReferendumFinishedEvent>(referendumFinishedEvent)
+
+  // no specific event processing
+}
+
+/*
+  The event is emitted when a vote is casted in the council election.
+*/
+export async function referendum_VoteCast({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing - init
+
+  const [account, hash, stake] = new Referendum.VoteCastEvent(event).params
+  const votePower = calculateVotePower(account.toString(), stake)
+
+  // specific event processing
+
+  const electionRound = await getCurrentElectionRound(store)
+
+  const castVote = new CastVote({
+    commitment: hash.toString(),
+    electionRound,
+    stake,
+    stakeLocked: true,
+    castBy: account.toString(),
+    votePower: votePower,
+  })
+  await store.save<CastVote>(castVote)
+
+  // common event processing - save
+
+  const voteCastEvent = new VoteCastEvent({
+    ...genericEventFields(event),
+    castVote,
+  })
+
+  await store.save<VoteCastEvent>(voteCastEvent)
+}
+
+/*
+  The event is emitted when a previously casted vote is revealed.
+*/
+export async function referendum_VoteRevealed({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing - init
+
+  const [account, memberId /*, salt */] = new Referendum.VoteRevealedEvent(event).params
+
+  // specific event processing
+
+  // read vote info
+  const electionRound = await getCurrentElectionRound(store)
+  const candidate = await getCandidate(store, memberId.toString(), electionRound, ['member'])
+  const castVote = await getAccountCastVote(store, account.toString(), electionRound)
+
+  // update cast vote's voteFor info
+  castVote.voteFor = candidate
+  await store.save<CastVote>(castVote)
+
+  // increase candidate's total vote power received accordingly
+  candidate.votePower = candidate.votePower.add(castVote.votePower)
+  candidate.lastVoteReceivedAtBlock = new BN(event.blockNumber)
+  candidate.lastVoteReceivedAtEventNumber = event.indexInBlock
+  await store.save<Candidate>(candidate)
+
+  // common event processing - save
+
+  const voteRevealedEvent = new VoteRevealedEvent({
+    ...genericEventFields(event),
+    castVote,
+  })
+
+  await store.save<VoteRevealedEvent>(voteRevealedEvent)
+}
+
+/*
+  The event is emitted when a vote's stake is released.
+*/
+export async function referendum_StakeReleased({ event, store }: EventContext & StoreContext): Promise<void> {
+  // common event processing
+
+  const [stakingAccount] = new Referendum.StakeReleasedEvent(event).params
+
+  const stakeReleasedEvent = new StakeReleasedEvent({
+    ...genericEventFields(event),
+    stakingAccount: stakingAccount.toString(),
+  })
+
+  await store.save<StakeReleasedEvent>(stakeReleasedEvent)
+
+  // specific event processing
+
+  const castVote = await getAccountCastVote(store, stakingAccount.toString())
+  castVote.stakeLocked = false
+
+  await store.save<CastVote>(castVote)
+}

+ 174 - 38
query-node/mappings/forum.ts → query-node/mappings/src/forum.ts

@@ -1,8 +1,15 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common'
-import { bytesToString, deserializeMetadata, genericEventFields, getWorker } from './common'
+import { EventContext, StoreContext, DatabaseManager } from '@joystream/hydra-common'
+import {
+  bytesToString,
+  deserializeMetadata,
+  genericEventFields,
+  getWorker,
+  inconsistentState,
+  perpareString,
+} from './common'
 import {
   CategoryCreatedEvent,
   CategoryStatusActive,
@@ -20,7 +27,7 @@ import {
   ForumPollAlternative,
   ThreadModeratedEvent,
   ThreadStatusModerated,
-  ThreadTitleUpdatedEvent,
+  ThreadMetadataUpdatedEvent,
   ThreadDeletedEvent,
   ThreadStatusLocked,
   ThreadStatusRemoved,
@@ -46,11 +53,20 @@ import {
   PostTextUpdatedEvent,
   PostDeletedEvent,
   PostStatusRemoved,
+  ForumThreadTag,
 } from 'query-node/dist/model'
-import { Forum } from './generated/types'
+import { Forum } from '../generated/types'
 import { PostReactionId, PrivilegedActor } from '@joystream/types/augment/all'
-import { ForumPostMetadata, ForumPostReaction as SupportedPostReactions } from '@joystream/metadata-protobuf'
+import {
+  ForumPostMetadata,
+  ForumPostReaction as SupportedPostReactions,
+  ForumThreadMetadata,
+} from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
+import { MAX_TAGS_PER_FORUM_THREAD } from '@joystream/metadata-protobuf/consts'
 import { Not, In } from 'typeorm'
+import { Bytes } from '@polkadot/types'
+import _ from 'lodash'
 
 async function getCategory(store: DatabaseManager, categoryId: string, relations?: string[]): Promise<ForumCategory> {
   const category = await store.get(ForumCategory, { where: { id: categoryId }, relations })
@@ -70,8 +86,8 @@ async function getThread(store: DatabaseManager, threadId: string): Promise<Foru
   return thread
 }
 
-async function getPost(store: DatabaseManager, postId: string): Promise<ForumPost> {
-  const post = await store.get(ForumPost, { where: { id: postId } })
+async function getPost(store: DatabaseManager, postId: string, relations?: 'thread'[]): Promise<ForumPost> {
+  const post = await store.get(ForumPost, { where: { id: postId }, relations })
   if (!post) {
     throw new Error(`Forum post not found by id: ${postId.toString()}`)
   }
@@ -108,6 +124,60 @@ async function getActorWorker(store: DatabaseManager, actor: PrivilegedActor): P
   return worker
 }
 
+function normalizeForumTagLabel(label: string): string {
+  // Optionally: normalize to lowercase & ASCII only?
+  return perpareString(label)
+}
+
+function parseThreadMetadata(metaBytes: Bytes) {
+  const meta = deserializeMetadata(ForumThreadMetadata, metaBytes)
+  return {
+    title: meta ? meta.title : bytesToString(metaBytes),
+    tags:
+      meta && isSet(meta.tags)
+        ? _.uniq(meta.tags.slice(0, MAX_TAGS_PER_FORUM_THREAD).map((label) => normalizeForumTagLabel(label))).filter(
+            (v) => v // Filter out empty strings
+          )
+        : undefined,
+  }
+}
+
+async function prepareThreadTagsToSet(
+  { event, store }: StoreContext & EventContext,
+  labels: string[]
+): Promise<ForumThreadTag[]> {
+  const eventTime = new Date(event.blockTimestamp)
+  return Promise.all(
+    labels.map(async (label) => {
+      const forumTag =
+        (await store.get(ForumThreadTag, { where: { id: label } })) ||
+        new ForumThreadTag({
+          id: label,
+          createdAt: eventTime,
+          visibleThreadsCount: 0,
+        })
+      forumTag.updatedAt = eventTime
+      ++forumTag.visibleThreadsCount
+      await store.save<ForumThreadTag>(forumTag)
+      return forumTag
+    })
+  )
+}
+
+async function unsetThreadTags({ event, store }: StoreContext & EventContext, tags: ForumThreadTag[]): Promise<void> {
+  const eventTime = new Date(event.blockTimestamp)
+  await Promise.all(
+    tags.map(async (forumTag) => {
+      --forumTag.visibleThreadsCount
+      if (forumTag.visibleThreadsCount < 0) {
+        inconsistentState('Trying to update forumTag.visibleThreadsCount to a number below 0!')
+      }
+      forumTag.updatedAt = eventTime
+      await store.save<ForumThreadTag>(forumTag)
+    })
+  )
+}
+
 // Get standarized PostReactionResult by PostReactionId
 function parseReaction(reactionId: PostReactionId): typeof PostReactionResult {
   switch (reactionId.toNumber()) {
@@ -201,11 +271,21 @@ export async function forum_CategoryDeleted({ event, store }: EventContext & Sto
   await store.save<ForumCategory>(category)
 }
 
-export async function forum_ThreadCreated({ event, store }: EventContext & StoreContext): Promise<void> {
-  const { forumUserId, categoryId, title, text, poll } = new Forum.CreateThreadCall(event).args
-  const [threadId] = new Forum.ThreadCreatedEvent(event).params
+export async function forum_ThreadCreated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
+  const [
+    categoryId,
+    threadId,
+    postId,
+    memberId,
+    threadMetaBytes,
+    postTextBytes,
+    pollInput,
+  ] = new Forum.ThreadCreatedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
-  const author = new Membership({ id: forumUserId.toString() })
+  const author = new Membership({ id: memberId.toString() })
+
+  const { title, tags } = parseThreadMetadata(threadMetaBytes)
 
   const thread = new ForumThread({
     createdAt: eventTime,
@@ -213,28 +293,31 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
     id: threadId.toString(),
     author,
     category: new ForumCategory({ id: categoryId.toString() }),
-    title: bytesToString(title),
+    title: title || '',
     isSticky: false,
     status: new ThreadStatusActive(),
+    isVisible: true,
+    visiblePostsCount: 1,
+    tags: tags ? await prepareThreadTagsToSet(ctx, tags) : [],
   })
   await store.save<ForumThread>(thread)
 
-  if (poll.isSome) {
+  if (pollInput.isSome) {
     const threadPoll = new ForumPoll({
       createdAt: eventTime,
       updatedAt: eventTime,
-      description: bytesToString(poll.unwrap().description_hash), // FIXME: This should be raw description!
-      endTime: new Date(poll.unwrap().end_time.toNumber()),
+      description: bytesToString(pollInput.unwrap().description),
+      endTime: new Date(pollInput.unwrap().end_time.toNumber()),
       thread,
     })
     await store.save<ForumPoll>(threadPoll)
     await Promise.all(
-      poll.unwrap().poll_alternatives.map(async (alt, index) => {
+      pollInput.unwrap().poll_alternatives.map(async (alt, index) => {
         const alternative = new ForumPollAlternative({
           createdAt: eventTime,
           updatedAt: eventTime,
           poll: threadPoll,
-          text: bytesToString(alt.alternative_text_hash), // FIXME: This should be raw text!
+          text: bytesToString(alt),
           index,
         })
 
@@ -246,8 +329,8 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
   const threadCreatedEvent = new ThreadCreatedEvent({
     ...genericEventFields(event),
     thread,
-    title: bytesToString(title),
-    text: bytesToString(text),
+    title: title || '',
+    text: bytesToString(postTextBytes),
   })
   await store.save<ThreadCreatedEvent>(threadCreatedEvent)
 
@@ -255,19 +338,24 @@ export async function forum_ThreadCreated({ event, store }: EventContext & Store
   postOrigin.threadCreatedEventId = threadCreatedEvent.id
 
   const initialPost = new ForumPost({
-    // FIXME: The postId is unknown
+    id: postId.toString(),
     createdAt: eventTime,
     updatedAt: eventTime,
     author,
     thread,
-    text: bytesToString(text),
+    text: bytesToString(postTextBytes),
     status: new PostStatusActive(),
+    isVisible: true,
     origin: postOrigin,
   })
   await store.save<ForumPost>(initialPost)
+
+  thread.initialPost = initialPost
+  await store.save<ForumThread>(thread)
 }
 
-export async function forum_ThreadModerated({ event, store }: EventContext & StoreContext): Promise<void> {
+export async function forum_ThreadModerated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
   const [threadId, rationaleBytes, privilegedActor] = new Forum.ThreadModeratedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const actorWorker = await getActorWorker(store, privilegedActor)
@@ -287,28 +375,51 @@ export async function forum_ThreadModerated({ event, store }: EventContext & Sto
 
   thread.updatedAt = eventTime
   thread.status = newStatus
+  thread.isVisible = false
+  thread.visiblePostsCount = 0
+  await unsetThreadTags(ctx, thread.tags || [])
   await store.save<ForumThread>(thread)
 }
 
-export async function forum_ThreadTitleUpdated({ event, store }: EventContext & StoreContext): Promise<void> {
-  const [threadId, , , newTitleBytes] = new Forum.ThreadTitleUpdatedEvent(event).params
+export async function forum_ThreadMetadataUpdated(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
+  const [threadId, , , newMetadataBytes] = new Forum.ThreadMetadataUpdatedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const thread = await getThread(store, threadId.toString())
 
-  const threadTitleUpdatedEvent = new ThreadTitleUpdatedEvent({
-    ...genericEventFields(event),
-    thread,
-    newTitle: bytesToString(newTitleBytes),
-  })
+  const { title: newTitle, tags: newTagIds } = parseThreadMetadata(newMetadataBytes)
+
+  // Only update tags if set
+  if (isSet(newTagIds)) {
+    const currentTagIds = (thread.tags || []).map((t) => t.id)
+    const tagIdsToSet = _.difference(newTagIds, currentTagIds)
+    const tagIdsToUnset = _.difference(currentTagIds, newTagIds)
+    const newTags = await prepareThreadTagsToSet(ctx, tagIdsToSet)
+    await unsetThreadTags(
+      ctx,
+      (thread.tags || []).filter((t) => tagIdsToUnset.includes(t.id))
+    )
+    thread.tags = newTags
+  }
 
-  await store.save<ThreadTitleUpdatedEvent>(threadTitleUpdatedEvent)
+  if (isSet(newTitle)) {
+    thread.title = newTitle
+  }
 
   thread.updatedAt = eventTime
-  thread.title = bytesToString(newTitleBytes)
   await store.save<ForumThread>(thread)
+
+  const threadMetadataUpdatedEvent = new ThreadMetadataUpdatedEvent({
+    ...genericEventFields(event),
+    thread,
+    newTitle: newTitle || undefined,
+  })
+
+  await store.save<ThreadMetadataUpdatedEvent>(threadMetadataUpdatedEvent)
 }
 
-export async function forum_ThreadDeleted({ event, store }: EventContext & StoreContext): Promise<void> {
+export async function forum_ThreadDeleted(ctx: EventContext & StoreContext): Promise<void> {
+  const { event, store } = ctx
   const [threadId, , , hide] = new Forum.ThreadDeletedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const thread = await getThread(store, threadId.toString())
@@ -320,10 +431,15 @@ export async function forum_ThreadDeleted({ event, store }: EventContext & Store
 
   await store.save<ThreadDeletedEvent>(threadDeletedEvent)
 
-  const status = hide.valueOf() ? new ThreadStatusRemoved() : new ThreadStatusLocked()
+  const status = hide.isTrue ? new ThreadStatusRemoved() : new ThreadStatusLocked()
   status.threadDeletedEventId = threadDeletedEvent.id
   thread.status = status
   thread.updatedAt = eventTime
+  if (hide.isTrue) {
+    thread.isVisible = false
+    thread.visiblePostsCount = 0
+    await unsetThreadTags(ctx, thread.tags || [])
+  }
   await store.save<ForumThread>(thread)
 }
 
@@ -366,6 +482,7 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
   const [postId, forumUserId, , threadId, metadataBytes, isEditable] = new Forum.PostAddedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
 
+  const thread = await getThread(store, threadId.toString())
   const metadata = deserializeMetadata(ForumPostMetadata, metadataBytes)
   const postText = metadata ? metadata.text || '' : bytesToString(metadataBytes)
   const repliesToPost =
@@ -380,8 +497,9 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
     createdAt: eventTime,
     updatedAt: eventTime,
     text: postText,
-    thread: new ForumThread({ id: threadId.toString() }),
+    thread,
     status: postStatus,
+    isVisible: true,
     author: new Membership({ id: forumUserId.toString() }),
     origin: postOrigin,
     repliesTo: repliesToPost || undefined,
@@ -399,6 +517,10 @@ export async function forum_PostAdded({ event, store }: EventContext & StoreCont
   // Update the other side of cross-relationship
   postOrigin.postAddedEventId = postAddedEvent.id
   await store.save<ForumPost>(post)
+
+  ++thread.visiblePostsCount
+  thread.updatedAt = eventTime
+  await store.save<ForumThread>(thread)
 }
 
 export async function forum_CategoryStickyThreadUpdate({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -469,7 +591,7 @@ export async function forum_PostModerated({ event, store }: EventContext & Store
   const [postId, rationaleBytes, privilegedActor] = new Forum.PostModeratedEvent(event).params
   const eventTime = new Date(event.blockTimestamp)
   const actorWorker = await getActorWorker(store, privilegedActor)
-  const post = await getPost(store, postId.toString())
+  const post = await getPost(store, postId.toString(), ['thread'])
 
   const postModeratedEvent = new PostModeratedEvent({
     ...genericEventFields(event),
@@ -485,7 +607,13 @@ export async function forum_PostModerated({ event, store }: EventContext & Store
 
   post.updatedAt = eventTime
   post.status = newStatus
+  post.isVisible = false
   await store.save<ForumPost>(post)
+
+  const { thread } = post
+  --thread.visiblePostsCount
+  thread.updatedAt = eventTime
+  await store.save<ForumThread>(thread)
 }
 
 export async function forum_PostReacted({ event, store }: EventContext & StoreContext): Promise<void> {
@@ -558,14 +686,22 @@ export async function forum_PostDeleted({ event, store }: EventContext & StoreCo
   await store.save<PostDeletedEvent>(postDeletedEvent)
 
   await Promise.all(
-    postsData.map(async ([, , postId, hideFlag]) => {
-      const post = await getPost(store, postId.toString())
-      const newStatus = hideFlag.valueOf() ? new PostStatusRemoved() : new PostStatusLocked()
+    Array.from(postsData.entries()).map(async ([{ post_id: postId }, hideFlag]) => {
+      const post = await getPost(store, postId.toString(), ['thread'])
+      const newStatus = hideFlag.isTrue ? new PostStatusRemoved() : new PostStatusLocked()
       newStatus.postDeletedEventId = postDeletedEvent.id
       post.updatedAt = eventTime
       post.status = newStatus
       post.deletedInEvent = postDeletedEvent
+      post.isVisible = hideFlag.isFalse
       await store.save<ForumPost>(post)
+
+      if (hideFlag.isTrue) {
+        const { thread } = post
+        --thread.visiblePostsCount
+        thread.updatedAt = eventTime
+        await store.save<ForumThread>(thread)
+      }
     })
   )
 }

+ 0 - 0
query-node/mappings/genesis-data/index.ts → query-node/mappings/src/genesis-data/index.ts


+ 0 - 0
query-node/mappings/genesis-data/members.json → query-node/mappings/src/genesis-data/members.json


+ 0 - 0
query-node/mappings/genesis-data/membershipSystem.json → query-node/mappings/src/genesis-data/membershipSystem.json


+ 0 - 0
query-node/mappings/genesis-data/workers.json → query-node/mappings/src/genesis-data/workers.json


+ 0 - 0
query-node/mappings/genesis-data/workingGroups.json → query-node/mappings/src/genesis-data/workingGroups.json


+ 67 - 0
query-node/mappings/src/genesis.ts

@@ -0,0 +1,67 @@
+import { StoreContext, DatabaseManager } from '@joystream/hydra-common'
+import BN from 'bn.js'
+import { MembershipSystemSnapshot, WorkingGroup, ElectedCouncil, ElectionRound } from 'query-node/dist/model'
+import { membershipSystem, workingGroups } from './genesis-data'
+import { CURRENT_NETWORK } from './common'
+
+export async function loadGenesisData({ store }: StoreContext): Promise<void> {
+  await initMembershipSystem(store)
+
+  await initWorkingGroups(store)
+
+  await initFirstElectionRound(store)
+
+  // TODO: members, workers
+}
+
+async function initMembershipSystem(store: DatabaseManager) {
+  await store.save<MembershipSystemSnapshot>(
+    new MembershipSystemSnapshot({
+      createdAt: new Date(0),
+      updatedAt: new Date(0),
+      snapshotBlock: 0,
+      ...membershipSystem,
+      membershipPrice: new BN(membershipSystem.membershipPrice),
+      invitedInitialBalance: new BN(membershipSystem.invitedInitialBalance),
+    })
+  )
+}
+
+async function initWorkingGroups(store: DatabaseManager) {
+  await Promise.all(
+    workingGroups.map(async (group) =>
+      store.save<WorkingGroup>(
+        new WorkingGroup({
+          createdAt: new Date(0),
+          updatedAt: new Date(0),
+          id: group.name,
+          name: group.name,
+          budget: new BN(group.budget),
+        })
+      )
+    )
+  )
+}
+
+async function initFirstElectionRound(store: DatabaseManager) {
+  const electedCouncil = new ElectedCouncil({
+    councilMembers: [],
+    updates: [],
+    electedAtBlock: 0,
+    electedAtTime: new Date(0),
+    electedAtNetwork: CURRENT_NETWORK,
+    councilElections: [],
+    nextCouncilElections: [],
+    isResigned: false,
+  })
+  await store.save<ElectedCouncil>(electedCouncil)
+
+  const initialElectionRound = new ElectionRound({
+    cycleId: 0,
+    isFinished: false,
+    castVotes: [],
+    electedCouncil,
+    candidates: [],
+  })
+  await store.save<ElectionRound>(initialElectionRound)
+}

+ 1 - 0
query-node/mappings/index.ts → query-node/mappings/src/index.ts

@@ -8,6 +8,7 @@ BN.prototype.toJSON = function () {
 export * from './content'
 export * from './membership'
 export * from './storage'
+export * from './council'
 export * from './workingGroups'
 export * from './proposals'
 export * from './proposalsDiscussion'

+ 24 - 15
query-node/mappings/membership.ts → query-node/mappings/src/membership.ts

@@ -1,8 +1,8 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@dzlzv/hydra-common'
-import { Members } from './generated/types'
+import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@joystream/hydra-common'
+import { Members } from '../generated/types'
 import { MemberId, BuyMembershipParameters, InviteMembershipParameters } from '@joystream/types/augment/all'
 import { MembershipMetadata } from '@joystream/metadata-protobuf'
 import { bytesToString, deserializeMetadata, genericEventFields } from './common'
@@ -27,6 +27,7 @@ import {
   LeaderInvitationQuotaUpdatedEvent,
   MembershipEntryPaid,
   MembershipEntryInvited,
+  AvatarUri,
 } from 'query-node/dist/model'
 
 async function getMemberById(store: DatabaseManager, id: MemberId): Promise<Membership> {
@@ -69,16 +70,19 @@ async function createNewMemberFromParams(
   params: BuyMembershipParameters | InviteMembershipParameters
 ): Promise<Membership> {
   const { defaultInviteCount } = await getLatestMembershipSystemSnapshot(store)
-  const { root_account: rootAccount, controller_account: controllerAccount, handle, metadata: metatadaBytes } = params
-  const metadata = deserializeMetadata(MembershipMetadata, metatadaBytes)
+  const { root_account: rootAccount, controller_account: controllerAccount, handle, metadata: metadataBytes } = params
+  const metadata = deserializeMetadata(MembershipMetadata, metadataBytes)
   const eventTime = new Date(event.blockTimestamp)
 
+  const avatar = new AvatarUri()
+  avatar.avatarUri = metadata?.avatarUri ?? ''
+
   const metadataEntity = new MemberMetadata({
     createdAt: eventTime,
     updatedAt: eventTime,
     name: metadata?.name || undefined,
     about: metadata?.about || undefined,
-    // TODO: avatar
+    avatar,
   })
 
   const member = new Membership({
@@ -104,6 +108,10 @@ async function createNewMemberFromParams(
         ? new Membership({ id: (params as InviteMembershipParameters).inviting_member_id.toString() })
         : undefined,
     isFoundingMember: false,
+    isCouncilMember: false,
+
+    councilCandidacies: [],
+    councilMembers: [],
   })
 
   await store.save<MemberMetadata>(member.metadata)
@@ -140,11 +148,8 @@ export async function members_MembershipBought({ store, event }: EventContext &
 }
 
 export async function members_MemberProfileUpdated({ store, event }: EventContext & StoreContext): Promise<void> {
-  const [memberId] = new Members.MemberProfileUpdatedEvent(event).params
-  const { metadata: metadataBytesOpt, handle } = new Members.UpdateProfileCall(event).args
-  const metadata = metadataBytesOpt.isSome
-    ? deserializeMetadata(MembershipMetadata, metadataBytesOpt.unwrap())
-    : undefined
+  const [memberId, newHandle, newMetadata] = new Members.MemberProfileUpdatedEvent(event).params
+  const metadata = newMetadata.isSome ? deserializeMetadata(MembershipMetadata, newMetadata.unwrap()) : undefined
   const member = await getMemberById(store, memberId)
   const eventTime = new Date(event.blockTimestamp)
 
@@ -157,9 +162,14 @@ export async function members_MemberProfileUpdated({ store, event }: EventContex
     member.metadata.about = (metadata.about || null) as string | undefined
     member.metadata.updatedAt = eventTime
   }
-  // TODO: avatar
-  if (handle.isSome) {
-    member.handle = bytesToString(handle.unwrap())
+
+  if (typeof metadata?.avatarUri === 'string') {
+    member.metadata.avatar = new AvatarUri()
+    member.metadata.avatar.avatarUri = metadata.avatarUri
+  }
+
+  if (newHandle.isSome) {
+    member.handle = bytesToString(newHandle.unwrap())
     member.updatedAt = eventTime
   }
 
@@ -181,8 +191,7 @@ export async function members_MemberProfileUpdated({ store, event }: EventContex
 }
 
 export async function members_MemberAccountsUpdated({ store, event }: EventContext & StoreContext): Promise<void> {
-  const [memberId] = new Members.MemberAccountsUpdatedEvent(event).params
-  const { newRootAccount, newControllerAccount } = new Members.UpdateAccountsCall(event).args
+  const [memberId, newRootAccount, newControllerAccount] = new Members.MemberAccountsUpdatedEvent(event).params
   const member = await getMemberById(store, memberId)
   const eventTime = new Date(event.blockTimestamp)
 

+ 26 - 23
query-node/mappings/proposals.ts → query-node/mappings/src/proposals.ts

@@ -1,8 +1,8 @@
 /*
 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 { SubstrateEvent, DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common'
+import { ProposalDetails as RuntimeProposalDetails } from '@joystream/types/augment/all'
 import BN from 'bn.js'
 import {
   Proposal,
@@ -57,21 +57,15 @@ import {
   ProposalCancelledEvent,
   ProposalCreatedEvent,
   RuntimeWasmBytecode,
+  ProposalDiscussionThread,
+  ProposalDiscussionThreadModeOpen,
 } from 'query-node/dist/model'
-import { bytesToString, genericEventFields, getWorkingGroupModuleName, perpareString } from './common'
-import { ProposalsEngine, ProposalsCodex } from './generated/types'
+import { bytesToString, genericEventFields, getWorkingGroupModuleName, MemoryCache, perpareString } from './common'
+import { ProposalsEngine, ProposalsCodex } from '../generated/types'
 import { createWorkingGroupOpeningMetadata } from './workingGroups'
 import { blake2AsHex } from '@polkadot/util-crypto'
 import { Bytes } from '@polkadot/types'
 
-// FIXME: https://github.com/Joystream/joystream/issues/2457
-type ProposalsMappingsMemoryCache = {
-  lastCreatedProposalId: ProposalId | null
-}
-const proposalsMappingsMemoryCache: ProposalsMappingsMemoryCache = {
-  lastCreatedProposalId: null,
-}
-
 async function getProposal(store: DatabaseManager, id: string) {
   const proposal = await store.get(Proposal, { where: { id } })
   if (!proposal) {
@@ -321,24 +315,19 @@ async function parseProposalDetails(
   }
 }
 
-export async function proposalsEngine_ProposalCreated({ event }: EventContext & StoreContext): Promise<void> {
-  const [, proposalId] = new ProposalsEngine.ProposalCreatedEvent(event).params
-
-  // Cache the id
-  proposalsMappingsMemoryCache.lastCreatedProposalId = proposalId
-}
-
 export async function proposalsCodex_ProposalCreated({ store, event }: EventContext & StoreContext): Promise<void> {
-  const [generalProposalParameters, runtimeProposalDetails] = new ProposalsCodex.ProposalCreatedEvent(event).params
+  const [proposalId, generalProposalParameters, runtimeProposalDetails] = new ProposalsCodex.ProposalCreatedEvent(
+    event
+  ).params
   const eventTime = new Date(event.blockTimestamp)
   const proposalDetails = await parseProposalDetails(event, store, runtimeProposalDetails)
 
-  if (!proposalsMappingsMemoryCache.lastCreatedProposalId) {
-    throw new Error('Unexpected state: proposalsMappingsMemoryCache.lastCreatedProposalId is empty')
+  if (!MemoryCache.lastCreatedProposalThreadId) {
+    throw new Error('Unexpected state: MemoryCache.lastCreatedProposalThreadId is empty')
   }
 
   const proposal = new Proposal({
-    id: proposalsMappingsMemoryCache.lastCreatedProposalId.toString(),
+    id: proposalId.toString(),
     createdAt: eventTime,
     updatedAt: eventTime,
     details: proposalDetails,
@@ -349,11 +338,22 @@ export async function proposalsCodex_ProposalCreated({ store, event }: EventCont
     exactExecutionBlock: generalProposalParameters.exact_execution_block.unwrapOr(undefined)?.toNumber(),
     stakingAccount: generalProposalParameters.staking_account_id.toString(),
     status: new ProposalStatusDeciding(),
+    isFinalized: false,
     statusSetAtBlock: event.blockNumber,
     statusSetAtTime: eventTime,
   })
   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,
@@ -453,6 +453,7 @@ export async function proposalsEngine_ProposalDecisionMade({
       | ProposalStatusSlashed
       | ProposalStatusVetoed).proposalDecisionMadeEventId = proposalDecisionMadeEvent.id
     proposal.status = decisionStatus
+    proposal.isFinalized = true
     proposal.statusSetAtBlock = event.blockNumber
     proposal.statusSetAtTime = eventTime
     proposal.updatedAt = eventTime
@@ -485,6 +486,7 @@ export async function proposalsEngine_ProposalExecuted({ store, event }: EventCo
 
   newStatus.proposalExecutedEventId = proposalExecutedEvent.id
   proposal.status = newStatus
+  proposal.isFinalized = true
   proposal.statusSetAtBlock = event.blockNumber
   proposal.statusSetAtTime = eventTime
   proposal.updatedAt = eventTime
@@ -533,6 +535,7 @@ export async function proposalsEngine_ProposalCancelled({ store, event }: EventC
   await store.save<ProposalCancelledEvent>(proposalCancelledEvent)
 
   proposal.status = new ProposalStatusCancelled()
+  proposal.isFinalized = true
   proposal.status.cancelledInEventId = proposalCancelledEvent.id
   proposal.statusSetAtBlock = event.blockNumber
   proposal.statusSetAtTime = eventTime

+ 186 - 0
query-node/mappings/src/proposalsDiscussion.ts

@@ -0,0 +1,186 @@
+/*
+eslint-disable @typescript-eslint/naming-convention
+*/
+import { EventContext, StoreContext, DatabaseManager } from '@joystream/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'
+
+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
+}
+
+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_ThreadCreated({ event }: EventContext & StoreContext): Promise<void> {
+  const [threadId] = new ProposalsDiscussion.ThreadCreatedEvent(event).params
+  MemoryCache.lastCreatedProposalThreadId = threadId
+}
+
+export async function proposalsDiscussion_PostCreated({ event, store }: EventContext & StoreContext): Promise<void> {
+  // FIXME: extremely ugly and insecure workaround for `batch` and `sudo` calls support.
+  // Ideally this data would be part of the event data
+  let editable: boolean
+
+  if (!event.extrinsic) {
+    throw new Error('Missing extrinsic for proposalsDiscussion.PostCreated event!')
+  } else if (event.extrinsic.section === 'utility' && event.extrinsic.method === 'batch') {
+    // We cannot use new Utility.BatchCall(event).args, because createTypeUnsafe fails on Call
+    // First (and only) argument of utility.batch is "calls"
+    const calls = event.extrinsic.args[0].value as any[]
+    // proposalsDiscussion.addPost call index is currently 0x1f00
+    const call = calls.find((c) => c.callIndex === '0x1f00')
+    if (!call) {
+      throw new Error('Could not find proposalsDiscussion.addPostCall in a batch!')
+    }
+    editable = call.args.editable
+  } else if (
+    event.extrinsic.section === 'sudo' &&
+    (event.extrinsic.method === 'sudo' || event.extrinsic.method === 'sudoAs')
+  ) {
+    // Extract call arg
+    editable = (event.extrinsic.args[0].value as any).args.editable
+  } else {
+    editable = new ProposalsDiscussion.AddPostCall(event).args.editable.valueOf()
+  }
+
+  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 ? new ProposalDiscussionPostStatusActive() : new ProposalDiscussionPostStatusLocked(),
+    isVisible: true,
+    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_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.isVisible = hide.isFalse
+  post.status = newStatus
+  post.updatedAt = eventTime
+  await store.save<ProposalDiscussionPost>(post)
+}

+ 2 - 2
query-node/mappings/storage.ts → query-node/mappings/src/storage.ts

@@ -1,7 +1,7 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext, DatabaseManager } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext, DatabaseManager } from '@joystream/hydra-common'
 import { FindConditions, In, Raw } from 'typeorm'
 import {
   createDataObject,
@@ -11,7 +11,7 @@ import {
   logger,
   unexpectedData,
 } from './common'
-import { DataDirectory } from './generated/types'
+import { DataDirectory } from '../generated/types'
 import { ContentId, StorageObjectOwner } from '@joystream/types/augment'
 import { ContentId as Custom_ContentId } from '@joystream/types/storage'
 import { registry } from '@joystream/types'

+ 2 - 2
query-node/mappings/workingGroups.ts → query-node/mappings/src/workingGroups.ts

@@ -1,9 +1,9 @@
 /*
 eslint-disable @typescript-eslint/naming-convention
 */
-import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@dzlzv/hydra-common'
+import { EventContext, StoreContext, DatabaseManager, SubstrateEvent } from '@joystream/hydra-common'
 
-import { StorageWorkingGroup as WorkingGroups } from './generated/types'
+import { StorageWorkingGroup as WorkingGroups } from '../generated/types'
 import {
   ApplicationMetadata,
   IAddUpcomingOpening,

+ 1 - 1
query-node/mappings/tsconfig.json

@@ -21,5 +21,5 @@
       // "query-node/*": [ "../generated/graphql-server/src/*" ]
     }
   },
-  "include": ["./**/*"]
+  "include": ["./src/**/*"]
 }

+ 2 - 2
query-node/package.json

@@ -6,7 +6,7 @@
     "build": "./build.sh",
     "start": "./start.sh",
     "rebuild": "yarn db:drop && yarn clean:query-node && yarn codegen:query-node && yarn db:prepare && yarn db:migrate",
-    "lint": "echo \"Skippinng\"",
+    "lint": "yarn workspace query-node-mappings lint",
     "clean": "rm -rf ./generated",
     "clean:query-node": "rm -rf ./generated/graphql-server",
     "processor:start": "DEBUG=${DEBUG} hydra-processor run -e ../.env",
@@ -41,7 +41,7 @@
     "tslib": "^2.0.0",
     "@types/bn.js": "^4.11.6",
     "bn.js": "^5.1.2",
-    "@dzlzv/hydra-processor": "3.1.0-alpha.0",
+    "@joystream/hydra-processor": "3.1.0-alpha.13",
     "envsub": "4.0.7"
   },
   "volta": {

+ 282 - 0
query-node/schemas/council.graphql

@@ -0,0 +1,282 @@
+# TODO:
+# - do we need some fulltext search for council/election?
+
+# workaround for https://github.com/Joystream/hydra/issues/434
+type VariantNone @variant {
+  _phantom: Int
+}
+
+################### Council ####################################################
+
+type CouncilStageUpdate @entity {
+  "The new stage council got into."
+  stage: CouncilStage!
+
+  "Block number at which change happened."
+  changedAt: BigInt!
+
+  "Council term during which the update happened (if any)."
+  electedCouncil: ElectedCouncil
+
+  "Election not completed due to insufficient candidates or winners."
+  electionProblem: ElectionProblem
+}
+
+type CouncilStageAnnouncing @variant {
+  "Number of candidates aspiring to be elected as council members."
+  candidatesCount: BigInt!
+}
+
+type CouncilStageElection @variant {
+  "Number of candidates aspiring to be elected as council members."
+  candidatesCount: BigInt!
+}
+
+type CouncilStageIdle @variant {
+  # no properties
+
+  # TODO: remove me - variant needs to have at least 1 property now
+  dummy: Int
+}
+
+union CouncilStage = CouncilStageAnnouncing | CouncilStageElection | CouncilStageIdle | VariantNone
+
+enum ElectionProblem {
+  NOT_ENOUGH_CANDIDATES
+  NEW_COUNCIL_NOT_ELECTED
+}
+
+enum CandidacyStatus {
+  ACTIVE
+  WITHDRAWN
+  ELECTED
+  FAILED
+}
+
+type Candidate @entity {
+  "Account used for staking currency needed for the candidacy."
+  stakingAccountId: String!
+
+  "Account that will receive rewards if candidate's elected to the council."
+  rewardAccountId: String!
+
+  "Candidate's membership."
+  member: Membership!
+
+  "Election cycle"
+  electionRound: ElectionRound!
+
+  "Stake locked for the candidacy."
+  stake: BigInt!
+
+  "Reflects if the stake is still locked for candidacy or has been already released by the member."
+  stakeLocked: Boolean!
+
+  "Current candidate status"
+  status: CandidacyStatus!
+
+  "Sum of power of all votes received."
+  votePower: BigInt!
+
+  "Block in which the last vote was received."
+  lastVoteReceivedAtBlock: BigInt
+
+  "Event number in block in which the last vote was received."
+  lastVoteReceivedAtEventNumber: Int
+
+  "The metadata contained in note."
+  noteMetadata: CandidacyNoteMetadata!
+
+  "Votes received in referendums by this member."
+  votesReceived: [CastVote!]! @derivedFrom(field: "voteFor")
+}
+
+type CouncilMember @entity {
+  "Runtime council member id"
+  id: ID!
+
+  "Account used for staking currency for council membership."
+  stakingAccountId: String!
+
+  "Account that will receive used for reward currency for council membership."
+  rewardAccountId: String!
+
+  "Council member's membership."
+  member: Membership!
+
+  "Stake used for the council membership."
+  stake: BigInt!
+
+  "Block number in which council member received the last reward payment."
+  lastPaymentBlock: BigInt!
+
+  "Reward amount that should have been paid but couldn't be paid off due to insufficient budget."
+  unpaidReward: BigInt!
+
+  "Amount of reward collected by this council member so far."
+  accumulatedReward: BigInt!
+
+  electedInCouncil: ElectedCouncil!
+}
+
+type CandidacyNoteMetadata @entity {
+  "Candidacy header text."
+  header: String
+
+  "Candidate program in form of bullet points. Takes array with one empty string [''] as deletion request."
+  bulletPoints: [String!]
+
+  "Image uri of candidate's banner."
+  bannerImageUri: String
+
+  "Candidacy description (Markdown-formatted)."
+  description: String
+}
+
+################### Referendum #################################################
+
+# NOTE: Due to the bug https://github.com/Joystream/hydra/issues/467 `ReferendumStage*` variants were transformed to entities.
+#       It shouldn't have any negative impact on current usage, but it might need remodeling in the future depending on usage.
+
+type ReferendumStageVoting @entity {
+  "Block in which referendum started."
+  startedAtBlock: BigInt!
+
+  "Target number of winners."
+  winningTargetCount: BigInt!
+
+  "Election round"
+  electionRound: ElectionRound!
+}
+
+type ReferendumStageRevealing @entity {
+  "Block in which referendum started"
+  startedAtBlock: BigInt!
+
+  "Target number of winners"
+  winningTargetCount: BigInt!
+
+  "Election round."
+  electionRound: ElectionRound!
+}
+
+type CastVote @entity {
+  "Hashed vote that was casted before being revealed. Hex format."
+  commitment: String!
+
+  "Election round."
+  electionRound: ElectionRound!
+
+  "Stake used to back up the vote."
+  stake: BigInt!
+
+  "Reflects if the stake is still locked for candidacy or has been already released by the member."
+  stakeLocked: Boolean!
+
+  "Account that cast the vote."
+  castBy: String!
+
+  "Member receiving the vote."
+  voteFor: Candidate
+
+  "Vote's power."
+  votePower: BigInt!
+}
+
+################### Derived ####################################################
+
+type ElectedCouncil @entity {
+  "Members that were elected to the council."
+  councilMembers: [CouncilMember!]! @derivedFrom(field: "electedInCouncil")
+
+  "Changes to council status that were made during it's reign."
+  updates: [CouncilStageUpdate!]! @derivedFrom(field: "electedCouncil")
+
+  "Block number at which the council was elected."
+  electedAtBlock: Int!
+
+  "Block number at which the council reign ended and a new council was elected."
+  endedAtBlock: Int
+
+  "Time at which the council was elected."
+  electedAtTime: DateTime!
+
+  "Time at which the council reign ended and a new council was elected."
+  endedAtTime: DateTime
+
+  "Network running at the time of election."
+  electedAtNetwork: Network!
+
+  "Network running at the time of resignation."
+  endedAtNetwork: Network
+
+  # it might seems that derived field is wrongly set to `nextElectedCouncil`, but that's how it should be
+  "Elections held before the council was rightfully elected."
+  councilElections: [ElectionRound!]! @derivedFrom(field: "nextElectedCouncil")
+
+  # it might seems that derived field is wrongly set to `electedCouncil`, but that's how it should be
+  "Elections held before the next council was or will be rightfully elected."
+  nextCouncilElections: [ElectionRound!]! @derivedFrom(field: "electedCouncil")
+
+  "Sign if council is already resigned."
+  isResigned: Boolean!
+}
+
+type ElectionRound @entity {
+  "Election cycle ID."
+  cycleId: Int!
+
+  "Sign if election has already finished."
+  isFinished: Boolean!
+
+  "Block number at which the election ended."
+  endedAtBlock: Int
+
+  "Time at which the election ended."
+  endedAtTime: DateTime
+
+  "Network running at the time the election ended."
+  endedAtNetwork: Network
+
+  "Vote cast in the election round."
+  castVotes: [CastVote!]! @derivedFrom(field: "electionRound")
+
+  "Referendum voting stage that happened during this election round."
+  referendumStageVoting: ReferendumStageVoting @derivedFrom(field: "electionRound")
+
+  "Referendum revealing stage that happened during this election round."
+  referendumStageRevealing: ReferendumStageRevealing @derivedFrom(field: "electionRound")
+
+  "Council that is ruling during the election."
+  electedCouncil: ElectedCouncil!
+
+  "Council that was elected in this election round."
+  nextElectedCouncil: ElectedCouncil
+
+  "Candidates in this election round."
+  candidates: [Candidate!]! @derivedFrom(field: "electionRound")
+}
+
+# Not yet sure if this will be needed by apps using query node.
+#
+#type Budget @entity {
+#  "Block number at which the next rewards will be paid."
+#  nextRewardPaymentsAt: BigInt!
+#}
+#
+#type BudgetPayment @entity {
+#  "Block number at which the payment was done."
+#  paidAtBlock: Int!
+#
+#  "Member that was paid."
+#  member: Membership!
+#
+#  "Account that received the payment"
+#  account: String!
+#
+#  "Amount that was paid."
+#  amount: BigInt!
+#
+#  "Amount that couldn't be paid due to insufficient council budget's balance."
+#  unpaidAmount: BigInt!
+#}

+ 563 - 0
query-node/schemas/councilEvents.graphql

@@ -0,0 +1,563 @@
+################### Council ####################################################
+
+type AnnouncingPeriodStartedEvent 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 ###
+}
+
+type NotEnoughCandidatesEvent 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 ###
+}
+
+type VotingPeriodStartedEvent 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 ###
+
+  "Number of candidates in the election."
+  numOfCandidates: BigInt!
+}
+
+type NewCandidateEvent 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 ###
+
+  "Related candidate."
+  candidate: Candidate!
+
+  "Candidate's account used to stake currency."
+  stakingAccount: String!
+
+  "Candidate's account that will be recieving rewards if candidate's elected."
+  rewardAccount: String!
+
+  "Amount of currency to be staked for the candidacy."
+  balance: BigInt!
+}
+
+type NewCouncilElectedEvent 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 ###
+
+  "Newly elected council."
+  electedCouncil: ElectedCouncil!
+}
+
+type NewCouncilNotElectedEvent 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 ###
+}
+
+type CandidacyStakeReleaseEvent 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 ###
+
+  "Related candidate."
+  candidate: Candidate!
+}
+
+type CandidacyWithdrawEvent 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 ###
+
+  "Related candidate."
+  candidate: Candidate!
+}
+
+type CandidacyNoteSetEvent 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 ###
+
+  "Related candidate."
+  candidate: Candidate!
+
+  "The metadata contained in note."
+  noteMetadata: CandidacyNoteMetadata!
+}
+
+type RewardPaymentEvent 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 ###
+
+  "Related council member."
+  councilMember: CouncilMember!
+
+  "Candidate's account that will be recieving rewards if candidate's elected."
+  rewardAccount: String!
+
+  "Amount paid to the council member"
+  paidBalance: BigInt!
+
+  "Amount that couldn't be paid and will be paid the next time."
+  missingBalance: BigInt!
+}
+
+type BudgetBalanceSetEvent 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 ###
+
+  "Budget balance that has been set."
+  balance: BigInt!
+}
+
+type BudgetRefillEvent 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 ###
+
+  "Balance that has been refilled."
+  balance: BigInt!
+}
+
+type BudgetRefillPlannedEvent 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 ###
+
+  nextRefillInBlock: Int!
+}
+
+type BudgetIncrementUpdatedEvent 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 ###
+
+  "Amount that is added to the budget each time it's refilled."
+  amount: BigInt!
+}
+
+type CouncilorRewardUpdatedEvent 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 ###
+
+  "New reward amount paid each reward period."
+  rewardAmount: BigInt!
+}
+
+type RequestFundedEvent 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 ###
+
+  "Target account."
+  account: String!
+
+  "Funding amount."
+  amount: BigInt!
+}
+
+################### Referendum #################################################
+
+type ReferendumStartedEvent 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 ###
+
+  "Amount of winning referendum options."
+  winningTargetCount: BigInt!
+}
+
+type ReferendumStartedForcefullyEvent 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 ###
+
+  "Amount of winning referendum options."
+  winningTargetCount: BigInt!
+}
+
+type RevealingStageStartedEvent 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 ###
+}
+
+type ReferendumFinishedEvent 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 ###
+}
+
+type VoteCastEvent 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 ###
+
+  "Vote cast."
+  castVote: CastVote!
+}
+
+type VoteRevealedEvent 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 ###
+
+  "Vote cast."
+  castVote: CastVote!
+}
+
+type StakeReleasedEvent 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 ###
+
+  "Account used to stake the value."
+  stakingAccount: String!
+}

+ 28 - 2
query-node/schemas/forum.graphql

@@ -83,6 +83,12 @@ type ForumThread @entity {
   "All posts in the thread"
   posts: [ForumPost!] @derivedFrom(field: "thread")
 
+  "The intial post created along with the thread"
+  initialPost: ForumPost
+
+  "Number of non-deleted posts in the thread"
+  visiblePostsCount: Int!
+
   "Optional poll associated with the thread"
   poll: ForumPoll @derivedFrom(field: "thread")
 
@@ -95,8 +101,11 @@ type ForumThread @entity {
   "Current thread status"
   status: ThreadStatus!
 
-  "Theread title update events"
-  titleUpdates: [ThreadTitleUpdatedEvent!] @derivedFrom(field: "thread")
+  "True if the thread is either Active or Locked"
+  isVisible: Boolean!
+
+  "Theread metadata update events"
+  metadataUpdates: [ThreadMetadataUpdatedEvent!] @derivedFrom(field: "thread")
 
   # Required to create Many-to-Many relation
   "The events the thred was made sticky in"
@@ -104,6 +113,20 @@ type ForumThread @entity {
 
   "List of events that moved the thread to a different category"
   movedInEvents: [ThreadMovedEvent!] @derivedFrom(field: "thread")
+
+  "Assigned thread tags"
+  tags: [ForumThreadTag!]
+}
+
+type ForumThreadTag @entity {
+  "Tag id (and simultaneously - tag label)"
+  id: ID!
+
+  "Forum threads assigned to the tag"
+  threads: [ForumThread!] @derivedFrom(field: "tags")
+
+  "Number of non-removed threads currently assigned to the tag"
+  visibleThreadsCount: Int!
 }
 
 type ForumPoll @entity {
@@ -212,6 +235,9 @@ type ForumPost @entity {
   "Current post status"
   status: PostStatus!
 
+  "True if the post is either Active or Locked"
+  isVisible: Boolean!
+
   "The origin of the post (either thread creation event or regular PostAdded event)"
   origin: PostOrigin!
 

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

@@ -1,4 +1,4 @@
-type CategoryCreatedEvent @entity {
+type CategoryCreatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -24,7 +24,7 @@ type CategoryCreatedEvent @entity {
   # The actor is always lead
 }
 
-type CategoryArchivalStatusUpdatedEvent @entity {
+type CategoryArchivalStatusUpdatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -54,7 +54,7 @@ type CategoryArchivalStatusUpdatedEvent @entity {
   actor: Worker!
 }
 
-type CategoryDeletedEvent @entity {
+type CategoryDeletedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -81,7 +81,7 @@ type CategoryDeletedEvent @entity {
   actor: Worker!
 }
 
-type ThreadCreatedEvent @entity {
+type ThreadCreatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -113,7 +113,7 @@ type ThreadCreatedEvent @entity {
   # The author is already part of the Thread entity itself and is immutable
 }
 
-type ThreadModeratedEvent @entity {
+type ThreadModeratedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -143,22 +143,7 @@ type ThreadModeratedEvent @entity {
   actor: Worker!
 }
 
-# FIXME: Not emitted by the runtime
-# type ThreadUpdatedEvent @entity {
-#   "Generic event data"
-#   event: Event!
-
-#   "The thread beeing updated"
-#   thread: ForumThread!
-
-#   "The new archival status of the thread (true = archived)"
-#   newArchivalStatus: Boolean!
-
-#   "Actor responsible for the update"
-#   actor: Worker!
-# }
-
-type ThreadTitleUpdatedEvent @entity {
+type ThreadMetadataUpdatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -182,12 +167,12 @@ type ThreadTitleUpdatedEvent @entity {
   thread: ForumThread!
 
   "New title of the thread"
-  newTitle: String!
+  newTitle: String
 
   # Only author can update the thread title, so no actor information required
 }
 
-type ThreadDeletedEvent @entity {
+type ThreadDeletedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -213,7 +198,7 @@ type ThreadDeletedEvent @entity {
   # Only author can delete the thread, so no actor information required
 }
 
-type ThreadMovedEvent @entity {
+type ThreadMovedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -246,7 +231,7 @@ type ThreadMovedEvent @entity {
   actor: Worker!
 }
 
-type PostAddedEvent @entity {
+type PostAddedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -276,7 +261,7 @@ type PostAddedEvent @entity {
   text: String!
 }
 
-type PostModeratedEvent @entity {
+type PostModeratedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -306,7 +291,7 @@ type PostModeratedEvent @entity {
   actor: Worker!
 }
 
-type PostDeletedEvent @entity {
+type PostDeletedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -336,7 +321,7 @@ type PostDeletedEvent @entity {
   rationale: String!
 }
 
-type PostTextUpdatedEvent @entity {
+type PostTextUpdatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -380,7 +365,7 @@ type PostReactionResultInvalid @variant {
 
 union PostReactionResult = PostReactionResultCancel | PostReactionResultValid | PostReactionResultInvalid
 
-type PostReactedEvent @entity {
+type PostReactedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -410,7 +395,7 @@ type PostReactedEvent @entity {
   reactingMember: Membership!
 }
 
-type VoteOnPollEvent @entity {
+type VoteOnPollEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -437,7 +422,7 @@ type VoteOnPollEvent @entity {
   votingMember: Membership!
 }
 
-type CategoryStickyThreadUpdateEvent @entity {
+type CategoryStickyThreadUpdateEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"
@@ -467,7 +452,7 @@ type CategoryStickyThreadUpdateEvent @entity {
   actor: Worker!
 }
 
-type CategoryMembershipOfModeratorUpdatedEvent @entity {
+type CategoryMembershipOfModeratorUpdatedEvent implements Event @entity {
   ### GENERIC DATA ###
 
   "(network}-{blockNumber}-{indexInBlock}"

+ 27 - 1
query-node/schemas/membership.graphql

@@ -1,9 +1,21 @@
+type AvatarObject @variant {
+  "The avatar data object"
+  avatarObject: DataObject!
+}
+
+type AvatarUri @variant {
+  "The avatar URL"
+  avatarUri: String!
+}
+
+union Avatar = AvatarObject | AvatarUri
+
 type MemberMetadata @entity {
   "Member's name"
   name: String
 
   "Avatar data object"
-  avatar: DataObject
+  avatar: Avatar
 
   "Short text chosen by member to share information about themselves"
   about: String
@@ -71,6 +83,9 @@ type Membership @entity {
   "Whether member is founding member."
   isFoundingMember: Boolean!
 
+  "Whether member is elected in the current council."
+  isCouncilMember: Boolean!
+
   "Member's working group roles (current and past)"
   roles: [Worker!] @derivedFrom(field: "membership")
 
@@ -80,6 +95,17 @@ type Membership @entity {
 
   "Content channels the member owns"
   channels: [Channel!] @derivedFrom(field: "ownerMember")
+
+  # Council & Referendum relations
+
+  #"Council reward payment made received by the member."
+  #budgetPayments: [BudgetPayment!] @derivedFrom(field: "member")
+
+  "Candidacies announced by this member."
+  councilCandidacies: [Candidate!] @derivedFrom(field: "member")
+
+  "Elected councils' memberships of the member."
+  councilMembers: [CouncilMember!] @derivedFrom(field: "member")
 }
 
 type MembershipSystemSnapshot @entity {

+ 8 - 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!
@@ -67,6 +70,9 @@ type ProposalDiscussionPost @entity {
   "Current post status"
   status: ProposalDiscussionPostStatus!
 
+  "True if the post is either Active or Locked"
+  isVisible: Boolean!
+
   "Post's md-formatted text"
   text: String!
 

+ 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 ###
 

+ 3 - 0
query-node/schemas/proposals.graphql

@@ -138,6 +138,9 @@ type Proposal @entity {
   "Current proposal status"
   status: ProposalStatus!
 
+  "If true then the proposal status is final and will not change form this point"
+  isFinalized: Boolean
+
   # Additional fileds to avoid the need for complex filtering through status variant relations:
 
   "Number of the block the current status was set at"

+ 0 - 155
query-node/scripts/initializeDefaultSchemas.ts

@@ -1,155 +0,0 @@
-/* eslint-disable import/first */
-import 'reflect-metadata'
-
-import { loadConfig } from '../generated/graphql-server/src/config'
-loadConfig()
-
-import BN from 'bn.js'
-import { nanoid } from 'nanoid'
-import { SnakeNamingStrategy } from '@dzlzv/hydra-db-utils'
-import { createConnection, ConnectionOptions, getConnection, EntityManager } from 'typeorm'
-
-import { Video } from '../generated/graphql-server/src/modules/video/video.model'
-import { Channel } from '../generated/graphql-server/src/modules/channel/channel.model'
-import { Block, Network } from '../generated/graphql-server/src/modules/block/block.model'
-import { Category } from '../generated/graphql-server/src/modules/category/category.model'
-import { VideoMedia } from '../generated/graphql-server/src/modules/video-media/video-media.model'
-import { LicenseEntity } from '../generated/graphql-server/src/modules/license-entity/license-entity.model'
-import { JoystreamMediaLocation, KnownLicense } from '../generated/graphql-server/src/modules/variants/variants.model'
-import { KnownLicenseEntity } from '../generated/graphql-server/src/modules/known-license-entity/known-license-entity.model'
-import { VideoMediaEncoding } from '../generated/graphql-server/src/modules/video-media-encoding/video-media-encoding.model'
-import { MediaLocationEntity } from '../generated/graphql-server/src/modules/media-location-entity/media-location-entity.model'
-import { HttpMediaLocationEntity } from '../generated/graphql-server/src/modules/http-media-location-entity/http-media-location-entity.model'
-import { JoystreamMediaLocationEntity } from '../generated/graphql-server/src/modules/joystream-media-location-entity/joystream-media-location-entity.model'
-
-function getConnectionOptions() {
-  const connectionConfig: ConnectionOptions = {
-    type: 'postgres',
-    host: process.env.WARTHOG_DB_HOST,
-    port: parseInt(process.env.WARTHOG_DB_PORT!),
-    username: process.env.WARTHOG_DB_USERNAME,
-    password: process.env.WARTHOG_DB_PASSWORD,
-    database: process.env.WARTHOG_DB_DATABASE,
-    entities: [process.env.WARTHOG_DB_ENTITIES!],
-    namingStrategy: new SnakeNamingStrategy(),
-    logging: true,
-  }
-  return connectionConfig
-}
-
-export async function main(): Promise<void> {
-  console.log(`Initializing...`)
-  await createConnection(getConnectionOptions())
-  await getConnection().transaction(async (db: EntityManager) => {
-    const id = '0'
-    const createdAt = new Date()
-    const createdById = '0'
-    const version = 0
-
-    // ///////// Block /////////////////
-    const happenedIn = new Block({
-      createdAt,
-      createdById,
-      version,
-      block: 0,
-      timestamp: new BN(Date.now()),
-      network: Network.BABYLON,
-    })
-    await db.save<Block>(happenedIn)
-    // ///////// Block /////////////////
-
-    const commonProperties = { id, happenedIn, createdAt, createdById, version }
-
-    // ///////// HttpMediaLocationEntity /////////////////
-    const httpMediaLocation = new HttpMediaLocationEntity({
-      ...commonProperties,
-      url: '5FyzfM2YtZa75hHYCAo5evNS8bH8P4Kw8EyXqKkC5upVSDBQ',
-    })
-    await db.save<HttpMediaLocationEntity>(httpMediaLocation)
-    // ///////// HttpMediaLocationEntity /////////////////
-
-    // ///////// JoystreamMediaLocationEntity /////////////////
-    const joyMediaLocation = new JoystreamMediaLocationEntity({
-      ...commonProperties,
-      dataObjectId: '5FyzfM2YtZa75hHYCAo5evNS8bH8P4Kw8EyXqKkC5upVSDBQ',
-    })
-    await db.save<JoystreamMediaLocationEntity>(joyMediaLocation)
-    // ///////// JoystreamMediaLocationEntity /////////////////
-
-    // ///////// KnownLicenseEntity /////////////////
-    const knownLicense = new KnownLicenseEntity({ ...commonProperties, code: 'NA' })
-    await db.save<KnownLicenseEntity>(knownLicense)
-    // ///////// KnownLicenseEntity /////////////////
-
-    // ///////// License /////////////////
-    const k = new KnownLicense()
-    k.code = knownLicense.code
-    const license = new LicenseEntity({ ...commonProperties, type: k })
-    await db.save<LicenseEntity>(license)
-    // ///////// License /////////////////
-
-    // ///////// MediaLocationEntity /////////////////
-    const mediaLocEntity = new MediaLocationEntity({ ...commonProperties, joystreamMediaLocation: joyMediaLocation })
-    await db.save<MediaLocationEntity>(mediaLocEntity)
-    // ///////// MediaLocationEntity /////////////////
-
-    // ///////// Channel /////////////////
-    const channel = new Channel({
-      ...commonProperties,
-      handle: `Channel(0) - ${nanoid()}`,
-      description: `Channel 0`,
-      isPublic: false,
-      isCurated: false,
-    })
-    await db.save<Channel>(channel)
-    // ///////// Channel /////////////////
-
-    // ///////// Category /////////////////
-    const category = new Category({ ...commonProperties, name: `Other` })
-    await db.save<Category>(category)
-    // ///////// Category /////////////////
-
-    // ///////// VideoMediaEncoding /////////////////
-    const videoMediaEncod = new VideoMediaEncoding({ ...commonProperties, name: 'NA' })
-    await db.save<VideoMediaEncoding>(videoMediaEncod)
-    // ///////// VideoMediaEncoding /////////////////
-
-    // ///////// VideoMedia /////////////////
-    const location = new JoystreamMediaLocation()
-    location.dataObjectId = joyMediaLocation.dataObjectId
-    const videoMedia = new VideoMedia({
-      ...commonProperties,
-      location,
-      locationEntity: mediaLocEntity,
-      encoding: videoMediaEncod,
-      pixelHeight: 0,
-      pixelWidth: 0,
-    })
-    await db.save<VideoMedia>(videoMedia)
-    // ///////// VideoMedia /////////////////
-
-    // ///////// Video /////////////////
-    const v = new Video({ ...commonProperties })
-    v.category = category
-    v.channel = channel
-    v.media = videoMedia
-    v.license = license
-    v.title = `Video(0)`
-    v.description = `Video(0)`
-    v.duration = 0
-    v.thumbnailUrl = 'https://eu-central-1.linodeobjects.com/joystream/1.png'
-    v.isPublic = false
-    v.isCurated = false
-    v.isExplicit = false
-    v.isFeatured = false
-    await db.save<Video>(v)
-    // ///////// Video /////////////////
-  })
-}
-
-main()
-  .then(() => {
-    console.log(`Done.`)
-    process.exit()
-  })
-  .catch(console.log)

+ 38 - 12
runtime-modules/council/src/lib.rs

@@ -631,7 +631,7 @@ decl_module! {
         /// # </weight>
         #[weight = CouncilWeightInfo::<T>::withdraw_candidacy()]
         pub fn withdraw_candidacy(origin, membership_id: T::MemberId) -> Result<(), Error<T>> {
-            let staking_account_id =
+            let (stage_data, candidate) =
                 EnsureChecks::<T>::can_withdraw_candidacy(origin, &membership_id)?;
 
             //
@@ -639,7 +639,7 @@ decl_module! {
             //
 
             // update state
-            Mutations::<T>::release_candidacy_stake(&membership_id, &staking_account_id);
+            Mutations::<T>::withdraw_candidacy(&stage_data, &membership_id, &candidate);
 
             // emit event
             Self::deposit_event(RawEvent::CandidacyWithdraw(membership_id));
@@ -814,7 +814,7 @@ decl_module! {
             let funding_total: Balance<T> =
                 funding_requests.iter().fold(
                     Zero::zero(),
-                    |accumulated, funding_request| accumulated + funding_request.amount,
+                    |accumulated, funding_request| accumulated.saturating_add(funding_request.amount),
                 );
 
             let current_budget = Self::budget();
@@ -846,7 +846,7 @@ decl_module! {
             // == MUTATION SAFE ==
             //
 
-            Mutations::<T>::set_budget(current_budget - funding_total);
+            Mutations::<T>::set_budget(current_budget.saturating_sub(funding_total));
 
             for funding_request in funding_requests {
                 let amount = funding_request.amount;
@@ -902,10 +902,10 @@ impl<T: Trait> Module<T> {
 
     // Finish voting and start ravealing.
     fn end_announcement_period(stage_data: CouncilStageAnnouncing) {
-        let candidate_count = T::CouncilSize::get() + T::MinNumberOfExtraCandidates::get();
+        let min_candidate_count = T::CouncilSize::get() + T::MinNumberOfExtraCandidates::get();
 
         // reset announcing period when not enough candidates registered
-        if stage_data.candidates_count < candidate_count {
+        if stage_data.candidates_count < min_candidate_count {
             Mutations::<T>::start_announcing_period();
 
             // emit event
@@ -1051,7 +1051,7 @@ impl<T: Trait> Module<T> {
                 ));
 
                 // return new balance
-                balance - available_balance
+                balance.saturating_sub(available_balance)
             },
         );
 
@@ -1322,7 +1322,7 @@ impl<T: Trait> Mutations<T> {
             candidates_count: stage_data.candidates_count + 1,
         };
 
-        // store new candidacy list
+        // store new stage
         Stage::<T>::mutate(|value| {
             *value = CouncilStageUpdate {
                 stage: CouncilStage::Announcing(new_stage_data),
@@ -1336,6 +1336,30 @@ impl<T: Trait> Mutations<T> {
         T::CandidacyLock::lock(&candidate.staking_account_id, *stake);
     }
 
+    fn withdraw_candidacy(
+        stage_data: &CouncilStageAnnouncing,
+        membership_id: &T::MemberId,
+        candidate: &CandidateOf<T>,
+    ) {
+        // release candidacy stake
+        Self::release_candidacy_stake(&membership_id, &candidate.staking_account_id);
+
+        // prepare new stage
+        let new_stage_data = CouncilStageAnnouncing {
+            candidates_count: stage_data.candidates_count.saturating_sub(1),
+        };
+
+        // store new stage
+        Stage::<T>::mutate(|value| {
+            *value = CouncilStageUpdate {
+                stage: CouncilStage::Announcing(new_stage_data),
+
+                // keep changed_at (and other values) - stage phase haven't changed
+                ..*value
+            }
+        });
+    }
+
     // Release user's stake that was used for candidacy.
     fn release_candidacy_stake(membership_id: &T::MemberId, account_id: &T::AccountId) {
         // release stake amount
@@ -1525,7 +1549,7 @@ impl<T: Trait> EnsureChecks<T> {
     fn can_withdraw_candidacy(
         origin: T::Origin,
         membership_id: &T::MemberId,
-    ) -> Result<T::AccountId, Error<T>> {
+    ) -> Result<(CouncilStageAnnouncing, CandidateOf<T>), Error<T>> {
         // ensure user's membership
         Self::ensure_user_membership(origin, membership_id)?;
 
@@ -1537,17 +1561,19 @@ impl<T: Trait> EnsureChecks<T> {
         let candidate = Candidates::<T>::get(membership_id);
 
         // ensure candidacy announcing period is running now
-        match Stage::<T>::get().stage {
-            CouncilStage::Announcing(_) => {
+        let stage_data = match Stage::<T>::get().stage {
+            CouncilStage::Announcing(stage_data) => {
                 // ensure candidacy was announced in current election cycle
                 if candidate.cycle_id != AnnouncementPeriodNr::get() {
                     return Err(Error::NotCandidatingNow);
                 }
+
+                stage_data
             }
             _ => return Err(Error::CantWithdrawCandidacyNow),
         };
 
-        Ok(candidate.staking_account_id)
+        Ok((stage_data, candidate))
     }
 
     // Ensures there is no problem in setting new note for the candidacy.

+ 52 - 1
runtime-modules/council/src/tests.rs

@@ -244,7 +244,8 @@ fn council_candidacy_release_candidate_stake() {
     });
 }
 
-// Test that only valid members can candidate.
+// Test that the announcement period is reset in case that not enough candidates
+// to fill the council has announced their candidacy.
 #[test]
 fn council_announcement_reset_on_insufficient_candidates() {
     let config = default_genesis_config();
@@ -288,6 +289,56 @@ fn council_announcement_reset_on_insufficient_candidates() {
     });
 }
 
+// Test that the announcement period is reset in case that not enough candidates
+// to fill the council has announced and not withdrawn their candidacy.
+#[test]
+fn council_announcement_reset_on_insufficient_candidates_after_candidacy_withdrawal() {
+    let config = default_genesis_config();
+
+    build_test_externalities(config).execute_with(|| {
+        let council_settings = CouncilSettings::<Runtime>::extract_settings();
+
+        // generate candidates
+        let candidates: Vec<CandidateInfo<Runtime>> = (0..council_settings.min_candidate_count)
+            .map(|i| {
+                MockUtils::generate_candidate(u64::from(i), council_settings.min_candidate_stake)
+            })
+            .collect();
+
+        let params = CouncilCycleParams {
+            council_settings: council_settings.clone(),
+            cycle_start_block_number: 0,
+            expected_initial_council_members: vec![],
+            expected_final_council_members: vec![], // not needed in this scenario
+            candidates_announcing: candidates.clone(),
+            expected_candidates: vec![], // not needed in this scenario
+            voters: vec![],              // not needed in this scenario
+
+            // escape before voting
+            interrupt_point: Some(CouncilCycleInterrupt::AfterCandidatesAnnounce),
+        };
+
+        Mocks::simulate_council_cycle(params.clone());
+
+        Mocks::withdraw_candidacy(
+            candidates[0].origin.clone(),
+            candidates[0].account_id.clone(),
+            Ok(()),
+        );
+
+        // forward to election-voting period
+        MockUtils::increase_block_number(council_settings.announcing_stage_duration + 1);
+
+        // check announcements were reset
+        Mocks::check_announcing_period(
+            params.cycle_start_block_number + council_settings.announcing_stage_duration,
+            CouncilStageAnnouncing {
+                candidates_count: 0,
+            },
+        );
+    });
+}
+
 // Test that announcement phase is reset when not enough candidates to fill council recieved votes
 #[test]
 fn council_announcement_reset_on_not_enough_winners() {

+ 2 - 1
runtime-modules/proposals/discussion/src/benchmarking.rs

@@ -267,7 +267,8 @@ benchmarks! {
                 post_id,
                 caller_member_id,
                 thread_id,
-                text
+                text,
+                true,
             ).into()
         );
     }

+ 2 - 2
runtime-modules/proposals/discussion/src/lib.rs

@@ -101,7 +101,7 @@ decl_event!(
         ThreadCreated(ThreadId, MemberId),
 
         /// Emits on post creation.
-        PostCreated(PostId, MemberId, ThreadId, Vec<u8>),
+        PostCreated(PostId, MemberId, ThreadId, Vec<u8>, bool),
 
         /// Emits on post update.
         PostUpdated(PostId, MemberId, ThreadId, Vec<u8>),
@@ -277,7 +277,7 @@ decl_module! {
             }
 
             PostCount::put(next_post_count_value);
-            Self::deposit_event(RawEvent::PostCreated(post_id, post_author_id, thread_id, text));
+            Self::deposit_event(RawEvent::PostCreated(post_id, post_author_id, thread_id, text, editable));
        }
 
         /// Remove post from storage, with the last parameter indicating whether to also hide it

+ 7 - 1
runtime-modules/proposals/discussion/src/tests/mod.rs

@@ -467,7 +467,13 @@ fn update_post_call_succeeds() {
 
         EventFixture::assert_events(vec![
             RawEvent::ThreadCreated(1, 1),
-            RawEvent::PostCreated(1, 1, post_fixture.thread_id, post_fixture.text.clone()),
+            RawEvent::PostCreated(
+                1,
+                1,
+                post_fixture.thread_id,
+                post_fixture.text.clone(),
+                post_fixture.editable,
+            ),
             RawEvent::PostUpdated(1, 1, post_fixture.thread_id, post_fixture.text.clone()),
         ]);
     });

+ 9 - 9
runtime-modules/proposals/engine/src/tests/mod.rs

@@ -890,9 +890,10 @@ fn veto_proposal_event_emitted() {
         let veto_proposal = VetoProposalFixture::new(proposal_id);
         veto_proposal.veto_and_assert(Ok(()));
 
-        EventFixture::assert_events(vec![
-            RawEvent::ProposalDecisionMade(proposal_id, ProposalDecision::Vetoed),
-        ]);
+        EventFixture::assert_events(vec![RawEvent::ProposalDecisionMade(
+            proposal_id,
+            ProposalDecision::Vetoed,
+        )]);
     });
 }
 
@@ -930,9 +931,7 @@ fn vote_proposal_event_emitted() {
         let mut vote_generator = VoteGenerator::new(proposal_id);
         vote_generator.vote_and_assert_ok(VoteKind::Approve);
 
-        EventFixture::assert_events(vec![
-            RawEvent::Voted(1, 1, VoteKind::Approve, Vec::new()),
-        ]);
+        EventFixture::assert_events(vec![RawEvent::Voted(1, 1, VoteKind::Approve, Vec::new())]);
     });
 }
 
@@ -957,9 +956,10 @@ fn create_proposal_and_expire_it() {
         run_to_block_and_finalize(expected_expriration_block);
 
         assert!(!<crate::Proposals<Test>>::contains_key(proposal_id));
-        EventFixture::assert_events(vec![
-            RawEvent::ProposalDecisionMade(proposal_id, ProposalDecision::Expired),
-        ]);
+        EventFixture::assert_events(vec![RawEvent::ProposalDecisionMade(
+            proposal_id,
+            ProposalDecision::Expired,
+        )]);
     });
 }
 

+ 2 - 2
scripts/cargo-build.sh

@@ -1,5 +1,5 @@
 #!/usr/bin/env bash
 
-export WASM_BUILD_TOOLCHAIN=nightly-2021-03-24
+export WASM_BUILD_TOOLCHAIN=nightly-2021-02-20
 
-cargo build --release
+cargo +nightly-2021-02-20 build --release

+ 2 - 2
scripts/cargo-tests-with-networking.sh

@@ -1,7 +1,7 @@
 #!/bin/sh
 set -e
 
-export WASM_BUILD_TOOLCHAIN=nightly-2021-03-24
+export WASM_BUILD_TOOLCHAIN=nightly-2021-02-20
 
 echo 'running all cargo tests'
-cargo test --release --all -- --ignored
+cargo +nightly-2021-02-20 test --release --all -- --ignored

+ 1 - 1
scripts/raspberry-cross-build.sh

@@ -9,7 +9,7 @@
 export WORKSPACE_ROOT=`cargo metadata --offline --no-deps --format-version 1 | jq .workspace_root -r`
 
 docker run \
-    -e WASM_BUILD_TOOLCHAIN=nightly-2021-03-24 \
+    -e WASM_BUILD_TOOLCHAIN=nightly-2021-02-20 \
     --volume ${WORKSPACE_ROOT}/:/home/cross/project \
     --volume ${HOME}/.cargo/registry:/home/cross/.cargo/registry \
     joystream/rust-raspberry \

+ 4 - 4
scripts/run-dev-chain.sh

@@ -1,13 +1,13 @@
 #!/usr/bin/env bash
 
-export WASM_BUILD_TOOLCHAIN=nightly-2021-03-24
+export WASM_BUILD_TOOLCHAIN=nightly-2021-02-20
 
 # Build release binary
-cargo build --release
+cargo +nightly-2021-02-20 build --release
 
 # Purge existing local chain
-yes | cargo run --release -- purge-chain --dev
+yes | cargo +nightly-2021-02-20 run --release -- purge-chain --dev
 
 # Run local development chain -
 # No need to specify `-p joystream-node` it is the default bin crate in the cargo workspace
-cargo run --release -- --dev --log runtime
+cargo +nightly-2021-02-20 run --release -- --dev --log runtime

+ 1 - 0
scripts/runtime-code-shasum.sh

@@ -23,6 +23,7 @@ ${TAR} -c --sort=name --owner=root:0 --group=root:0 --mode 644 --mtime='UTC 2020
     runtime-modules \
     utils/chain-spec-builder \
     joystream-node.Dockerfile \
+    node \
     $(test -n "$TEST_NODE" && echo "$TEST_PROPOSALS_PARAMETERS_PATH") \
     | if [[ -n "$TEST_NODE" ]]; then sed '$a'"$TEST_NODE_BLOCKTIME"; else tee; fi \
     | shasum \

+ 2 - 4
setup.sh

@@ -25,10 +25,8 @@ curl https://getsubstrate.io -sSf | bash -s -- --fast
 
 source ~/.cargo/env
 
-rustup install nightly-2021-03-24
-rustup target add wasm32-unknown-unknown --toolchain nightly-2021-03-24
-
-rustup default nightly-2021-03-24
+rustup install nightly-2021-02-20
+rustup target add wasm32-unknown-unknown --toolchain nightly-2021-02-20
 
 rustup component add rustfmt clippy
 

+ 1 - 1
storage-node/package.json

@@ -46,7 +46,7 @@
     "eslint-plugin-babel": "^5.3.1",
     "eslint-plugin-prettier": "^3.1.4",
     "prettier": "^2.0.5",
-    "typescript": "^3.9.6",
+    "typescript": "^4.3.5",
     "wsrun": "^3.6.5"
   },
   "volta": {

+ 1 - 1
storage-node/packages/runtime-api/package.json

@@ -50,7 +50,7 @@
   "dependencies": {
     "@joystream/storage-utils": "^0.1.0",
     "@joystream/types": "^0.17.0",
-    "@polkadot/api": "4.2.1",
+    "@polkadot/api": "5.3.2",
     "async-lock": "^1.2.0",
     "lodash": "^4.17.11",
     "password-prompt": "^1.1.2"

+ 6 - 5
tests/integration-tests/package.json

@@ -10,13 +10,14 @@
     "lint": "eslint . --quiet --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "format": "prettier ./ --write",
-    "generate:graphql-types": "graphql-codegen"
+    "generate:graphql-types": "graphql-codegen",
+    "generate:all": "yarn generate:graphql-types"
   },
   "dependencies": {
     "@apollo/client": "^3.2.5",
     "@joystream/types": "^0.17.0",
-    "@polkadot/api": "4.2.1",
-    "@polkadot/keyring": "^6.0.5",
+    "@polkadot/api": "5.3.2",
+    "@polkadot/keyring": "^7.1.1",
     "@types/async-lock": "^1.1.2",
     "@types/bn.js": "^4.11.5",
     "@types/lowdb": "^1.0.9",
@@ -28,13 +29,13 @@
     "uuid": "^7.0.3"
   },
   "devDependencies": {
-    "@polkadot/ts": "^0.3.14",
+    "@polkadot/ts": "^0.4.4",
     "@types/chai": "^4.2.11",
     "@types/uuid": "^7.0.2",
     "chai": "^4.2.0",
     "prettier": "2.0.2",
     "ts-node": "^8.8.1",
-    "typescript": "^3.8.3",
+    "typescript": "^4.3.5",
     "@graphql-codegen/cli": "^1.21.4",
     "@graphql-codegen/typescript": "^1.22.0",
     "@graphql-codegen/import-types-preset": "^1.18.1",

+ 48 - 4
tests/integration-tests/src/Api.ts

@@ -31,6 +31,9 @@ import {
   CategoryCreatedEventDetails,
   PostAddedEventDetails,
   ThreadCreatedEventDetails,
+  ProposalsCodexEventName,
+  ProposalDiscussionPostCreatedEventDetails,
+  ProposalsDiscussionEventName,
 } from './types'
 import {
   ApplicationId,
@@ -442,6 +445,17 @@ export class Api {
     return details
   }
 
+  public async retrieveProposalsCodexEventDetails(
+    result: ISubmittableResult,
+    eventName: ProposalsCodexEventName
+  ): Promise<EventDetails> {
+    const details = await this.retrieveEventDetails(result, 'proposalsCodex', eventName)
+    if (!details) {
+      throw new Error(`${eventName} event details not found in result: ${JSON.stringify(result.toHuman())}`)
+    }
+    return details
+  }
+
   public async retrieveForumEventDetails(result: ISubmittableResult, eventName: ForumEventName): Promise<EventDetails> {
     const details = await this.retrieveEventDetails(result, 'forum', eventName)
     if (!details) {
@@ -451,10 +465,31 @@ export class Api {
   }
 
   public async retrieveProposalCreatedEventDetails(result: ISubmittableResult): Promise<ProposalCreatedEventDetails> {
-    const details = await this.retrieveProposalsEngineEventDetails(result, 'ProposalCreated')
+    const details = await this.retrieveProposalsCodexEventDetails(result, 'ProposalCreated')
+    return {
+      ...details,
+      proposalId: details.event.data[0] as ProposalId,
+    }
+  }
+
+  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,
-      proposalId: details.event.data[1] as ProposalId,
+      postId: details.event.data[0] as PostId,
     }
   }
 
@@ -500,6 +535,7 @@ export class Api {
 
   public async untilCouncilStage(
     targetStage: 'Announcing' | 'Voting' | 'Revealing' | 'Idle',
+    announcementPeriodNr: number | null = null,
     blocksReserve = 3,
     intervalMs = BLOCKTIME
   ): Promise<void> {
@@ -527,9 +563,16 @@ export class Api {
 
         const currentStageEndsIn = currentStageStartedAt.add(durationByStage[currentStage]).sub(currentBlock)
 
+        const currentAnnouncementPeriodNr =
+          announcementPeriodNr === null ? null : (await this.api.query.council.announcementPeriodNr()).toNumber()
+
         debug(`Current stage: ${currentStage}, blocks left: ${currentStageEndsIn.toNumber()}`)
 
-        return currentStage === targetStage && currentStageEndsIn.gten(blocksReserve)
+        return (
+          currentStage === targetStage &&
+          currentStageEndsIn.gten(blocksReserve) &&
+          announcementPeriodNr === currentAnnouncementPeriodNr
+        )
       },
       intervalMs
     )
@@ -551,7 +594,8 @@ export class Api {
     const details = await this.retrieveForumEventDetails(result, 'ThreadCreated')
     return {
       ...details,
-      threadId: details.event.data[0] as ThreadId,
+      threadId: details.event.data[1] as ThreadId,
+      postId: details.event.data[2] as PostId,
     }
   }
 

+ 135 - 19
tests/integration-tests/src/QueryNodeApi.ts

@@ -1,9 +1,17 @@
-import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client'
+import { ApolloClient, DocumentNode, NormalizedCacheObject } from '@apollo/client/core'
 import { MemberId, PostId, ThreadId } from '@joystream/types/common'
 import { extendDebug, Debugger } from './Debugger'
 import { ApplicationId, OpeningId, WorkerId } from '@joystream/types/working-group'
 import { EventDetails, WorkingGroupModuleName } from './types'
 import {
+  ElectedCouncilFieldsFragment,
+  GetCurrentCouncilMembers,
+  GetCurrentCouncilMembersQuery,
+  GetCurrentCouncilMembersQueryVariables,
+  CandidateFieldsFragment,
+  GetReferendumIntermediateWinners,
+  GetReferendumIntermediateWinnersQuery,
+  GetReferendumIntermediateWinnersQueryVariables,
   GetMemberByIdQuery,
   GetMemberByIdQueryVariables,
   GetMemberById,
@@ -187,10 +195,10 @@ import {
   GetThreadDeletedEventsByEventIdsQuery,
   GetThreadDeletedEventsByEventIdsQueryVariables,
   GetThreadDeletedEventsByEventIds,
-  ForumThreadWithPostsFieldsFragment,
-  GetThreadsWithPostsByIdsQuery,
-  GetThreadsWithPostsByIdsQueryVariables,
-  GetThreadsWithPostsByIds,
+  ForumThreadWithInitialPostFragment,
+  GetThreadsWithInitialPostsByIds,
+  GetThreadsWithInitialPostsByIdsQuery,
+  GetThreadsWithInitialPostsByIdsQueryVariables,
   GetMembershipBoughtEventsByEventIdsQuery,
   GetMembershipBoughtEventsByEventIdsQueryVariables,
   GetMembershipBoughtEventsByEventIds,
@@ -226,10 +234,10 @@ import {
   GetPostAddedEventsByEventIdsQuery,
   GetPostAddedEventsByEventIdsQueryVariables,
   GetPostAddedEventsByEventIds,
-  ThreadTitleUpdatedEventFieldsFragment,
-  GetThreadTitleUpdatedEventsByEventIdsQuery,
-  GetThreadTitleUpdatedEventsByEventIdsQueryVariables,
-  GetThreadTitleUpdatedEventsByEventIds,
+  ThreadMetadataUpdatedEventFieldsFragment,
+  GetThreadMetadataUpdatedEventsByEventIds,
+  GetThreadMetadataUpdatedEventsByEventIdsQuery,
+  GetThreadMetadataUpdatedEventsByEventIdsQueryVariables,
   ThreadMovedEventFieldsFragment,
   GetThreadMovedEventsByEventIdsQuery,
   GetThreadMovedEventsByEventIdsQueryVariables,
@@ -263,6 +271,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'
@@ -410,6 +442,31 @@ export class QueryNodeApi {
     >(GetMemberInvitedEventsByEventIds, { eventIds }, 'memberInvitedEvents')
   }
 
+  public async getCurrentCouncilMembers(): Promise<ElectedCouncilFieldsFragment | null> {
+    return this.firstEntityQuery<GetCurrentCouncilMembersQuery, GetCurrentCouncilMembersQueryVariables>(
+      GetCurrentCouncilMembers,
+      {},
+      'electedCouncils'
+    )
+  }
+
+  public async getReferendumIntermediateWinners(
+    electionRoundCycleId: number,
+    councilSize: number
+  ): Promise<CandidateFieldsFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetReferendumIntermediateWinnersQuery,
+      GetReferendumIntermediateWinnersQueryVariables
+    >(
+      GetReferendumIntermediateWinners,
+      {
+        electionRoundCycleId,
+        councilSize,
+      },
+      'candidates'
+    )
+  }
+
   // TODO: Use event id
   public async getInvitesTransferredEvent(
     sourceMemberId: MemberId
@@ -834,20 +891,21 @@ export class QueryNodeApi {
     >(GetThreadCreatedEventsByEventIds, { eventIds }, 'threadCreatedEvents')
   }
 
-  public async getThreadTitleUpdatedEvents(events: EventDetails[]): Promise<ThreadTitleUpdatedEventFieldsFragment[]> {
+  public async getThreadMetadataUpdatedEvents(
+    events: EventDetails[]
+  ): Promise<ThreadMetadataUpdatedEventFieldsFragment[]> {
     const eventIds = events.map((e) => this.getQueryNodeEventId(e.blockNumber, e.indexInBlock))
     return this.multipleEntitiesQuery<
-      GetThreadTitleUpdatedEventsByEventIdsQuery,
-      GetThreadTitleUpdatedEventsByEventIdsQueryVariables
-    >(GetThreadTitleUpdatedEventsByEventIds, { eventIds }, 'threadTitleUpdatedEvents')
+      GetThreadMetadataUpdatedEventsByEventIdsQuery,
+      GetThreadMetadataUpdatedEventsByEventIdsQueryVariables
+    >(GetThreadMetadataUpdatedEventsByEventIds, { eventIds }, 'threadMetadataUpdatedEvents')
   }
 
-  public async getThreadsWithPostsByIds(ids: ThreadId[]): Promise<ForumThreadWithPostsFieldsFragment[]> {
-    return this.multipleEntitiesQuery<GetThreadsWithPostsByIdsQuery, GetThreadsWithPostsByIdsQueryVariables>(
-      GetThreadsWithPostsByIds,
-      { ids: ids.map((id) => id.toString()) },
-      'forumThreads'
-    )
+  public async getThreadsWithInitialPostsByIds(ids: ThreadId[]): Promise<ForumThreadWithInitialPostFragment[]> {
+    return this.multipleEntitiesQuery<
+      GetThreadsWithInitialPostsByIdsQuery,
+      GetThreadsWithInitialPostsByIdsQueryVariables
+    >(GetThreadsWithInitialPostsByIds, { ids: ids.map((id) => id.toString()) }, 'forumThreads')
   }
 
   public async getVoteOnPollEvents(events: EventDetails[]): Promise<VoteOnPollEventFieldsFragment[]> {
@@ -955,4 +1013,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')
+  }
 }

+ 1 - 1
tests/integration-tests/src/Scenario.ts

@@ -2,7 +2,7 @@ import { WsProvider } from '@polkadot/api'
 import { ApiFactory } from './Api'
 import { QueryNodeApi } from './QueryNodeApi'
 import { config } from 'dotenv'
-import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
+import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core'
 import { extendDebug, Debugger } from './Debugger'
 import { Flow } from './Flow'
 import { Job } from './Job'

+ 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',

+ 17 - 1
tests/integration-tests/src/fixtures/council/ElectCouncilFixture.ts

@@ -1,5 +1,6 @@
 import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
 import { AddStakingAccountsHappyCaseFixture, BuyMembershipHappyCaseFixture } from '../membership'
+import { assertCouncilMembersRuntimeQnMatch } from './common'
 import { blake2AsHex } from '@polkadot/util-crypto'
 import { MINIMUM_STAKING_ACCOUNT_BALANCE } from '../../consts'
 import { assert } from 'chai'
@@ -76,12 +77,27 @@ export class ElectCouncilFixture extends BaseQueryNodeFixture {
     await api.prepareAccountsForFeeExpenses(votersStakingAccounts, votingTxs)
     await api.sendExtrinsicsAndGetResults(revealingTxs, votersStakingAccounts)
 
+    const candidatesToWinIds = candidatesMemberIds.slice(0, councilSize.toNumber()).map((id) => id.toString())
+
+    // check intermediate election winners are properly set
+    await query.tryQueryWithTimeout(
+      () => query.getReferendumIntermediateWinners(cycleId.toNumber(), councilSize.toNumber()),
+      (qnReferendumIntermediateWinners) => {
+        assert.sameMembers(
+          qnReferendumIntermediateWinners.map((item) => item.member.id.toString()),
+          candidatesToWinIds
+        )
+      }
+    )
+
     await this.api.untilCouncilStage('Idle')
 
     const councilMembers = await api.query.council.councilMembers()
     assert.sameMembers(
       councilMembers.map((m) => m.membership_id.toString()),
-      candidatesMemberIds.slice(0, councilSize.toNumber()).map((id) => id.toString())
+      candidatesToWinIds
     )
+
+    await assertCouncilMembersRuntimeQnMatch(this.api, this.query)
   }
 }

+ 57 - 0
tests/integration-tests/src/fixtures/council/NotEnoughCandidatesFixture.ts

@@ -0,0 +1,57 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { assertCouncilMembersRuntimeQnMatch, prepareFailToElectResources } from './common'
+import { assert } from 'chai'
+
+export class NotEnoughCandidatesFixture extends BaseQueryNodeFixture {
+  /*
+      Execute scenario when not enough candidates announce their candidacy and candidacy announcement stage
+      has to be repeated.
+  */
+  public async execute(): Promise<void> {
+    const {
+      candidatesMemberIds,
+      candidatesStakingAccounts,
+      candidatesMemberAccounts,
+      councilCandidateStake,
+      councilMemberIds,
+    } = await prepareFailToElectResources(this.api, this.query)
+
+    const lessCandidatesNumber = 1
+    const candidatingMemberIds = candidatesMemberIds.slice(0, candidatesMemberIds.length - lessCandidatesNumber)
+
+    // announcing stage
+    await this.api.untilCouncilStage('Announcing')
+
+    // ensure no voting is in progress
+    assert((await this.api.query.referendum.stage()).isOfType('Inactive'))
+    const announcementPeriodNrInit = await this.api.query.council.announcementPeriodNr()
+
+    // announce candidacies
+    const applyForCouncilTxs = candidatingMemberIds.map((memberId, i) =>
+      this.api.tx.council.announceCandidacy(
+        memberId,
+        candidatesStakingAccounts[i],
+        candidatesMemberAccounts[i],
+        councilCandidateStake
+      )
+    )
+    await this.api.prepareAccountsForFeeExpenses(candidatesMemberAccounts, applyForCouncilTxs)
+    await this.api.sendExtrinsicsAndGetResults(applyForCouncilTxs, candidatesMemberAccounts)
+
+    // wait for next announcement stage that should be right after the previous one
+    await this.api.untilCouncilStage('Announcing', announcementPeriodNrInit.toNumber() + 1)
+    const announcementPeriodNrEnding = await this.api.query.council.announcementPeriodNr()
+
+    assert((await this.api.query.referendum.stage()).isOfType('Inactive'))
+    assert.equal(announcementPeriodNrEnding.toNumber(), announcementPeriodNrInit.toNumber() + 1)
+
+    // ensure council members haven't changed
+    const councilMembersEnding = await this.api.query.council.councilMembers()
+    assert.sameMembers(
+      councilMemberIds.map((item) => item.toString()),
+      councilMembersEnding.map((item) => item.membership_id.toString())
+    )
+
+    await assertCouncilMembersRuntimeQnMatch(this.api, this.query)
+  }
+}

+ 82 - 0
tests/integration-tests/src/fixtures/council/NotEnoughCandidatesWithVotesFixture.ts

@@ -0,0 +1,82 @@
+import { BaseQueryNodeFixture, FixtureRunner } from '../../Fixture'
+import { assertCouncilMembersRuntimeQnMatch, prepareFailToElectResources } from './common'
+import { blake2AsHex } from '@polkadot/util-crypto'
+import { assert } from 'chai'
+import { MINIMUM_STAKING_ACCOUNT_BALANCE } from '../../consts'
+
+export class NotEnoughCandidatesWithVotesFixture extends BaseQueryNodeFixture {
+  public async execute(): Promise<void> {
+    const {
+      candidatesMemberIds,
+      candidatesStakingAccounts,
+      candidatesMemberAccounts,
+      councilCandidateStake,
+      councilMemberIds,
+    } = await prepareFailToElectResources(this.api, this.query)
+
+    const lessVotersNumber = 1
+    const numberOfCandidates = candidatesMemberIds.length
+    const numberOfVoters = numberOfCandidates - 1
+
+    // create voters
+    const voteStake = this.api.consts.referendum.minimumStake
+    const votersStakingAccounts = (await this.api.createKeyPairs(numberOfVoters)).map((kp) => kp.address)
+    await this.api.treasuryTransferBalanceToAccounts(
+      votersStakingAccounts,
+      voteStake.addn(MINIMUM_STAKING_ACCOUNT_BALANCE)
+    )
+
+    // announcing stage
+    await this.api.untilCouncilStage('Announcing')
+
+    // ensure no voting is in progress
+    assert((await this.api.query.referendum.stage()).isOfType('Inactive'))
+    const announcementPeriodNrInit = await this.api.query.council.announcementPeriodNr()
+
+    // announce candidacies
+    const applyForCouncilTxs = candidatesMemberIds.map((memberId, i) =>
+      this.api.tx.council.announceCandidacy(
+        memberId,
+        candidatesStakingAccounts[i],
+        candidatesMemberAccounts[i],
+        councilCandidateStake
+      )
+    )
+    await this.api.prepareAccountsForFeeExpenses(candidatesMemberAccounts, applyForCouncilTxs)
+    await this.api.sendExtrinsicsAndGetResults(applyForCouncilTxs, candidatesMemberAccounts)
+
+    // voting stage
+    await this.api.untilCouncilStage('Voting')
+
+    // vote
+    const cycleId = (await this.api.query.referendum.stage()).asType('Voting').current_cycle_id
+    const votingTxs = votersStakingAccounts.map((account, i) => {
+      const accountId = this.api.createType('AccountId', account)
+      const optionId = candidatesMemberIds[i % numberOfCandidates]
+      const salt = this.api.createType('Bytes', `salt${i}`)
+
+      const payload = Buffer.concat([accountId.toU8a(), optionId.toU8a(), salt.toU8a(), cycleId.toU8a()])
+      const commitment = blake2AsHex(payload)
+      return this.api.tx.referendum.vote(commitment, voteStake)
+    })
+    await this.api.prepareAccountsForFeeExpenses(votersStakingAccounts, votingTxs)
+    await this.api.sendExtrinsicsAndGetResults(votingTxs, votersStakingAccounts)
+
+    // Announcing stage
+    await this.api.untilCouncilStage('Announcing')
+    const announcementPeriodNrEnding = await this.api.query.council.announcementPeriodNr()
+
+    // ensure new announcement stage started
+    assert((await this.api.query.referendum.stage()).isOfType('Inactive'))
+    assert.equal(announcementPeriodNrEnding.toNumber(), announcementPeriodNrInit.toNumber() + 1)
+
+    // ensure council members haven't changed
+    const councilMembersEnding = await this.api.query.council.councilMembers()
+    assert.sameMembers(
+      councilMemberIds.map((item) => item.toString()),
+      councilMembersEnding.map((item) => item.membership_id.toString())
+    )
+
+    await assertCouncilMembersRuntimeQnMatch(this.api, this.query)
+  }
+}

+ 67 - 0
tests/integration-tests/src/fixtures/council/common.ts

@@ -0,0 +1,67 @@
+import { assert } from 'chai'
+import { Api } from '../../Api'
+import { QueryNodeApi } from '../../QueryNodeApi'
+import { AddStakingAccountsHappyCaseFixture, BuyMembershipHappyCaseFixture } from '../membership'
+import { FixtureRunner } from '../../Fixture'
+import { MemberId } from '@joystream/types/common'
+import { Balance } from '@polkadot/types/interfaces'
+
+interface IFailToElectResources {
+  candidatesMemberIds: MemberId[]
+  candidatesStakingAccounts: string[]
+  candidatesMemberAccounts: string[]
+  councilCandidateStake: Balance
+  councilMemberIds: MemberId[]
+}
+
+export async function assertCouncilMembersRuntimeQnMatch(api: Api, query: QueryNodeApi) {
+  const runtimeCouncilMembers = await api.query.council.councilMembers()
+
+  await query.tryQueryWithTimeout(
+    () => query.getCurrentCouncilMembers(),
+    (qnElectedCouncil) => {
+      assert.sameMembers(
+        (qnElectedCouncil?.councilMembers || []).map((item: any) => item.member.id.toString()),
+        runtimeCouncilMembers.map((item: any) => item.membership_id.toString())
+      )
+    }
+  )
+}
+
+export async function prepareFailToElectResources(api: Api, query: QueryNodeApi): Promise<IFailToElectResources> {
+  const { councilSize, minNumberOfExtraCandidates } = api.consts.council
+  const numberOfCandidates = councilSize.add(minNumberOfExtraCandidates).toNumber()
+
+  // prepare memberships
+  const candidatesMemberAccounts = (await api.createKeyPairs(numberOfCandidates)).map((kp) => kp.address)
+  const buyMembershipsFixture = new BuyMembershipHappyCaseFixture(api, query, candidatesMemberAccounts)
+  await new FixtureRunner(buyMembershipsFixture).run()
+  const candidatesMemberIds = buyMembershipsFixture.getCreatedMembers()
+
+  // prepare staking accounts
+  const councilCandidateStake = api.consts.council.minCandidateStake
+
+  const candidatesStakingAccounts = (await api.createKeyPairs(numberOfCandidates)).map((kp) => kp.address)
+  const addStakingAccountsFixture = new AddStakingAccountsHappyCaseFixture(
+    api,
+    query,
+    candidatesStakingAccounts.map((account, i) => ({
+      asMember: candidatesMemberIds[i],
+      account,
+      stakeAmount: councilCandidateStake,
+    }))
+  )
+  await new FixtureRunner(addStakingAccountsFixture).run()
+
+  // retrieve currently elected council's members
+  const councilMembers = await api.query.council.councilMembers()
+  const councilMemberIds = councilMembers.map((item) => item.membership_id)
+
+  return {
+    candidatesMemberIds,
+    candidatesStakingAccounts,
+    candidatesMemberAccounts,
+    councilCandidateStake,
+    councilMemberIds,
+  }
+}

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

@@ -1 +1,3 @@
 export { ElectCouncilFixture } from './ElectCouncilFixture'
+export { NotEnoughCandidatesFixture } from './NotEnoughCandidatesFixture'
+export { NotEnoughCandidatesWithVotesFixture } from './NotEnoughCandidatesWithVotesFixture'

+ 5 - 4
tests/integration-tests/src/fixtures/forum/AddPostsFixture.ts

@@ -68,10 +68,10 @@ export class AddPostsFixture extends StandardizedFixture {
     return this.api.retrievePostAddedEventDetails(result)
   }
 
-  protected getPostExpectedText(postParams: PostParams): string {
-    const expectedMetadata = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, postParams.metadata)
-    const metadataBytes = Utils.getMetadataBytesFromInput(ForumPostMetadata, postParams.metadata)
-    return typeof expectedMetadata?.text === 'string' ? expectedMetadata.text : Utils.bytesToString(metadataBytes)
+  protected getPostExpectedText({ metadata: inputMeta }: PostParams): string {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumPostMetadata, inputMeta)
+    const metaBytes = Utils.getMetadataBytesFromInput(ForumPostMetadata, inputMeta)
+    return meta ? meta.text || '' : Utils.bytesToString(metaBytes)
   }
 
   protected assertQueriedPostsAreValid(
@@ -89,6 +89,7 @@ export class AddPostsFixture extends StandardizedFixture {
       assert.equal(qPost.thread.id, postParams.threadId.toString())
       assert.equal(qPost.author.id, postParams.asMember.toString())
       assert.equal(qPost.status.__typename, expectedStatus)
+      assert.equal(qPost.isVisible, true)
       assert.equal(qPost.text, this.getPostExpectedText(postParams))
       assert.equal(
         qPost.repliesTo?.id,

+ 37 - 16
tests/integration-tests/src/fixtures/forum/CreateThreadsFixture.ts

@@ -1,16 +1,18 @@
 import { Api } from '../../Api'
 import { QueryNodeApi } from '../../QueryNodeApi'
-import { ThreadCreatedEventDetails } from '../../types'
+import { MetadataInput, ThreadCreatedEventDetails } from '../../types'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Utils } from '../../utils'
 import { ISubmittableResult } from '@polkadot/types/types/'
-import { ForumThreadWithPostsFieldsFragment, ThreadCreatedEventFieldsFragment } from '../../graphql/generated/queries'
+import { ForumThreadWithInitialPostFragment, ThreadCreatedEventFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
-import { CategoryId, Poll } from '@joystream/types/forum'
+import { CategoryId, PollInput } from '@joystream/types/forum'
 import { MemberId, ThreadId } from '@joystream/types/common'
 import { CreateInterface } from '@joystream/types'
 import { POST_DEPOSIT, THREAD_DEPOSIT } from '../../consts'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { isSet } from '@joystream/metadata-protobuf/utils'
 
 export type PollParams = {
   description: string
@@ -19,7 +21,7 @@ export type PollParams = {
 }
 
 export type ThreadParams = {
-  title: string
+  metadata: MetadataInput<IForumThreadMetadata>
   text: string
   categoryId: CategoryId
   asMember: MemberId
@@ -58,18 +60,15 @@ export class CreateThreadsFixture extends StandardizedFixture {
     await super.execute()
   }
 
-  protected parsePollParams(pollParams?: PollParams): CreateInterface<Poll> | null {
+  protected parsePollParams(pollParams?: PollParams): CreateInterface<PollInput> | null {
     if (!pollParams) {
       return null
     }
 
     return {
-      description_hash: pollParams.description,
+      description: pollParams.description,
       end_time: pollParams.endTime.getTime(),
-      poll_alternatives: pollParams.alternatives.map((a) => ({
-        alternative_text_hash: a,
-        vote_count: 0,
-      })),
+      poll_alternatives: pollParams.alternatives,
     }
   }
 
@@ -78,7 +77,7 @@ export class CreateThreadsFixture extends StandardizedFixture {
       this.api.tx.forum.createThread(
         params.asMember,
         params.categoryId,
-        params.title,
+        Utils.getMetadataBytesFromInput(ForumThreadMetadata, params.metadata),
         params.text,
         this.parsePollParams(params.poll)
       )
@@ -89,23 +88,33 @@ export class CreateThreadsFixture extends StandardizedFixture {
     return this.api.retrieveThreadCreatedEventDetails(result)
   }
 
+  protected getExpectedThreadTitle({ metadata: inputMeta }: ThreadParams): string {
+    const meta = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, inputMeta)
+    const metaBytes = Utils.getMetadataBytesFromInput(ForumThreadMetadata, inputMeta)
+    return meta ? meta.title || '' : Utils.bytesToString(metaBytes)
+  }
+
   protected assertQueriedThreadsAreValid(
-    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qThreads: ForumThreadWithInitialPostFragment[],
     qEvents: ThreadCreatedEventFieldsFragment[]
   ): void {
     this.events.map((e, i) => {
       const qThread = qThreads.find((t) => t.id === e.threadId.toString())
       const qEvent = this.findMatchingQueryNodeEvent(e, qEvents)
       const threadParams = this.threadsParams[i]
+      const metadata = Utils.getDeserializedMetadataFormInput(ForumThreadMetadata, threadParams.metadata)
+      const expectedTitle = this.getExpectedThreadTitle(threadParams)
       Utils.assert(qThread, 'Query node: Thread not found')
-      assert.equal(qThread.title, threadParams.title)
+      assert.equal(qThread.title, expectedTitle)
       assert.equal(qThread.category.id, threadParams.categoryId.toString())
       assert.equal(qThread.author.id, threadParams.asMember.toString())
       assert.equal(qThread.status.__typename, 'ThreadStatusActive')
+      assert.equal(qThread.isVisible, true)
       assert.equal(qThread.isSticky, false)
       assert.equal(qThread.createdInEvent.id, qEvent.id)
-      const initialPost = qThread.posts.find((p) => p.origin.__typename === 'PostOriginThreadInitial')
-      Utils.assert(initialPost, "Query node: Thread's initial post not found!")
+      const { initialPost } = qThread
+      Utils.assert(initialPost, "Query node: Thread's initial post is empty!")
+      assert.equal(initialPost.id, e.postId.toString())
       assert.equal(initialPost.text, threadParams.text)
       Utils.assert(initialPost.origin.__typename === 'PostOriginThreadInitial')
       // FIXME: Temporarly not working (https://github.com/Joystream/hydra/issues/396)
@@ -116,13 +125,25 @@ export class CreateThreadsFixture extends StandardizedFixture {
       if (threadParams.poll) {
         Utils.assert(qThread.poll, 'Query node: Thread poll is missing')
         assert.equal(qThread.poll.description, threadParams.poll.description)
+        assert.sameDeepMembers(
+          qThread.poll.pollAlternatives.map((a) => [a.text, a.index]),
+          threadParams.poll.alternatives.map((text, index) => [text, index])
+        )
         assert.equal(new Date(qThread.poll.endTime).getTime(), threadParams.poll.endTime.getTime())
       }
+      if (metadata && isSet(metadata?.tags)) {
+        assert.sameDeepMembers(
+          qThread.tags.map((t) => t.id),
+          metadata.tags
+        )
+      }
     })
   }
 
   protected assertQueryNodeEventIsValid(qEvent: ThreadCreatedEventFieldsFragment, i: number): void {
     assert.equal(qEvent.thread.id, this.events[i].threadId.toString())
+    assert.equal(qEvent.title, this.getExpectedThreadTitle(this.threadsParams[i]))
+    assert.equal(qEvent.text, this.threadsParams[i].text)
   }
 
   async runQueryNodeChecks(): Promise<void> {
@@ -134,7 +155,7 @@ export class CreateThreadsFixture extends StandardizedFixture {
     )
 
     // Query the threads
-    const qThreads = await this.query.getThreadsWithPostsByIds(this.events.map((e) => e.threadId))
+    const qThreads = await this.query.getThreadsWithInitialPostsByIds(this.events.map((e) => e.threadId))
     this.assertQueriedThreadsAreValid(qThreads, qEvents)
   }
 }

+ 14 - 11
tests/integration-tests/src/fixtures/forum/DeletePostsFixture.ts

@@ -7,9 +7,10 @@ import { ISubmittableResult } from '@polkadot/types/types/'
 import { ForumPostFieldsFragment, PostDeletedEventFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
-import { MemberId, PostId, ThreadId } from '@joystream/types/common'
-import { CategoryId } from '@joystream/types/forum'
+import { MemberId, PostId } from '@joystream/types/common'
+import { ExtendedPostId, PostsToDeleteMap } from '@joystream/types/forum'
 import _ from 'lodash'
+import { registry } from '@joystream/types'
 
 const DEFAULT_RATIONALE = 'State cleanup'
 
@@ -40,16 +41,17 @@ export class DeletePostsFixture extends StandardizedFixture {
   }
 
   protected async getExtrinsics(): Promise<SubmittableExtrinsic<'promise'>[]> {
-    return this.removals.map((r) =>
-      this.api.tx.forum.deletePosts(
-        r.asMember,
-        r.posts.map(
-          ({ categoryId, threadId, postId, hide }) =>
-            [categoryId, threadId, postId, hide === undefined || hide] as [CategoryId, ThreadId, PostId, boolean]
-        ),
-        r.rationale || DEFAULT_RATIONALE
+    return this.removals.map((r) => {
+      const postsToDeleteEntries = r.posts.map(
+        ({ hide, categoryId, threadId, postId }) =>
+          [
+            this.api.createType('ExtendedPostId', { post_id: postId, thread_id: threadId, category_id: categoryId }),
+            hide === undefined || hide,
+          ] as [ExtendedPostId, boolean]
       )
-    )
+      const postsToDeleteMap = new PostsToDeleteMap(registry, new Map(postsToDeleteEntries))
+      return this.api.tx.forum.deletePosts(r.asMember, postsToDeleteMap, r.rationale || DEFAULT_RATIONALE)
+    })
   }
 
   protected async getEventFromResult(result: ISubmittableResult): Promise<EventDetails> {
@@ -71,6 +73,7 @@ export class DeletePostsFixture extends StandardizedFixture {
         Utils.assert(qPost.status.__typename === expectedStatus, `Invalid post status. Expected: ${expectedStatus}`)
         Utils.assert(qPost.status.postDeletedEvent, 'Query node: Missing PostDeletedEvent ref')
         assert.equal(qPost.status.postDeletedEvent.id, qEvent.id)
+        assert.equal(qPost.isVisible, !hidden)
       })
     })
   }

+ 4 - 3
tests/integration-tests/src/fixtures/forum/DeleteThreadsFixture.ts

@@ -4,7 +4,7 @@ import { EventDetails, MemberContext } from '../../types'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Utils } from '../../utils'
 import { ISubmittableResult } from '@polkadot/types/types/'
-import { ForumThreadWithPostsFieldsFragment, ThreadDeletedEventFieldsFragment } from '../../graphql/generated/queries'
+import { ForumThreadWithInitialPostFragment, ThreadDeletedEventFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { StandardizedFixture } from '../../Fixture'
 import { CategoryId } from '@joystream/types/forum'
@@ -60,7 +60,7 @@ export class DeleteThreadsFixture extends StandardizedFixture {
   }
 
   protected assertQueriedThreadsAreValid(
-    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qThreads: ForumThreadWithInitialPostFragment[],
     qEvents: ThreadDeletedEventFieldsFragment[]
   ): void {
     this.events.map((e, i) => {
@@ -73,6 +73,7 @@ export class DeleteThreadsFixture extends StandardizedFixture {
       Utils.assert(qThread.status.__typename === expectedStatus, `Invalid thread status. Expected: ${expectedStatus}`)
       Utils.assert(qThread.status.threadDeletedEvent, 'Query node: Missing ThreadDeletedEvent ref')
       assert.equal(qThread.status.threadDeletedEvent.id, qEvent.id)
+      assert.equal(qThread.isVisible, !hidden)
     })
   }
 
@@ -89,7 +90,7 @@ export class DeleteThreadsFixture extends StandardizedFixture {
     )
 
     // Query the threads
-    const qThreads = await this.query.getThreadsWithPostsByIds(this.removals.map((r) => r.threadId))
+    const qThreads = await this.query.getThreadsWithInitialPostsByIds(this.removals.map((r) => r.threadId))
     this.assertQueriedThreadsAreValid(qThreads, qEvents)
   }
 }

+ 1 - 1
tests/integration-tests/src/fixtures/forum/InitializeForumFixture.ts

@@ -177,7 +177,7 @@ export class InitializeForumFixture extends BaseQueryNodeFixture {
             Array.from({ length: threadsPerCategory }, (v, i) => ({
               categoryId,
               asMember: forumMemberIds[i % forumMemberIds.length],
-              title: `Thread ${i} in category ${categoryId.toString()}`,
+              metadata: { value: { title: `Thread ${i} in category ${categoryId.toString()}` } },
               text: `Initialize forum test thread ${i} in category ${categoryId.toString()}`,
             }))
           ))

+ 1 - 0
tests/integration-tests/src/fixtures/forum/ModeratePostsFixture.ts

@@ -61,6 +61,7 @@ export class ModeratePostsFixture extends WithForumWorkersFixture {
       Utils.assert(qPost.status.__typename === 'PostStatusModerated', 'Invalid post status')
       Utils.assert(qPost.status.postModeratedEvent, 'Query node: Missing PostModeratedEvent ref')
       assert.equal(qPost.status.postModeratedEvent.id, qEvent.id)
+      assert.equal(qPost.isVisible, false)
     })
   }
 

+ 4 - 3
tests/integration-tests/src/fixtures/forum/ModerateThreadsFixture.ts

@@ -5,7 +5,7 @@ import { WorkerId } from '@joystream/types/working-group'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { Utils } from '../../utils'
 import { ISubmittableResult } from '@polkadot/types/types/'
-import { ForumThreadWithPostsFieldsFragment, ThreadModeratedEventFieldsFragment } from '../../graphql/generated/queries'
+import { ForumThreadWithInitialPostFragment, ThreadModeratedEventFieldsFragment } from '../../graphql/generated/queries'
 import { assert } from 'chai'
 import { CategoryId } from '@joystream/types/forum'
 import { WithForumWorkersFixture } from './WithForumWorkersFixture'
@@ -48,7 +48,7 @@ export class ModerateThreadsFixture extends WithForumWorkersFixture {
   }
 
   protected assertQueriedThreadsAreValid(
-    qThreads: ForumThreadWithPostsFieldsFragment[],
+    qThreads: ForumThreadWithInitialPostFragment[],
     qEvents: ThreadModeratedEventFieldsFragment[]
   ): void {
     this.events.map((e, i) => {
@@ -59,6 +59,7 @@ export class ModerateThreadsFixture extends WithForumWorkersFixture {
       Utils.assert(qThread.status.__typename === 'ThreadStatusModerated', 'Invalid thread status')
       Utils.assert(qThread.status.threadModeratedEvent, 'Query node: Missing ThreadModeratedEvent ref')
       assert.equal(qThread.status.threadModeratedEvent.id, qEvent.id)
+      assert.equal(qThread.isVisible, false)
     })
   }
 
@@ -78,7 +79,7 @@ export class ModerateThreadsFixture extends WithForumWorkersFixture {
     )
 
     // Query the threads
-    const qThreads = await this.query.getThreadsWithPostsByIds(this.moderations.map((m) => m.threadId))
+    const qThreads = await this.query.getThreadsWithInitialPostsByIds(this.moderations.map((m) => m.threadId))
     this.assertQueriedThreadsAreValid(qThreads, qEvents)
   }
 }

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff