Browse Source

Merge branch 'olympia' into olympia_vnft_schema_mappings

ondratra 3 years ago
parent
commit
f117911518
60 changed files with 2796 additions and 314 deletions
  1. 2 15
      Cargo.lock
  2. 0 1
      Cargo.toml
  3. 0 0
      chain-metadata.json
  4. 377 31
      cli/README.md
  5. 17 0
      cli/examples/working-groups/CreateOpening.json
  6. 6 0
      cli/examples/working-groups/UpdateMetadata.json
  7. 4 1
      cli/package.json
  8. 93 0
      cli/scripts/forum-test.sh
  9. 61 0
      cli/scripts/working-groups-test.sh
  10. 161 14
      cli/src/Api.ts
  11. 1 0
      cli/src/Consts.ts
  12. 71 2
      cli/src/QueryNodeApi.ts
  13. 55 7
      cli/src/Types.ts
  14. 14 5
      cli/src/base/AccountsCommandBase.ts
  15. 61 20
      cli/src/base/ApiCommandBase.ts
  16. 165 0
      cli/src/base/ForumCommandBase.ts
  17. 20 5
      cli/src/base/WorkingGroupCommandBase.ts
  18. 2 1
      cli/src/commands/account/info.ts
  19. 52 0
      cli/src/commands/forum/addPost.ts
  20. 91 0
      cli/src/commands/forum/categories.ts
  21. 65 0
      cli/src/commands/forum/category.ts
  22. 55 0
      cli/src/commands/forum/createCategory.ts
  23. 52 0
      cli/src/commands/forum/createThread.ts
  24. 53 0
      cli/src/commands/forum/deleteCategory.ts
  25. 54 0
      cli/src/commands/forum/moderatePost.ts
  26. 46 0
      cli/src/commands/forum/moderateThread.ts
  27. 51 0
      cli/src/commands/forum/moveThread.ts
  28. 37 0
      cli/src/commands/forum/posts.ts
  29. 46 0
      cli/src/commands/forum/setStickiedThreads.ts
  30. 38 0
      cli/src/commands/forum/threads.ts
  31. 49 0
      cli/src/commands/forum/updateCategoryArchivalStatus.ts
  32. 50 0
      cli/src/commands/forum/updateCategoryModeratorStatus.ts
  33. 10 2
      cli/src/commands/working-groups/application.ts
  34. 86 23
      cli/src/commands/working-groups/apply.ts
  35. 175 42
      cli/src/commands/working-groups/createOpening.ts
  36. 13 10
      cli/src/commands/working-groups/fillOpening.ts
  37. 81 27
      cli/src/commands/working-groups/opening.ts
  38. 30 8
      cli/src/commands/working-groups/openings.ts
  39. 85 0
      cli/src/commands/working-groups/removeUpcomingOpening.ts
  40. 54 0
      cli/src/commands/working-groups/updateGroupMetadata.ts
  41. 151 0
      cli/src/graphql/generated/queries.ts
  42. 69 0
      cli/src/graphql/queries/workingGroups.graphql
  43. 6 1
      cli/src/schemas/ContentDirectory.ts
  44. 68 0
      cli/src/schemas/WorkingGroups.ts
  45. 1 1
      query-node/mappings/src/bootstrap-data/index.ts
  46. 5 7
      query-node/mappings/src/bootstrap-data/types.ts
  47. 24 1
      query-node/mappings/src/bootstrap.ts
  48. 50 0
      query-node/mappings/src/membership.ts
  49. 8 0
      runtime-modules/common/src/lib.rs
  50. 0 2
      runtime-modules/membership/src/genesis.rs
  51. 0 24
      runtime-modules/memo/Cargo.toml
  52. 0 46
      runtime-modules/memo/src/lib.rs
  53. 1 1
      runtime-modules/utility/Cargo.toml
  54. 10 3
      runtime-modules/utility/src/lib.rs
  55. 9 0
      runtime-modules/utility/src/tests/mocks.rs
  56. 0 2
      runtime/Cargo.toml
  57. 11 5
      runtime/src/lib.rs
  58. 0 1
      types/augment/all/defs.json
  59. 0 3
      types/augment/all/types.ts
  60. 0 3
      types/src/common.ts

+ 2 - 15
Cargo.lock

@@ -2365,7 +2365,6 @@ dependencies = [
  "pallet-grandpa",
  "pallet-im-online",
  "pallet-membership",
- "pallet-memo",
  "pallet-offences",
  "pallet-offences-benchmarking",
  "pallet-proposals-codex",
@@ -2383,7 +2382,7 @@ dependencies = [
  "pallet-timestamp",
  "pallet-transaction-payment",
  "pallet-transaction-payment-rpc-runtime-api",
- "pallet-utility 1.0.0",
+ "pallet-utility 1.1.0",
  "pallet-utility 2.0.1",
  "pallet-working-group",
  "parity-scale-codec",
@@ -3958,18 +3957,6 @@ dependencies = [
  "sp-std",
 ]
 
-[[package]]
-name = "pallet-memo"
-version = "5.0.0"
-dependencies = [
- "frame-support",
- "frame-system",
- "pallet-balances",
- "parity-scale-codec",
- "sp-arithmetic",
- "sp-std",
-]
-
 [[package]]
 name = "pallet-offences"
 version = "2.0.1"
@@ -4309,7 +4296,7 @@ dependencies = [
 
 [[package]]
 name = "pallet-utility"
-version = "1.0.0"
+version = "1.1.0"
 dependencies = [
  "frame-benchmarking",
  "frame-support",

+ 0 - 1
Cargo.toml

@@ -8,7 +8,6 @@ members = [
 	"runtime-modules/council",
 	"runtime-modules/forum",
 	"runtime-modules/membership",
-	"runtime-modules/memo",
 	"runtime-modules/referendum",
 	"runtime-modules/storage",
 	"runtime-modules/working-group",

File diff suppressed because it is too large
+ 0 - 0
chain-metadata.json


+ 377 - 31
cli/README.md

@@ -23,7 +23,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.6.0 linux-x64 node-v14.18.0
+@joystream/cli/0.7.0 linux-x64 node-v14.18.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -111,6 +111,20 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content:updateVideoCensorshipStatus ID [STATUS]`](#joystream-cli-contentupdatevideocensorshipstatus-id-status)
 * [`joystream-cli content:video VIDEOID`](#joystream-cli-contentvideo-videoid)
 * [`joystream-cli content:videos [CHANNELID]`](#joystream-cli-contentvideos-channelid)
+* [`joystream-cli forum:addPost`](#joystream-cli-forumaddpost)
+* [`joystream-cli forum:categories`](#joystream-cli-forumcategories)
+* [`joystream-cli forum:category`](#joystream-cli-forumcategory)
+* [`joystream-cli forum:createCategory`](#joystream-cli-forumcreatecategory)
+* [`joystream-cli forum:createThread`](#joystream-cli-forumcreatethread)
+* [`joystream-cli forum:deleteCategory`](#joystream-cli-forumdeletecategory)
+* [`joystream-cli forum:moderatePost`](#joystream-cli-forummoderatepost)
+* [`joystream-cli forum:moderateThread`](#joystream-cli-forummoderatethread)
+* [`joystream-cli forum:moveThread`](#joystream-cli-forummovethread)
+* [`joystream-cli forum:posts`](#joystream-cli-forumposts)
+* [`joystream-cli forum:setStickiedThreads`](#joystream-cli-forumsetstickiedthreads)
+* [`joystream-cli forum:threads`](#joystream-cli-forumthreads)
+* [`joystream-cli forum:updateCategoryArchivalStatus`](#joystream-cli-forumupdatecategoryarchivalstatus)
+* [`joystream-cli forum:updateCategoryModeratorStatus`](#joystream-cli-forumupdatecategorymoderatorstatus)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 * [`joystream-cli membership:addStakingAccount`](#joystream-cli-membershipaddstakingaccount)
 * [`joystream-cli membership:buy`](#joystream-cli-membershipbuy)
@@ -118,19 +132,21 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli membership:update`](#joystream-cli-membershipupdate)
 * [`joystream-cli membership:updateAccounts`](#joystream-cli-membershipupdateaccounts)
 * [`joystream-cli working-groups:application WGAPPLICATIONID`](#joystream-cli-working-groupsapplication-wgapplicationid)
-* [`joystream-cli working-groups:apply [OPENINGID]`](#joystream-cli-working-groupsapply-openingid)
+* [`joystream-cli working-groups:apply`](#joystream-cli-working-groupsapply)
 * [`joystream-cli working-groups:cancelOpening OPENINGID`](#joystream-cli-working-groupscancelopening-openingid)
 * [`joystream-cli working-groups:createOpening`](#joystream-cli-working-groupscreateopening)
 * [`joystream-cli working-groups:decreaseWorkerStake WORKERID AMOUNT`](#joystream-cli-working-groupsdecreaseworkerstake-workerid-amount)
 * [`joystream-cli working-groups:evictWorker WORKERID`](#joystream-cli-working-groupsevictworker-workerid)
-* [`joystream-cli working-groups:fillOpening WGOPENINGID`](#joystream-cli-working-groupsfillopening-wgopeningid)
+* [`joystream-cli working-groups:fillOpening`](#joystream-cli-working-groupsfillopening)
 * [`joystream-cli working-groups:increaseStake AMOUNT`](#joystream-cli-working-groupsincreasestake-amount)
 * [`joystream-cli working-groups:leaveRole`](#joystream-cli-working-groupsleaverole)
-* [`joystream-cli working-groups:opening WGOPENINGID`](#joystream-cli-working-groupsopening-wgopeningid)
+* [`joystream-cli working-groups:opening`](#joystream-cli-working-groupsopening)
 * [`joystream-cli working-groups:openings`](#joystream-cli-working-groupsopenings)
 * [`joystream-cli working-groups:overview`](#joystream-cli-working-groupsoverview)
+* [`joystream-cli working-groups:removeUpcomingOpening`](#joystream-cli-working-groupsremoveupcomingopening)
 * [`joystream-cli working-groups:setDefaultGroup`](#joystream-cli-working-groupssetdefaultgroup)
 * [`joystream-cli working-groups:slashWorker WORKERID AMOUNT`](#joystream-cli-working-groupsslashworker-workerid-amount)
+* [`joystream-cli working-groups:updateGroupMetadata`](#joystream-cli-working-groupsupdategroupmetadata)
 * [`joystream-cli working-groups:updateRewardAccount [ADDRESS]`](#joystream-cli-working-groupsupdaterewardaccount-address)
 * [`joystream-cli working-groups:updateRoleAccount [ADDRESS]`](#joystream-cli-working-groupsupdateroleaccount-address)
 * [`joystream-cli working-groups:updateRoleStorage STORAGE`](#joystream-cli-working-groupsupdaterolestorage-storage)
@@ -284,15 +300,15 @@ OPTIONS
       If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.
 
   -a, --callArgs=callArgs
-      Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie.
+      Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. 
       "-a=arg1,arg2".
       You can omit this flag even if the method requires some aguments.
       In that case you will be promted to provide value for each required argument.
-      Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as
+      Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as 
       just simple comma-separated strings)
 
   -e, --exec
-      Provide this flag if you want to execute the actual call, instead of displaying the method description (which is
+      Provide this flag if you want to execute the actual call, instead of displaying the method description (which is 
       default)
 
   -m, --method=method
@@ -876,6 +892,256 @@ OPTIONS
 
 _See code: [src/commands/content/videos.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/videos.ts)_
 
+## `joystream-cli forum:addPost`
+
+Add forum post.
+
+```
+USAGE
+  $ joystream-cli forum:addPost
+
+OPTIONS
+  --categoryId=categoryId    (required) Id of the forum category of the parent thread
+  --editable                 Whether the post should be editable
+  --text=text                (required) Post content (md-formatted text)
+  --threadId=threadId        (required) Post's parent thread
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/addPost.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/addPost.ts)_
+
+## `joystream-cli forum:categories`
+
+List existing forum categories by parent id (root categories by default) or displays a category tree.
+
+```
+USAGE
+  $ joystream-cli forum:categories
+
+OPTIONS
+  -c, --tree                               Display a category tree (with parentCategoryId as root, if specified)
+  -p, --parentCategoryId=parentCategoryId  Parent category id (only child categories will be listed)
+  --useMemberId=useMemberId                Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId                Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/categories.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/categories.ts)_
+
+## `joystream-cli forum:category`
+
+Display forum category details.
+
+```
+USAGE
+  $ joystream-cli forum:category
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Forum category id
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/category.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/category.ts)_
+
+## `joystream-cli forum:createCategory`
+
+Create forum category.
+
+```
+USAGE
+  $ joystream-cli forum:createCategory
+
+OPTIONS
+  -d, --description=description            (required) Category description
+  -p, --parentCategoryId=parentCategoryId  Parent category id (in case of creating a subcategory)
+  -t, --title=title                        (required) Category title
+  --useMemberId=useMemberId                Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId                Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/createCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/createCategory.ts)_
+
+## `joystream-cli forum:createThread`
+
+Create forum thread.
+
+```
+USAGE
+  $ joystream-cli forum:createThread
+
+OPTIONS
+  --categoryId=categoryId    (required) Id of the forum category the thread should be created in
+  --tags=tags                Space-separated tags to associate with the thread
+  --text=text                (required) Initial post text
+  --title=title              (required) Thread title
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/createThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/createThread.ts)_
+
+## `joystream-cli forum:deleteCategory`
+
+Delete forum category provided it has no existing subcategories and threads.
+
+```
+USAGE
+  $ joystream-cli forum:deleteCategory
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Id of the category to delete
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/deleteCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/deleteCategory.ts)_
+
+## `joystream-cli forum:moderatePost`
+
+Moderate a forum post and slash the associated stake.
+
+```
+USAGE
+  $ joystream-cli forum:moderatePost
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Forum category id
+  -p, --postId=postId           (required) Forum post id
+  -r, --rationale=rationale     (required) Rationale behind the post moderation.
+  -t, --threadId=threadId       (required) Forum thread id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moderatePost.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moderatePost.ts)_
+
+## `joystream-cli forum:moderateThread`
+
+Moderate a forum thread and slash the associated stake.
+
+```
+USAGE
+  $ joystream-cli forum:moderateThread
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Id of the forum category the thread is currently in
+  -r, --rationale=rationale     (required) Rationale behind the thread moderation.
+  -t, --threadId=threadId       (required) Forum thread id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moderateThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moderateThread.ts)_
+
+## `joystream-cli forum:moveThread`
+
+Move forum thread to a different category.
+
+```
+USAGE
+  $ joystream-cli forum:moveThread
+
+OPTIONS
+  -c, --categoryId=categoryId        (required) Thread's current category id
+  -n, --newCategoryId=newCategoryId  (required) Thread's new category id
+  -t, --threadId=threadId            (required) Forum thread id
+  --context=(Leader|Moderator)       Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId          Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId          Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moveThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moveThread.ts)_
+
+## `joystream-cli forum:posts`
+
+List existing forum posts in given thread.
+
+```
+USAGE
+  $ joystream-cli forum:posts
+
+OPTIONS
+  -t, --threadId=threadId    (required) Thread id (only posts in this thread will be listed)
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/posts.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/posts.ts)_
+
+## `joystream-cli forum:setStickiedThreads`
+
+Set stickied threads in a given category.
+
+```
+USAGE
+  $ joystream-cli forum:setStickiedThreads
+
+OPTIONS
+  --categoryId=categoryId       (required) Forum category id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --threadIds=threadIds         Space-separated thread ids
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/setStickiedThreads.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/setStickiedThreads.ts)_
+
+## `joystream-cli forum:threads`
+
+List existing forum threads in given category.
+
+```
+USAGE
+  $ joystream-cli forum:threads
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Category id (only threads in this category will be listed)
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/threads.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/threads.ts)_
+
+## `joystream-cli forum:updateCategoryArchivalStatus`
+
+Update archival status of a forum category.
+
+```
+USAGE
+  $ joystream-cli forum:updateCategoryArchivalStatus
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Forum category id
+  --archived=(yes|no)           (required) Whether the category should be archived
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/updateCategoryArchivalStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/updateCategoryArchivalStatus.ts)_
+
+## `joystream-cli forum:updateCategoryModeratorStatus`
+
+Update moderator status of a worker in relation to a category.
+
+```
+USAGE
+  $ joystream-cli forum:updateCategoryModeratorStatus
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Forum category id
+  -w, --workerId=workerId      (required) Forum working group worker id
+  --status=(active|disabled)   (required) Status of the moderator membership in the category
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/updateCategoryModeratorStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/updateCategoryModeratorStatus.ts)_
+
 ## `joystream-cli help [COMMAND]`
 
 display help for joystream-cli
@@ -1019,16 +1285,13 @@ OPTIONS
 
 _See code: [src/commands/working-groups/application.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/application.ts)_
 
-## `joystream-cli working-groups:apply [OPENINGID]`
+## `joystream-cli working-groups:apply`
 
 Apply to a working group opening (requires a membership)
 
 ```
 USAGE
-  $ joystream-cli working-groups:apply [OPENINGID]
-
-ARGUMENTS
-  OPENINGID  Opening ID
+  $ joystream-cli working-groups:apply
 
 OPTIONS
   -g, 
@@ -1038,6 +1301,21 @@ OPTIONS
       Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
       operationsGamma, distributors.
 
+  --answers=answers
+      Answers for opening's application form questions (sorted by question index)
+
+  --openingId=openingId
+      (required) Opening ID
+
+  --rewardAccount=rewardAccount
+      Future worker reward account
+
+  --roleAccount=roleAccount
+      Future worker role account
+
+  --stakingAccount=stakingAccount
+      Account to hold applicant's / worker's stake
+
   --useMemberId=useMemberId
       Try using the specified member id as context whenever possible
 
@@ -1077,7 +1355,7 @@ _See code: [src/commands/working-groups/cancelOpening.ts](https://github.com/Joy
 
 ## `joystream-cli working-groups:createOpening`
 
-Create working group opening (requires lead access)
+Create working group opening / upcoming opening (requires lead access)
 
 ```
 USAGE
@@ -1104,6 +1382,15 @@ OPTIONS
       If provided along with --output - skips sending the actual extrinsic(can be used to generate a "draft" which can be 
       provided as input later)
 
+  --stakeTopUpSource=stakeTopUpSource
+      If provided - this account (key) will be used as default funds source for lead stake top up (in case it's needed)
+
+  --startsAt=startsAt
+      If upcoming opening - the expected opening start date (YYYY-MM-DD HH:mm:ss)
+
+  --upcoming
+      Whether the opening should be an upcoming opening
+
   --useMemberId=useMemberId
       Try using the specified member id as context whenever possible
 
@@ -1176,16 +1463,13 @@ OPTIONS
 
 _See code: [src/commands/working-groups/evictWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/evictWorker.ts)_
 
-## `joystream-cli working-groups:fillOpening WGOPENINGID`
+## `joystream-cli working-groups:fillOpening`
 
 Allows filling working group opening that's currently in review. Requires lead access.
 
 ```
 USAGE
-  $ joystream-cli working-groups:fillOpening WGOPENINGID
-
-ARGUMENTS
-  WGOPENINGID  Working Group Opening ID
+  $ joystream-cli working-groups:fillOpening
 
 OPTIONS
   -g, 
@@ -1195,6 +1479,12 @@ OPTIONS
       Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
       operationsGamma, distributors.
 
+  --applicationIds=applicationIds
+      Accepted application ids
+
+  --openingId=openingId
+      (required) Working Group Opening ID
+
   --useMemberId=useMemberId
       Try using the specified member id as context whenever possible
 
@@ -1259,16 +1549,13 @@ OPTIONS
 
 _See code: [src/commands/working-groups/leaveRole.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/leaveRole.ts)_
 
-## `joystream-cli working-groups:opening WGOPENINGID`
+## `joystream-cli working-groups:opening`
 
-Shows an overview of given working group opening by Working Group Opening ID
+Shows detailed information about working group opening / upcoming opening by id
 
 ```
 USAGE
-  $ joystream-cli working-groups:opening WGOPENINGID
-
-ARGUMENTS
-  WGOPENINGID  Working Group Opening ID
+  $ joystream-cli working-groups:opening
 
 OPTIONS
   -g, 
@@ -1278,6 +1565,12 @@ OPTIONS
       Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
       operationsGamma, distributors.
 
+  --id=id
+      (required) Opening / upcoming opening id (depending on --upcoming flag)
+
+  --upcoming
+      Whether the opening is an upcoming opening
+
   --useMemberId=useMemberId
       Try using the specified member id as context whenever possible
 
@@ -1289,7 +1582,7 @@ _See code: [src/commands/working-groups/opening.ts](https://github.com/Joystream
 
 ## `joystream-cli working-groups:openings`
 
-Shows an overview of given working group openings
+Lists active/upcoming openings in a given working group
 
 ```
 USAGE
@@ -1303,6 +1596,9 @@ OPTIONS
       Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
       operationsGamma, distributors.
 
+  --upcoming
+      List upcoming openings (active openings are listed by default)
+
   --useMemberId=useMemberId
       Try using the specified member id as context whenever possible
 
@@ -1337,6 +1633,34 @@ OPTIONS
 
 _See code: [src/commands/working-groups/overview.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/overview.ts)_
 
+## `joystream-cli working-groups:removeUpcomingOpening`
+
+Remove an existing upcoming opening by sending RemoveUpcomingOpening metadata signal (requires lead access)
+
+```
+USAGE
+  $ joystream-cli working-groups:removeUpcomingOpening
+
+OPTIONS
+  -g, 
+  --group=(storageProviders|curators|forum|membership|gateway|operationsAlpha|operationsBeta|operationsGamma|distributor
+  s)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
+      operationsGamma, distributors.
+
+  -i, --id=id
+      (required) Id of the upcoming opening to remove
+
+  --useMemberId=useMemberId
+      Try using the specified member id as context whenever possible
+
+  --useWorkerId=useWorkerId
+      Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/working-groups/removeUpcomingOpening.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/removeUpcomingOpening.ts)_
+
 ## `joystream-cli working-groups:setDefaultGroup`
 
 Change the default group context for working-groups commands.
@@ -1393,6 +1717,34 @@ OPTIONS
 
 _See code: [src/commands/working-groups/slashWorker.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/slashWorker.ts)_
 
+## `joystream-cli working-groups:updateGroupMetadata`
+
+Update working group metadata (description, status etc.). The update will be atomic (just like video / channel metadata updates)
+
+```
+USAGE
+  $ joystream-cli working-groups:updateGroupMetadata
+
+OPTIONS
+  -g, 
+  --group=(storageProviders|curators|forum|membership|gateway|operationsAlpha|operationsBeta|operationsGamma|distributor
+  s)
+      The working group context in which the command should be executed
+      Available values are: storageProviders, curators, forum, membership, gateway, operationsAlpha, operationsBeta, 
+      operationsGamma, distributors.
+
+  -i, --input=input
+      (required) Path to JSON file to use as input
+
+  --useMemberId=useMemberId
+      Try using the specified member id as context whenever possible
+
+  --useWorkerId=useWorkerId
+      Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/working-groups/updateGroupMetadata.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateGroupMetadata.ts)_
+
 ## `joystream-cli working-groups:updateRewardAccount [ADDRESS]`
 
 Updates the worker/lead reward account (requires current role account to be selected)
@@ -1506,9 +1858,3 @@ OPTIONS
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_
 <!-- commandsstop -->
-
-# Environment variables
-<!-- env -->
-- `FORCE_COLOR` - can be set to `0` to disable output coloring
-- `AUTO_CONFIRM` - can be set to `1` or `true` to skip any required confirmations (can be useful for creating bash scripts)
-<!-- envstop -->

+ 17 - 0
cli/examples/working-groups/CreateOpening.json

@@ -0,0 +1,17 @@
+{
+  "applicationDetails": "- Detail 1\n- Detail 2\n- Detail 3",
+  "expectedEndingTimestamp": 1893499200,
+  "hiringLimit": 10,
+  "shortDescription": "Example opening short description",
+  "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sodales ligula purus, vel malesuada urna dignissim et. Vestibulum tincidunt gravida diam ac ultrices. Vivamus et dolor non turpis egestas cursus quis non sapien. Praesent eros ligula, faucibus id viverra nec, feugiat vel lacus. Etiam eget magna ipsum. In ac dolor hendrerit, sodales enim vel, lobortis neque. Sed feugiat egestas turpis non ultrices. Sed sed purus neque. Vestibulum feugiat elementum finibus. Vivamus eget pulvinar eros. Curabitur non dapibus turpis, nec egestas erat.",
+  "applicationFormQuestions": [
+    { "question": "What's your name?", "type": "TEXT" },
+    { "question": "How old are you?", "type": "TEXT" },
+    { "question": "Why are you a good candidate?", "type": "TEXTAREA" }
+  ],
+  "stakingPolicy": {
+    "amount": 2000,
+    "unstakingPeriod": 100800
+  },
+  "rewardPerBlock": 100
+}

+ 6 - 0
cli/examples/working-groups/UpdateMetadata.json

@@ -0,0 +1,6 @@
+{
+  "description": "Example working group description",
+  "about": "Example working group about text",
+  "status": "Example status",
+  "statusMessage": "Example status message"
+}

+ 4 - 1
cli/package.json

@@ -122,6 +122,9 @@
       },
       "membership": {
         "description": "Membership management - buy a new membership, update membership, manage membership keys"
+      },
+      "forum": {
+        "description": "Forum working group activities (moderation, category management)"
       }
     }
   },
@@ -135,7 +138,7 @@
     "posttest": "yarn lint",
     "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
-    "build": "tsc --build tsconfig.json",
+    "build": "rm -rf lib && tsc --build tsconfig.json",
     "version": "oclif-dev readme && git add README.md",
     "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",

+ 93 - 0
cli/scripts/forum-test.sh

@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
+
+# Init forum lead
+GROUP=forumWorkingGroup yarn workspace api-scripts initialize-lead
+# Add integration tests lead key (in case the script is executed after ./start.sh)
+yarn joystream-cli account:forget --name "Test forum lead key" || true
+yarn joystream-cli account:import --suri //testing//worker//Forum//0 --name "Test forum lead key" --password ""
+
+# Assume leader is the first worker
+LEADER_WORKER_ID="0"
+
+# Create test categories
+CATEGORY_1_ID=`${CLI} forum:createCategory -t "Test category 1" -d "Test category 1 description"`
+CATEGORY_2_ID=`${CLI} forum:createCategory -t "Test category 2" -d "Test category 2 description" -p ${CATEGORY_1_ID}`
+# Create test threads
+THREAD_1_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_1_ID}\
+  --title "Test thread 1"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 1 initial post text"`
+THREAD_2_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_2_ID}\
+  --title "Test thread 2"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 2 initial post text"`
+# Create test posts
+POST_1_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_1_ID}\
+  --text "Test post 1"\
+  --editable`
+POST_2_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_2_ID}\
+  --text "Test post 2"\
+  --editable`
+
+# Update category modrator permissions
+${CLI} forum:updateCategoryModeratorStatus --categoryId ${CATEGORY_1_ID} --workerId ${LEADER_WORKER_ID} --status active
+
+# Update category archival status as lead
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived yes --context Leader
+# Update category archival status as moderator
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived no --context Moderator
+
+# Move thread as lead
+${CLI} forum:moveThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_1_ID} --newCategoryId ${CATEGORY_2_ID} --context Leader
+# Move thread as moderator
+${CLI} forum:moveThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_2_ID} --newCategoryId ${CATEGORY_1_ID} --context Moderator
+
+# Set stickied threads as lead
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_1_ID} --threadIds ${THREAD_2_ID} --context Leader
+# Set stickied threads as moderator
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_2_ID} --threadIds ${THREAD_1_ID} --context Moderator
+
+# Moderate post as lead
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_1_ID}\
+  --postId ${POST_1_ID}\
+  --rationale "Leader test"\
+  --context Leader
+# Moderate post as moderator
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_2_ID}\
+  --postId ${POST_2_ID}\
+  --rationale "Moderator test"\
+  --context Moderator
+
+# Moderate thread as lead
+${CLI} forum:moderateThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_1_ID} --rationale "Leader test" --context Leader
+# Moderate thread as moderator
+${CLI} forum:moderateThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_2_ID} --rationale "Moderator test" --context Moderator
+
+# Delete category as moderator
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_2_ID} --context Moderator
+# Delete category as lead
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_1_ID} --context Leader
+
+# Forget test lead account
+yarn joystream-cli account:forget --name "Test forum lead key"

+ 61 - 0
cli/scripts/working-groups-test.sh

@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
+
+# Use storage working group as default group
+TEST_LEAD_SURI="//testing//worker//Storage//0"
+
+# Init lead
+GROUP="storageWorkingGroup" yarn workspace api-scripts initialize-lead
+# CLI commands group
+GROUP="storageProviders"
+# Add integration tests lead key (in case the script is executed after ./start.sh)
+${CLI} account:forget --name "Test wg lead key" || true
+${CLI} account:import --suri ${TEST_LEAD_SURI} --name "Test wg lead key" --password "" || true
+# Set/update working group metadata
+${CLI} working-groups:updateGroupMetadata --group ${GROUP} -i ../examples/working-groups/UpdateMetadata.json
+# Create upcoming opening
+UPCOMING_OPENING_ID=`${CLI} working-groups:createOpening \
+  --group ${GROUP} \
+  --input ../examples/working-groups/CreateOpening.json \
+  --upcoming \
+  --startsAt 2030-01-01`
+# Delete upcoming opening
+${CLI} working-groups:removeUpcomingOpening --group ${GROUP} --id ${UPCOMING_OPENING_ID}
+# Create opening
+OPENING_ID=`${CLI} working-groups:createOpening \
+  --group ${GROUP} \
+  --input ../examples/working-groups/CreateOpening.json \
+  --stakeTopUpSource 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY`
+# Setup a staking account (//Alice//worker-stake)
+${CLI} account:forget --name "Test worker staking key" || true
+${CLI} account:import --suri //Alice//worker-stake --name "Test worker staking key" --password "" || true
+${CLI} membership:addStakingAccount \
+  --address 5Dyzr3jNj1JngvJPDf4dpjsgZqZaUSrhFMdmKJMYkziv74qt \
+  --withBalance 2000 \
+  --fundsSource 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
+# Apply
+APPLICATION_ID=`${CLI} working-groups:apply \
+  --group ${GROUP} \
+  --openingId ${OPENING_ID} \
+  --roleAccount 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY \
+  --rewardAccount 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY \
+  --stakingAccount 5Dyzr3jNj1JngvJPDf4dpjsgZqZaUSrhFMdmKJMYkziv74qt \
+  --answers "Alice" "30" "I'm the best!"`
+# Fill opening
+${CLI} working-groups:fillOpening \
+  --group ${GROUP} \
+  --openingId ${OPENING_ID} \
+  --applicationIds ${APPLICATION_ID}
+# Forget test lead account and test worker staking account
+${CLI} account:forget --name "Test wg lead key"
+${CLI} account:forget --name "Test worker staking key"

+ 161 - 14
cli/src/Api.ts

@@ -3,7 +3,7 @@ import { createType, types } from '@joystream/types'
 import { ApiPromise, WsProvider } from '@polkadot/api'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
-import { Balance } from '@polkadot/types/interfaces'
+import { Balance, LockIdentifier } from '@polkadot/types/interfaces'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { Codec, Observable } from '@polkadot/types/types'
 import { UInt } from '@polkadot/types'
@@ -16,12 +16,13 @@ import {
   OpeningDetails,
   UnaugmentedApiPromise,
   MemberDetails,
+  AvailableGroups,
 } from './Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 import { Worker, WorkerId, OpeningId, Application, ApplicationId, Opening } from '@joystream/types/working-group'
 import { Membership, StakingAccountMemberBinding } from '@joystream/types/members'
-import { MemberId, ChannelId, AccountId } from '@joystream/types/common'
+import { MemberId, ChannelId, AccountId, ThreadId, PostId } from '@joystream/types/common'
 import {
   Channel,
   Video,
@@ -35,6 +36,9 @@ import { BagId, DataObject, DataObjectId } from '@joystream/types/storage'
 import QueryNodeApi from './QueryNodeApi'
 import { MembershipFieldsFragment } from './graphql/generated/queries'
 import { blake2AsHex } from '@polkadot/util-crypto'
+import { Category, CategoryId, Post, Thread } from '@joystream/types/forum'
+import chalk from 'chalk'
+import _ from 'lodash'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 
@@ -156,7 +160,9 @@ export default class Api {
   }
 
   async membersDetails(entries: [MemberId, Membership][]): Promise<MemberDetails[]> {
-    const membersQnData = await this._qnApi?.membersByIds(entries.map(([id]) => id))
+    const membersQnData: MembershipFieldsFragment[] | undefined = await this._qnApi?.membersByIds(
+      entries.map(([id]) => id)
+    )
     const memberQnDataById = new Map<string, MembershipFieldsFragment>()
     membersQnData?.forEach((m) => {
       memberQnDataById.set(m.id, m)
@@ -251,18 +257,11 @@ export default class Api {
     }
   }
 
-  async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
-    const nextId = await this.workingGroupApiQuery(group).nextWorkerId()
-
-    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
-    if (workerId < 0 || workerId >= nextId.toNumber()) {
-      throw new CLIError('Invalid worker id!')
-    }
-
+  async workerByWorkerId(group: WorkingGroups, workerId: WorkerId | number): Promise<Worker> {
     const worker = await this.workingGroupApiQuery(group).workerById(workerId)
 
     if (worker.isEmpty) {
-      throw new CLIError('This worker is not active anymore')
+      throw new CLIError(`Worker ${chalk.magentaBright(workerId)} does not exist!`)
     }
 
     return worker
@@ -310,9 +309,11 @@ export default class Api {
   }
 
   protected async fetchApplicationDetails(
+    group: WorkingGroups,
     applicationId: number,
     application: Application
   ): Promise<ApplicationDetails> {
+    const qnData = await this._qnApi?.applicationDetailsById(group, applicationId)
     return {
       applicationId,
       member: await this.expectedMemberDetailsById(application.member_id),
@@ -321,12 +322,13 @@ export default class Api {
       stakingAccount: application.staking_account_id,
       descriptionHash: application.description_hash.toString(),
       openingId: application.opening_id.toNumber(),
+      answers: qnData?.answers,
     }
   }
 
   async groupApplication(group: WorkingGroups, applicationId: number): Promise<ApplicationDetails> {
     const application = await this.applicationById(group, applicationId)
-    return await this.fetchApplicationDetails(applicationId, application)
+    return await this.fetchApplicationDetails(group, applicationId, application)
   }
 
   protected async groupOpeningApplications(group: WorkingGroups, openingId: number): Promise<ApplicationDetails[]> {
@@ -337,7 +339,7 @@ export default class Api {
     return Promise.all(
       applicationEntries
         .filter(([, application]) => application.opening_id.eqn(openingId))
-        .map(([id, application]) => this.fetchApplicationDetails(id.toNumber(), application))
+        .map(([id, application]) => this.fetchApplicationDetails(group, id.toNumber(), application))
     )
   }
 
@@ -358,6 +360,7 @@ export default class Api {
   }
 
   async fetchOpeningDetails(group: WorkingGroups, opening: Opening, openingId: number): Promise<OpeningDetails> {
+    const qnData = await this._qnApi?.openingDetailsById(group, openingId)
     const applications = await this.groupOpeningApplications(group, openingId)
     const type = opening.opening_type
     const stake = {
@@ -372,6 +375,7 @@ export default class Api {
       stake,
       createdAtBlock: opening.created.toNumber(),
       rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
+      metadata: qnData?.metadata || undefined,
     }
   }
 
@@ -459,4 +463,147 @@ export default class Api {
     const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
     return !existingMeber.isEmpty
   }
+
+  allowedLockCombinations(): { [lockId: string]: LockIdentifier[] } {
+    // TODO: Fetch from runtime once exposed
+    const invitedMemberLockId = this._api.consts.members.invitedMemberLockId
+    const candidacyLockId = this._api.consts.council.candidacyLockId
+    const votingLockId = this._api.consts.referendum.stakingHandlerLockId
+    const councilorLockId = this._api.consts.council.councilorLockId
+    const stakingCandidateLockId = this._api.consts.members.stakingCandidateLockId
+    const proposalsLockId = this._api.consts.proposalsEngine.stakingHandlerLockId
+    const groupLockIds: { group: WorkingGroups; lockId: LockIdentifier }[] = AvailableGroups.map((group) => ({
+      group,
+      lockId: this._api.consts[apiModuleByGroup[group]].stakingHandlerLockId,
+    }))
+    const bountyLockId = this._api.consts.bounty.bountyLockId
+
+    const lockCombinationsByWorkingGroupLockId: { [groupLockId: string]: LockIdentifier[] } = {}
+    groupLockIds.forEach(
+      ({ lockId }) =>
+        (lockCombinationsByWorkingGroupLockId[lockId.toString()] = [
+          invitedMemberLockId,
+          votingLockId,
+          stakingCandidateLockId,
+        ])
+    )
+
+    return {
+      [invitedMemberLockId.toString()]: [
+        votingLockId,
+        candidacyLockId,
+        councilorLockId,
+        // STAKING_LOCK_ID,
+        proposalsLockId,
+        stakingCandidateLockId,
+        ...groupLockIds.map(({ lockId }) => lockId),
+      ],
+      [stakingCandidateLockId.toString()]: [
+        votingLockId,
+        candidacyLockId,
+        councilorLockId,
+        // STAKING_LOCK_ID,
+        proposalsLockId,
+        invitedMemberLockId,
+        ...groupLockIds.map(({ lockId }) => lockId),
+      ],
+      [votingLockId.toString()]: [
+        invitedMemberLockId,
+        candidacyLockId,
+        councilorLockId,
+        // STAKING_LOCK_ID,
+        proposalsLockId,
+        stakingCandidateLockId,
+        ...groupLockIds.map(({ lockId }) => lockId),
+      ],
+      [candidacyLockId.toString()]: [invitedMemberLockId, votingLockId, councilorLockId, stakingCandidateLockId],
+      [councilorLockId.toString()]: [invitedMemberLockId, votingLockId, candidacyLockId, stakingCandidateLockId],
+      [proposalsLockId.toString()]: [invitedMemberLockId, votingLockId, stakingCandidateLockId],
+      ...lockCombinationsByWorkingGroupLockId,
+      [bountyLockId.toString()]: [votingLockId, stakingCandidateLockId],
+    }
+  }
+
+  async areAccountLocksCompatibleWith(account: AccountId | string, lockId: LockIdentifier): Promise<boolean> {
+    const accountLocks = await this._api.query.balances.locks(account)
+    const allowedLocks = this.allowedLockCombinations()[lockId.toString()]
+    return accountLocks.every((l) => allowedLocks.some((allowedLock) => allowedLock.eq(l.id)))
+  }
+
+  async forumCategoryExists(categoryId: CategoryId | number): Promise<boolean> {
+    const size = await this._api.query.forum.categoryById.size(categoryId)
+    return size.gtn(0)
+  }
+
+  async forumThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<boolean> {
+    const size = await this._api.query.forum.threadById.size(categoryId, threadId)
+    return size.gtn(0)
+  }
+
+  async forumPostExists(threadId: ThreadId | number, postId: PostId | number): Promise<boolean> {
+    const size = await this._api.query.forum.postById.size(threadId, postId)
+    return size.gtn(0)
+  }
+
+  async forumCategoryAncestors(categoryId: CategoryId | number): Promise<[CategoryId, Category][]> {
+    const ancestors: [CategoryId, Category][] = []
+    let category = await this._api.query.forum.categoryById(categoryId)
+    while (category.parent_category_id.isSome) {
+      const parentCategoryId = category.parent_category_id.unwrap()
+      category = await this._api.query.forum.categoryById(parentCategoryId)
+      ancestors.push([parentCategoryId, category])
+    }
+    return ancestors
+  }
+
+  async forumCategoryModerators(categoryId: CategoryId | number): Promise<[CategoryId, WorkerId][]> {
+    const categoryAncestors = await this.forumCategoryAncestors(categoryId)
+
+    const moderatorIds = _.uniqWith(
+      _.flatten(
+        await Promise.all(
+          categoryAncestors
+            .map(([id]) => id as CategoryId | number)
+            .reverse()
+            .concat([categoryId])
+            .map(async (id) => {
+              const storageKeys = await this._api.query.forum.categoryByModerator.keys(id)
+              return storageKeys.map((k) => k.args)
+            })
+        )
+      ),
+      (a, b) => a[1].eq(b[1])
+    )
+
+    return moderatorIds
+  }
+
+  async getForumCategory(categoryId: CategoryId | number): Promise<Category> {
+    const category = await this._api.query.forum.categoryById(categoryId)
+    return category
+  }
+
+  async getForumThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    const thread = await this._api.query.forum.threadById(categoryId, threadId)
+    return thread
+  }
+
+  async getForumPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    const post = await this._api.query.forum.postById(threadId, postId)
+    return post
+  }
+
+  async forumCategories(): Promise<[CategoryId, Category][]> {
+    return this.entriesByIds(this._api.query.forum.categoryById)
+  }
+
+  async forumThreads(categoryId: CategoryId | number): Promise<[ThreadId, Thread][]> {
+    const entries = await this._api.query.forum.threadById.entries(categoryId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
+
+  async forumPosts(threadId: ThreadId | number): Promise<[PostId, Post][]> {
+    const entries = await this._api.query.forum.postById.entries(threadId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
 }

+ 1 - 0
cli/src/Consts.ts

@@ -0,0 +1 @@
+export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'

+ 71 - 2
cli/src/QueryNodeApi.ts

@@ -1,4 +1,4 @@
-import { StorageNodeInfo } from './Types'
+import { StorageNodeInfo, WorkingGroups } from './Types'
 import {
   ApolloClient,
   InMemoryCache,
@@ -28,10 +28,30 @@ import {
   GetMembersByIdsQuery,
   GetMembersByIdsQueryVariables,
   MembershipFieldsFragment,
+  WorkingGroupOpeningDetailsFragment,
+  OpeningDetailsByIdQuery,
+  OpeningDetailsByIdQueryVariables,
+  OpeningDetailsById,
+  WorkingGroupApplicationDetailsFragment,
+  ApplicationDetailsByIdQuery,
+  ApplicationDetailsByIdQueryVariables,
+  ApplicationDetailsById,
+  UpcomingWorkingGroupOpeningByEventQuery,
+  UpcomingWorkingGroupOpeningByEventQueryVariables,
+  UpcomingWorkingGroupOpeningByEvent,
+  UpcomingWorkingGroupOpeningDetailsFragment,
+  UpcomingWorkingGroupOpeningByIdQuery,
+  UpcomingWorkingGroupOpeningByIdQueryVariables,
+  UpcomingWorkingGroupOpeningById,
+  UpcomingWorkingGroupOpeningsByGroupQuery,
+  UpcomingWorkingGroupOpeningsByGroupQueryVariables,
+  UpcomingWorkingGroupOpeningsByGroup,
 } from './graphql/generated/queries'
 import { URL } from 'url'
 import fetch from 'cross-fetch'
 import { MemberId } from '@joystream/types/common'
+import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { apiModuleByGroup } from './Api'
 
 export default class QueryNodeApi {
   private _qnClient: ApolloClient<NormalizedCacheObject>
@@ -44,7 +64,7 @@ export default class QueryNodeApi {
     links.push(new HttpLink({ uri, fetch }))
     this._qnClient = new ApolloClient({
       link: from(links),
-      cache: new InMemoryCache(),
+      cache: new InMemoryCache({ addTypename: false }),
       defaultOptions: { query: { fetchPolicy: 'no-cache', errorPolicy: 'all' } },
     })
   }
@@ -137,4 +157,53 @@ export default class QueryNodeApi {
       'memberships'
     )
   }
+
+  async openingDetailsById(
+    group: WorkingGroups,
+    id: OpeningId | number
+  ): Promise<WorkingGroupOpeningDetailsFragment | null> {
+    return this.uniqueEntityQuery<OpeningDetailsByIdQuery, OpeningDetailsByIdQueryVariables>(
+      OpeningDetailsById,
+      { id: `${apiModuleByGroup[group]}-${id.toString()}` },
+      'workingGroupOpeningByUniqueInput'
+    )
+  }
+
+  async applicationDetailsById(
+    group: WorkingGroups,
+    id: ApplicationId | number
+  ): Promise<WorkingGroupApplicationDetailsFragment | null> {
+    return this.uniqueEntityQuery<ApplicationDetailsByIdQuery, ApplicationDetailsByIdQueryVariables>(
+      ApplicationDetailsById,
+      { id: `${apiModuleByGroup[group]}-${id.toString()}` },
+      'workingGroupApplicationByUniqueInput'
+    )
+  }
+
+  async upcomingWorkingGroupOpeningByEvent(
+    blockNumber: number,
+    indexInBlock: number
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment | null> {
+    return this.firstEntityQuery<
+      UpcomingWorkingGroupOpeningByEventQuery,
+      UpcomingWorkingGroupOpeningByEventQueryVariables
+    >(UpcomingWorkingGroupOpeningByEvent, { blockNumber, indexInBlock }, 'upcomingWorkingGroupOpenings')
+  }
+
+  async upcomingWorkingGroupOpeningById(id: string): Promise<UpcomingWorkingGroupOpeningDetailsFragment | null> {
+    return this.uniqueEntityQuery<UpcomingWorkingGroupOpeningByIdQuery, UpcomingWorkingGroupOpeningByIdQueryVariables>(
+      UpcomingWorkingGroupOpeningById,
+      { id },
+      'upcomingWorkingGroupOpeningByUniqueInput'
+    )
+  }
+
+  async upcomingWorkingGroupOpeningsByGroup(
+    group: WorkingGroups
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment[]> {
+    return this.multipleEntitiesQuery<
+      UpcomingWorkingGroupOpeningsByGroupQuery,
+      UpcomingWorkingGroupOpeningsByGroupQueryVariables
+    >(UpcomingWorkingGroupOpeningsByGroup, { workingGroupId: apiModuleByGroup[group] }, 'upcomingWorkingGroupOpenings')
+  }
 }

+ 55 - 7
cli/src/Types.ts

@@ -1,5 +1,5 @@
 import BN from 'bn.js'
-import { Codec } from '@polkadot/types/types'
+import { Codec, IEvent } from '@polkadot/types/types'
 import { Balance, AccountId } from '@polkadot/types/interfaces'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { KeyringPair } from '@polkadot/keyring/types'
@@ -8,16 +8,27 @@ import { Membership } from '@joystream/types/members'
 import { MemberId } from '@joystream/types/common'
 import { Validator } from 'inquirer'
 import { ApiPromise } from '@polkadot/api'
-import { SubmittableModuleExtrinsics, QueryableModuleStorage, QueryableModuleConsts } from '@polkadot/api/types'
+import {
+  SubmittableModuleExtrinsics,
+  QueryableModuleStorage,
+  QueryableModuleConsts,
+  AugmentedEvent,
+} from '@polkadot/api/types'
 import { JSONSchema4 } from 'json-schema'
 import {
   IChannelMetadata,
   IVideoMetadata,
   IVideoCategoryMetadata,
   IChannelCategoryMetadata,
+  IOpeningMetadata,
+  IWorkingGroupMetadata,
 } from '@joystream/metadata-protobuf'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
-import { MembershipFieldsFragment } from './graphql/generated/queries'
+import {
+  MembershipFieldsFragment,
+  WorkingGroupApplicationDetailsFragment,
+  WorkingGroupOpeningDetailsFragment,
+} from './graphql/generated/queries'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -87,6 +98,7 @@ export type ApplicationDetails = {
   rewardAccount: AccountId
   descriptionHash: string
   openingId: number
+  answers?: WorkingGroupApplicationDetailsFragment['answers']
 }
 
 export type OpeningDetails = {
@@ -99,6 +111,7 @@ export type OpeningDetails = {
   type: OpeningType
   createdAtBlock: number
   rewardPerBlock?: Balance
+  metadata?: WorkingGroupOpeningDetailsFragment['metadata']
 }
 
 // Extended membership information (including optional query node data)
@@ -140,6 +153,23 @@ export type UnaugmentedApiPromise = Omit<ApiPromise, 'query' | 'tx' | 'consts'>
   consts: { [key: string]: QueryableModuleConsts }
 }
 
+// Event-related types
+export type EventSection = keyof ApiPromise['events'] & string
+export type EventMethod<Section extends EventSection> = keyof ApiPromise['events'][Section] & string
+export type EventType<
+  Section extends EventSection,
+  Method extends EventMethod<Section>
+> = ApiPromise['events'][Section][Method] extends AugmentedEvent<'promise', infer T> ? IEvent<T> & Codec : never
+
+export type EventDetails<E> = {
+  event: E
+  blockNumber: number
+  blockHash: string
+  blockTimestamp: number
+  indexInBlock: number
+}
+
+// Storage
 export type AssetToUpload = {
   dataObjectId: BN
   path: string
@@ -184,7 +214,21 @@ export type ChannelCategoryInputParameters = IChannelCategoryMetadata
 
 export type VideoCategoryInputParameters = IVideoCategoryMetadata
 
-type AnyNonObject = string | number | boolean | any[] | Long
+export type WorkingGroupOpeningInputParameters = Omit<IOpeningMetadata, 'applicationFormQuestions'> & {
+  stakingPolicy: {
+    amount: number
+    unstakingPeriod: number
+  }
+  rewardPerBlock?: number
+  applicationFormQuestions?: {
+    question: string
+    type: 'TEXTAREA' | 'TEXT'
+  }[]
+}
+
+export type WorkingGroupUpdateStatusInputParameters = IWorkingGroupMetadata
+
+type AnyPrimitive = string | number | boolean | Long
 
 // JSONSchema utility types
 
@@ -198,18 +242,22 @@ type AnyJSONSchema = RemoveIndex<JSONSchema4>
 export type JSONTypeName<T> = T extends string
   ? 'string' | ['string', 'null']
   : T extends number
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : T extends boolean
   ? 'boolean' | ['boolean', 'null']
   : T extends any[]
   ? 'array' | ['array', 'null']
   : T extends Long
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : 'object' | ['object', 'null']
 
 export type PropertySchema<P> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
   type: JSONTypeName<P>
-} & (P extends AnyNonObject ? { properties?: never } : { properties: JsonSchemaProperties<P> })
+} & (P extends AnyPrimitive
+    ? { properties?: never }
+    : P extends (infer T)[]
+    ? { properties?: never; items: PropertySchema<T> }
+    : { properties: JsonSchemaProperties<P> })
 
 export type JsonSchemaProperties<T> = {
   [K in keyof Required<T>]: PropertySchema<Required<T>[K]>

+ 14 - 5
cli/src/base/AccountsCommandBase.ts

@@ -18,6 +18,7 @@ import { mnemonicGenerate } from '@polkadot/util-crypto'
 import { validateAddress } from '../helpers/validation'
 import slug from 'slug'
 import { Membership } from '@joystream/types/members'
+import { LockIdentifier } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
 
 const ACCOUNTS_DIRNAME = 'accounts'
@@ -333,7 +334,8 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     member: Membership,
     address?: string,
     requiredStake: BN = new BN(0),
-    fundsSource?: string
+    fundsSource?: string,
+    lockId?: LockIdentifier
   ): Promise<string> {
     if (fundsSource && !this.isKeyAvailable(fundsSource)) {
       throw new CLIError(`Key ${chalk.magentaBright(fundsSource)} is not available!`)
@@ -345,8 +347,10 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     const { balances } = await this.getApi().getAccountSummary(address)
     const stakingStatus = await this.getApi().stakingAccountStatus(address)
 
-    if (balances.lockedBalance.gtn(0)) {
-      throw new CLIError('This account is already used for other staking purposes, choose a different account...')
+    if (lockId && !this.getApi().areAccountLocksCompatibleWith(address, lockId)) {
+      throw new CLIError(
+        'This account is already used for other, incompatible staking purposes. Choose a different account...'
+      )
     }
 
     if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
@@ -423,12 +427,17 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return address
   }
 
-  async promptForStakingAccount(requiredStake: BN, memberId: MemberId, member: Membership): Promise<string> {
+  async promptForStakingAccount(
+    requiredStake: BN,
+    memberId: MemberId,
+    member: Membership,
+    lockId?: LockIdentifier
+  ): Promise<string> {
     this.log(`Required stake: ${formatBalance(requiredStake)}`)
     while (true) {
       const stakingAccount = await this.promptForAnyAddress('Choose staking account')
       try {
-        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake)
+        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake, undefined, lockId)
         return stakingAccount
       } catch (e) {
         if (e instanceof CLIError) {

+ 61 - 20
cli/src/base/ApiCommandBase.ts

@@ -2,21 +2,32 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
+import {
+  EventSection,
+  EventMethod,
+  EventType,
+  EventDetails,
+  ApiMethodArg,
+  ApiMethodNamedArgs,
+  ApiParamsOptions,
+  ApiParamOptions,
+  UnaugmentedApiPromise,
+} from '../Types'
 import { getTypeDef, Option, Tuple } from '@polkadot/types'
-import { Registry, Codec, TypeDef, TypeDefInfo, IEvent, DetectCodec } from '@polkadot/types/types'
+import { Registry, Codec, TypeDef, TypeDefInfo, DetectCodec, ISubmittableResult } from '@polkadot/types/types'
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
 import { SubmittableResult, WsProvider, ApiPromise } from '@polkadot/api'
 import { KeyringPair } from '@polkadot/keyring/types'
 import chalk from 'chalk'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import { ApiMethodArg, ApiMethodNamedArgs, ApiParamsOptions, ApiParamOptions, UnaugmentedApiPromise } from '../Types'
 import { createParamOptions } from '../helpers/promptOptions'
-import { AugmentedSubmittables, SubmittableExtrinsic, AugmentedEvents, AugmentedEvent } from '@polkadot/api/types'
+import { AugmentedSubmittables, SubmittableExtrinsic } from '@polkadot/api/types'
 import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import QueryNodeApi from '../QueryNodeApi'
 import { formatBalance } from '@polkadot/util'
+import cli from 'cli-ux'
 import BN from 'bn.js'
 import _ from 'lodash'
 
@@ -93,13 +104,18 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
         queryNodeUri = await this.promptForQueryNodeUri()
       }
-      this.queryNodeApi = queryNodeUri
-        ? new QueryNodeApi(queryNodeUri, (err) => {
-            this.warn(`Query node error: ${err.networkError?.message || err.graphQLErrors?.join('\n')}`)
-          })
-        : null
+      if (queryNodeUri) {
+        cli.action.start(`Initializing the query node connection (${queryNodeUri})...`)
+        this.queryNodeApi = new QueryNodeApi(queryNodeUri, (err) => {
+          this.warn(`Query node error: ${err.networkError?.message || err.graphQLErrors?.join('\n')}`)
+        })
+        cli.action.stop()
+      } else {
+        this.queryNodeApi = null
+      }
 
       // Substrate api
+      cli.action.start(`Initializing the api connection (${apiUri})...`)
       const { metadataCache } = this.getPreservedState()
       this.api = await Api.create(apiUri, metadataCache, this.queryNodeApi || undefined)
 
@@ -110,6 +126,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
         await this.setPreservedState({ metadataCache })
       }
+      cli.action.stop()
     }
   }
 
@@ -562,26 +579,50 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return this.sendAndFollowTx(account, tx)
   }
 
-  public findEvent<
-    S extends keyof AugmentedEvents<'promise'> & string,
-    M extends keyof AugmentedEvents<'promise'>[S] & string,
-    EventType = AugmentedEvents<'promise'>[S][M] extends AugmentedEvent<'promise', infer T> ? IEvent<T> : never
-  >(result: SubmittableResult, section: S, method: M): EventType | undefined {
-    return result.findRecord(section, method)?.event as EventType | undefined
+  public findEvent<S extends EventSection, M extends EventMethod<S>, E = EventType<S, M>>(
+    result: SubmittableResult,
+    section: S,
+    method: M
+  ): E | undefined {
+    return result.findRecord(section, method)?.event as E | undefined
   }
 
-  public getEvent<
-    S extends keyof AugmentedEvents<'promise'> & string,
-    M extends keyof AugmentedEvents<'promise'>[S] & string,
-    EventType = AugmentedEvents<'promise'>[S][M] extends AugmentedEvent<'promise', infer T> ? IEvent<T> : never
-  >(result: SubmittableResult, section: S, method: M): EventType {
-    const event = this.findEvent<S, M, EventType>(result, section, method)
+  public getEvent<S extends EventSection, M extends EventMethod<S>, E = EventType<S, M>>(
+    result: SubmittableResult,
+    section: S,
+    method: M
+  ): E {
+    const event = this.findEvent<S, M, E>(result, section, method)
     if (!event) {
       throw new Error(`Event ${section}.${method} not found in tx result: ${JSON.stringify(result.toHuman())}`)
     }
     return event
   }
 
+  async getEventDetails<S extends EventSection, M extends EventMethod<S>>(
+    result: ISubmittableResult,
+    section: S,
+    method: M
+  ): Promise<EventDetails<EventType<S, M>>> {
+    const api = this.getOriginalApi()
+    const { status } = result
+    const event = this.getEvent(result, section, method)
+
+    const blockHash = (status.isInBlock ? status.asInBlock : status.asFinalized).toString()
+    const blockNumber = (await api.rpc.chain.getHeader(blockHash)).number.toNumber()
+    const blockTimestamp = (await api.query.timestamp.now.at(blockHash)).toNumber()
+    const blockEvents = await api.query.system.events.at(blockHash)
+    const indexInBlock = blockEvents.findIndex(({ event: blockEvent }) => blockEvent.hash.eq(event.hash))
+
+    return {
+      event,
+      blockNumber,
+      blockHash,
+      blockTimestamp,
+      indexInBlock,
+    }
+  }
+
   async buildAndSendExtrinsic<
     Module extends keyof AugmentedSubmittables<'promise'>,
     Method extends keyof AugmentedSubmittables<'promise'>[Module] & string

+ 165 - 0
cli/src/base/ForumCommandBase.ts

@@ -0,0 +1,165 @@
+import { Category, CategoryId, Post, PrivilegedActor, Thread } from '@joystream/types/forum'
+import { WorkingGroups } from '../Types'
+import { flags } from '@oclif/command'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../ExitCodes'
+import { createType } from '@joystream/types'
+import { AccountId, PostId, ThreadId } from '@joystream/types/common'
+import { WorkerId } from '@joystream/types/working-group'
+
+const FORUM_MODERATION_CONTEXT = ['Leader', 'Moderator'] as const
+
+type ForumModerationContext = typeof FORUM_MODERATION_CONTEXT[number]
+
+/**
+ * Abstract base class for commands related to forum management
+ */
+export default abstract class ForumCommandBase extends WorkingGroupCommandBase {
+  static flags = {
+    ...WorkingGroupCommandBase.flags,
+  }
+
+  static forumModerationContextFlag = flags.enum({
+    required: false,
+    description: `Actor context to execute the command in (${FORUM_MODERATION_CONTEXT.join('/')})`,
+    options: [...FORUM_MODERATION_CONTEXT],
+  })
+
+  async init(): Promise<void> {
+    await super.init()
+    this._group = WorkingGroups.Forum // override group for RolesCommandBase
+  }
+
+  async ensureCategoryExists(categoryId: CategoryId | number): Promise<void> {
+    const categoryExists = await this.getApi().forumCategoryExists(categoryId)
+    if (!categoryExists) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureCategoryMutable(categoryId: CategoryId | number): Promise<void> {
+    const category = await this.getCategory(categoryId)
+    const ancestors = await this.getApi().forumCategoryAncestors(categoryId)
+    if (category.archived.isTrue || ancestors.some(([, category]) => category.archived.isTrue)) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} is not mutable (belongs to archived category tree)!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<void> {
+    const threadExists = await this.getApi().forumThreadExists(categoryId, threadId)
+    if (!threadExists) {
+      this.error(
+        `Thread ${chalk.magentaBright(threadId)} in category ${chalk.magentaBright(categoryId)} does not exist!`,
+        {
+          exit: ExitCodes.InvalidInput,
+        }
+      )
+    }
+  }
+
+  async ensurePostExists(threadId: ThreadId | number, postId: PostId | number): Promise<void> {
+    const postExists = await this.getApi().forumPostExists(threadId, postId)
+    if (!postExists) {
+      this.error(`Post ${chalk.magentaBright(postId)} in thread ${chalk.magentaBright(threadId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async getThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    await this.ensureThreadExists(categoryId, threadId)
+    const thread = await this.getApi().getForumThread(categoryId, threadId)
+    return thread
+  }
+
+  async getCategory(categoryId: CategoryId | number): Promise<Category> {
+    await this.ensureCategoryExists(categoryId)
+    const category = await this.getApi().getForumCategory(categoryId)
+    return category
+  }
+
+  async getPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    await this.ensurePostExists(threadId, postId)
+    const post = await this.getApi().getForumPost(threadId, postId)
+    return post
+  }
+
+  async getIdsOfModeratorsWithAccessToCategories(categories: CategoryId[] | number[]): Promise<WorkerId[]> {
+    const moderatorsByCategory = await Promise.all(categories.map((id) => this.getApi().forumCategoryModerators(id)))
+    const categoriesCountByModeratorId = new Map<number, number>()
+    for (const moderators of moderatorsByCategory) {
+      for (const [, id] of moderators) {
+        categoriesCountByModeratorId.set(id.toNumber(), (categoriesCountByModeratorId.get(id.toNumber()) || 0) + 1)
+      }
+    }
+    return Array.from(categoriesCountByModeratorId.entries())
+      .filter(([, count]) => count === categories.length)
+      .map(([id]) => createType<WorkerId, 'WorkerId'>('WorkerId', id))
+  }
+
+  async getForumModeratorContext(categories: CategoryId[] | number[]): Promise<[AccountId, PrivilegedActor]> {
+    const moderators = await this.getIdsOfModeratorsWithAccessToCategories(categories)
+    try {
+      const worker = await this.getRequiredWorkerContext('Role', moderators)
+      return [
+        worker.roleAccount,
+        createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', { Moderator: worker.workerId }),
+      ]
+    } catch (e) {
+      this.error(
+        `Moderator access to categories: ${categories
+          .map((id) => chalk.magentaBright(id.toString()))
+          .join(', ')} is required!`,
+        { exit: ExitCodes.AccessDenied }
+      )
+    }
+  }
+
+  async getForumLeadContext(): Promise<[AccountId, PrivilegedActor]> {
+    const lead = await this.getRequiredLeadContext()
+    return [lead.roleAccount, createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', 'Lead')]
+  }
+
+  async getForumModerationContext(
+    categories: CategoryId[] | number[],
+    context?: ForumModerationContext
+  ): Promise<[AccountId, PrivilegedActor]> {
+    if (context === 'Leader') {
+      return this.getForumLeadContext()
+    }
+
+    if (context === 'Moderator') {
+      return this.getForumModeratorContext(categories)
+    }
+
+    // Context not explicitly set...
+
+    try {
+      const context = await this.getForumLeadContext()
+      this.log('Derived context: Forum Working Group Leader')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    try {
+      const context = await this.getForumModeratorContext(categories)
+      this.log('Derived context: Moderator')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    this.error(
+      `You need moderator permissions for forum categories: ${categories
+        .map((id) => chalk.magentaBright(id.toString()))
+        .join(', ')} in order to continue!`,
+      { exit: ExitCodes.AccessDenied }
+    )
+  }
+}

+ 20 - 5
cli/src/base/WorkingGroupCommandBase.ts

@@ -3,6 +3,7 @@ import { flags } from '@oclif/command'
 import { WorkingGroups, GroupMember } from '../Types'
 import _ from 'lodash'
 import MembershipsCommandBase from './MembershipsCommandBase'
+import { WorkerId } from '@joystream/types/working-group'
 
 /**
  * Abstract base class for commands relying on a specific working group context
@@ -46,20 +47,30 @@ export default abstract class WorkingGroupCommandBase extends MembershipsCommand
   }
 
   // Use when worker access is required in given command
-  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
+  async getRequiredWorkerContext(
+    expectedKeyType: 'Role' | 'MemberController' = 'Role',
+    allowedIds?: WorkerId[]
+  ): Promise<GroupMember> {
     const flags = this.parse(this.constructor as typeof WorkingGroupCommandBase).flags
 
     const groupMembers = await this.getApi().groupMembers(this.group)
-    const availableGroupMemberContexts = groupMembers.filter((m) =>
+    const allowedGroupMembers = groupMembers.filter((m) => !allowedIds || allowedIds.some((id) => id.eq(m.workerId)))
+
+    const availableGroupMemberContexts = allowedGroupMembers.filter((m) =>
       expectedKeyType === 'Role'
         ? this.isKeyAvailable(m.roleAccount.toString())
         : this.isKeyAvailable(m.profile.membership.controller_account.toString())
     )
 
     if (!availableGroupMemberContexts.length) {
-      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
-        exit: ExitCodes.AccessDenied,
-      })
+      this.error(
+        `No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key ${
+          allowedIds ? ` (from the allowed set of workers: ${allowedIds.map((id) => id.toString())})` : ''
+        } available!`,
+        {
+          exit: ExitCodes.AccessDenied,
+        }
+      )
     } else if (availableGroupMemberContexts.length === 1) {
       return availableGroupMemberContexts[0]
     } else {
@@ -86,4 +97,8 @@ export default abstract class WorkingGroupCommandBase extends MembershipsCommand
 
     return groupMembers[chosenWorkerIndex]
   }
+
+  async ensureWorkerExists(workerId: WorkerId | number): Promise<void> {
+    await this.getApi().workerByWorkerId(this.group, workerId)
+  }
 }

+ 2 - 1
cli/src/commands/account/info.ts

@@ -5,6 +5,7 @@ import { NameValueObj } from '../../Types'
 import { displayHeader, displayNameValueTable } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
 import moment from 'moment'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class AccountInfo extends AccountsCommandBase {
   static description = 'Display detailed information about specified account'
@@ -31,7 +32,7 @@ export default class AccountInfo extends AccountsCommandBase {
       accountRows.push({ name: 'Account name', value: pair.meta.name })
       accountRows.push({ name: 'Type', value: pair.type })
       const creationDate = pair.meta.whenCreated
-        ? moment(pair.meta.whenCreated as string | number).format('YYYY-MM-DD HH:mm:ss')
+        ? moment(pair.meta.whenCreated as string | number).format(DEFAULT_DATE_FORMAT)
         : null
       if (creationDate) {
         accountRows.push({ name: 'Creation date', value: creationDate })

+ 52 - 0
cli/src/commands/forum/addPost.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumPostMetadata, IForumPostMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { PostId } from '@joystream/types/common'
+
+export default class ForumAddPostCommand extends ForumCommandBase {
+  static description = 'Add forum post.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category of the parent thread',
+    }),
+    threadId: flags.integer({
+      required: true,
+      description: "Post's parent thread",
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Post content (md-formatted text)',
+    }),
+    editable: flags.boolean({
+      required: false,
+      description: 'Whether the post should be editable',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, text, editable } = this.parse(ForumAddPostCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    // Replies not supported atm
+    const metadata: IForumPostMetadata = { text }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, text, editable }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      api.tx.forum.addPost(member.id, categoryId, threadId, metadataToBytes(ForumPostMetadata, metadata), editable)
+    )
+
+    const postId: PostId = this.getEvent(result, 'forum', 'PostAdded').data[0]
+    this.log(chalk.green(`ForumPost with id ${chalk.magentaBright(postId.toString())} successfully created!`))
+    this.output(postId.toString())
+  }
+}

+ 91 - 0
cli/src/commands/forum/categories.ts

@@ -0,0 +1,91 @@
+import { Category, CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import { cli } from 'cli-ux'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Tree } from 'cli-ux/lib/styled/tree'
+import { displayTable } from '../../helpers/display'
+
+export default class ForumCategoriesCommand extends ForumCommandBase {
+  static description =
+    'List existing forum categories by parent id (root categories by default) or displays a category tree.'
+
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (only child categories will be listed)',
+    }),
+    tree: flags.boolean({
+      char: 'c',
+      required: false,
+      description: 'Display a category tree (with parentCategoryId as root, if specified)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  recursivelyGenerateCategoryTree(
+    tree: Tree,
+    parents: [CategoryId, Category][],
+    allCategories: [CategoryId, Category][]
+  ): void {
+    for (const [parentId] of parents) {
+      const children = allCategories.filter(([, c]) => c.parent_category_id.unwrapOr(undefined)?.eq(parentId))
+      const childSubtree = cli.tree()
+      this.recursivelyGenerateCategoryTree(childSubtree, children, allCategories)
+      tree.insert(parentId.toString(), childSubtree)
+    }
+  }
+
+  buildCategoryTree(categories: [CategoryId, Category][], root?: number): Tree {
+    const tree = cli.tree()
+    let rootCategory: [CategoryId, Category] | undefined
+    if (root) {
+      rootCategory = categories.find(([id]) => id.toNumber() === root)
+      if (!rootCategory) {
+        this.error(`Category ${chalk.magentaBright(root)} not found!`)
+      }
+    }
+    const treeRootCategories = rootCategory ? [rootCategory] : categories.filter(([, c]) => c.parent_category_id.isNone)
+    this.recursivelyGenerateCategoryTree(tree, treeRootCategories, categories)
+    return tree
+  }
+
+  async run(): Promise<void> {
+    const { parentCategoryId, tree } = this.parse(ForumCategoriesCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryExists(parentCategoryId)
+    }
+
+    const categories = await this.getApi().forumCategories()
+
+    if (tree) {
+      const categoryTree = this.buildCategoryTree(categories, parentCategoryId)
+      categoryTree.display()
+    } else {
+      const children = categories.filter(
+        ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === parentCategoryId
+      )
+      if (children.length) {
+        displayTable(
+          children.map(([id, c]) => ({
+            'ID': id.toString(),
+            'Direct subcategories': c.num_direct_subcategories.toNumber(),
+            'Direct threads': c.num_direct_threads.toNumber(),
+            'Direct modreators': c.num_direct_moderators.toNumber(),
+          })),
+          5
+        )
+      } else {
+        this.log(
+          `No ${
+            parentCategoryId !== undefined
+              ? `subcategories of category ${chalk.magentaBright(parentCategoryId)}`
+              : 'root categories'
+          } found`
+        )
+      }
+    }
+  }
+}

+ 65 - 0
cli/src/commands/forum/category.ts

@@ -0,0 +1,65 @@
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayCollapsedRow, displayHeader, displayTable, memberHandle } from '../../helpers/display'
+import { GroupMember } from '../../Types'
+
+export default class ForumCategoryCommand extends ForumCommandBase {
+  static description = 'Display forum category details.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumCategoryCommand).flags
+    const category = await this.getCategory(categoryId)
+    const allCategories = await this.getApi().forumCategories()
+    const directSubcategories = allCategories.filter(
+      ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === categoryId
+    )
+    const moderatorsEntries = await this.getApi().forumCategoryModerators(categoryId)
+    const moderators = await Promise.all(
+      moderatorsEntries.map(
+        async ([categoryId, workerId]) =>
+          [categoryId, await this.getApi().groupMember(this.group, workerId.toNumber())] as [CategoryId, GroupMember]
+      )
+    )
+
+    displayCollapsedRow({
+      'ID': categoryId.toString(),
+      'No. direct subcategories': category.num_direct_subcategories.toString(),
+      'No. direct threads': category.num_direct_threads.toString(),
+      'No. direct moderators': category.num_direct_moderators.toString(),
+    })
+
+    displayHeader('Stickied threads')
+    if (category.sticky_thread_ids.length) {
+      this.log(category.sticky_thread_ids.map((id) => chalk.magentaBright(id.toString())).join(', '))
+    } else {
+      this.log('No stickied threads')
+    }
+
+    displayHeader('Direct subcategories')
+    this.log(directSubcategories.map(([id]) => chalk.magentaBright(id.toString())).join(', '))
+
+    displayHeader('Moderators')
+    if (moderators.length) {
+      displayTable(
+        moderators.map(([cId, moderator]) => ({
+          'Worker ID': moderator.workerId.toString(),
+          'Member Handle': memberHandle(moderator.profile),
+          'Access': cId.eq(categoryId) ? 'Direct' : `Ancestor (${cId.toString()})`,
+        })),
+        5
+      )
+    } else {
+      this.log('No moderators')
+    }
+  }
+}

+ 55 - 0
cli/src/commands/forum/createCategory.ts

@@ -0,0 +1,55 @@
+import { createType } from '@joystream/types'
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Option } from '@polkadot/types'
+
+export default class ForumCreateCategoryCommand extends ForumCommandBase {
+  static description = 'Create forum category.'
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (in case of creating a subcategory)',
+    }),
+    title: flags.string({
+      char: 't',
+      required: true,
+      description: 'Category title',
+    }),
+    description: flags.string({
+      char: 'd',
+      required: true,
+      description: 'Category description',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { parentCategoryId, title, description } = this.parse(ForumCreateCategoryCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryMutable(parentCategoryId)
+    }
+
+    const lead = await this.getRequiredLeadContext()
+
+    this.jsonPrettyPrint(JSON.stringify({ parentCategoryId, title, description }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.createCategory(
+        createType<Option<CategoryId>, 'Option<CategoryId>'>('Option<CategoryId>', parentCategoryId ?? null),
+        title,
+        description
+      )
+    )
+
+    const categoryId: CategoryId = this.getEvent(result, 'forum', 'CategoryCreated').data[0]
+    this.log(chalk.green(`ForumCategory with id ${chalk.magentaBright(categoryId.toString())} successfully created!`))
+    this.output(categoryId.toString())
+  }
+}

+ 52 - 0
cli/src/commands/forum/createThread.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { ThreadId } from '@joystream/types/common'
+
+export default class ForumCreateThreadCommand extends ForumCommandBase {
+  static description = 'Create forum thread.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category the thread should be created in',
+    }),
+    title: flags.string({
+      required: true,
+      description: 'Thread title',
+    }),
+    tags: flags.string({
+      required: false,
+      multiple: true,
+      description: 'Space-separated tags to associate with the thread',
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Initial post text',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, title, tags, text } = this.parse(ForumCreateThreadCommand).flags
+
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    const metadata: IForumThreadMetadata = { title, tags }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, metadata }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      // Polls not supported atm
+      api.tx.forum.createThread(member.id, categoryId, metadataToBytes(ForumThreadMetadata, metadata), text, null)
+    )
+
+    const threadId: ThreadId = this.getEvent(result, 'forum', 'ThreadCreated').data[1]
+    this.log(chalk.green(`ForumThread with id ${chalk.magentaBright(threadId.toString())} successfully created!`))
+    this.output(threadId.toString())
+  }
+}

+ 53 - 0
cli/src/commands/forum/deleteCategory.ts

@@ -0,0 +1,53 @@
+import { AccountId } from '@joystream/types/common'
+import { PrivilegedActor } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import ExitCodes from '../../ExitCodes'
+
+export default class ForumDeleteCategoryCommand extends ForumCommandBase {
+  static description = 'Delete forum category provided it has no existing subcategories and threads.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the category to delete',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, context } = this.parse(ForumDeleteCategoryCommand).flags
+
+    const category = await this.getCategory(categoryId)
+    let key: AccountId, actor: PrivilegedActor
+
+    if (category.parent_category_id.isNone) {
+      if (context === 'Moderator') {
+        this.error('Moderator cannot delete root categories!', { exit: ExitCodes.AccessDenied })
+      }
+      ;[key, actor] = await this.getForumLeadContext()
+    } else {
+      ;[key, actor] = await this.getForumModerationContext([category.parent_category_id.unwrap()], context)
+    }
+
+    if (category.num_direct_subcategories.gtn(0)) {
+      this.error('Cannot remove a category with existing subcategories!', { exit: ExitCodes.InvalidInput })
+    }
+
+    if (category.num_direct_threads.gtn(0)) {
+      this.error('Cannot remove a category with existing threads!', { exit: ExitCodes.InvalidInput })
+    }
+
+    await this.requireConfirmation(
+      `Are you sure you want to remove forum category ${chalk.magentaBright(categoryId)}?`,
+      true
+    )
+
+    await this.sendAndFollowTx(await this.getDecodedPair(key), api.tx.forum.deleteCategory(actor, categoryId))
+
+    this.log(chalk.green(`Forum category ${chalk.magentaBright(categoryId)} successfully removed!`))
+  }
+}

+ 54 - 0
cli/src/commands/forum/moderatePost.ts

@@ -0,0 +1,54 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModeratePostCommand extends ForumCommandBase {
+  static description = 'Moderate a forum post and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    postId: flags.integer({
+      char: 'p',
+      required: true,
+      description: 'Forum post id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the post moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, postId, rationale, context } = this.parse(ForumModeratePostCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureCategoryMutable(categoryId)
+    await this.ensureThreadExists(categoryId, threadId)
+    const post = await this.getPost(threadId, postId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, postId, rationale }))
+    this.warn(`Post stake of ${formatBalance(post.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderatePost(actor, categoryId, threadId, postId, rationale)
+    )
+
+    this.log(chalk.green(`Post ${chalk.magentaBright(postId.toString())} successfully moderated!`))
+  }
+}

+ 46 - 0
cli/src/commands/forum/moderateThread.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModerateThreadCommand extends ForumCommandBase {
+  static description = 'Moderate a forum thread and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the forum category the thread is currently in',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the thread moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, context, rationale } = this.parse(ForumModerateThreadCommand).flags
+
+    const thread = await this.getThread(categoryId, threadId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, rationale }))
+    this.warn(`Thread stake of ${formatBalance(thread.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderateThread(actor, categoryId, threadId, rationale)
+    )
+
+    this.log(chalk.green(`Thread ${chalk.magentaBright(threadId.toString())} successfully moderated!`))
+  }
+}

+ 51 - 0
cli/src/commands/forum/moveThread.ts

@@ -0,0 +1,51 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumMoveThreadCommand extends ForumCommandBase {
+  static description = 'Move forum thread to a different category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: "Thread's current category id",
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    newCategoryId: flags.integer({
+      char: 'n',
+      required: true,
+      description: "Thread's new category id",
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, newCategoryId, threadId, context } = this.parse(ForumMoveThreadCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryExists(newCategoryId)
+    const [key, actor] = await this.getForumModerationContext([categoryId, newCategoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ threadId, categoryId, newCategoryId }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moveThreadToCategory(actor, categoryId, threadId, newCategoryId)
+    )
+
+    this.log(
+      chalk.green(
+        `Thread ${chalk.magentaBright(threadId.toString())} successfully moved from category ${chalk.magentaBright(
+          categoryId.toString()
+        )} to category ${chalk.magentaBright(newCategoryId.toString())}!`
+      )
+    )
+  }
+}

+ 37 - 0
cli/src/commands/forum/posts.ts

@@ -0,0 +1,37 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumPostsCommand extends ForumCommandBase {
+  static description = 'List existing forum posts in given thread.'
+  static flags = {
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Thread id (only posts in this thread will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { threadId } = this.parse(ForumPostsCommand).flags
+
+    const posts = await this.getApi().forumPosts(threadId)
+
+    if (posts.length) {
+      displayTable(
+        posts.map(([id, p]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(p.cleanup_pay_off),
+          'Author member id': p.author_id.toString(),
+          'Last edited': `#${p.last_edited.toNumber()}`,
+        })),
+        5
+      )
+    } else {
+      this.log(`No posts in thread ${chalk.magentaBright(threadId)} found`)
+    }
+  }
+}

+ 46 - 0
cli/src/commands/forum/setStickiedThreads.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumSetStickiedThreadsCommand extends ForumCommandBase {
+  static description = 'Set stickied threads in a given category.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadIds: flags.integer({
+      required: false,
+      multiple: true,
+      description: 'Space-separated thread ids',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadIds, context } = this.parse(ForumSetStickiedThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await Promise.all((threadIds || []).map((threadId) => this.ensureThreadExists(categoryId, threadId)))
+
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadIds }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.setStickiedThreads(actor, categoryId, threadIds)
+    )
+
+    this.log(
+      chalk.green(
+        `Threads ${chalk.magentaBright(
+          threadIds.map((id) => chalk.magentaBright(id)).join(', ')
+        )} successfully set as stickied in category ${categoryId}!`
+      )
+    )
+  }
+}

+ 38 - 0
cli/src/commands/forum/threads.ts

@@ -0,0 +1,38 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumThreadsCommand extends ForumCommandBase {
+  static description = 'List existing forum threads in given category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Category id (only threads in this category will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    const threads = await this.getApi().forumThreads(categoryId)
+
+    if (threads.length) {
+      displayTable(
+        threads.map(([id, t]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(t.cleanup_pay_off),
+          'Author member id': t.author_id.toString(),
+          'No. posts': t.number_of_posts.toString(),
+        })),
+        5
+      )
+    } else {
+      this.log(`No threads in category ${chalk.magentaBright(categoryId)} found`)
+    }
+  }
+}

+ 49 - 0
cli/src/commands/forum/updateCategoryArchivalStatus.ts

@@ -0,0 +1,49 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryArchivalStatusCommand extends ForumCommandBase {
+  static description = 'Update archival status of a forum category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    archived: flags.enum<'yes' | 'no'>({
+      options: ['yes', 'no'],
+      required: true,
+      description: 'Whether the category should be archived',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, archived, context } = this.parse(ForumUpdateCategoryArchivalStatusCommand).flags
+    const category = await this.getCategory(categoryId)
+    if (category.archived.isTrue === (archived === 'yes')) {
+      this.error(
+        `Category ${chalk.magentaBright(categoryId.toString())} is already ${archived === 'no' ? 'not ' : ''}archived!`
+      )
+    }
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, archived }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.updateCategoryArchivalStatus(actor, categoryId, archived === 'yes')
+    )
+
+    this.log(
+      chalk.green(
+        `Archival status of category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully updated to: ${chalk.magentaBright(archived === 'yes' ? 'archived' : 'not archived')}!`
+      )
+    )
+  }
+}

+ 50 - 0
cli/src/commands/forum/updateCategoryModeratorStatus.ts

@@ -0,0 +1,50 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryModeratorStatusCommand extends ForumCommandBase {
+  static description = 'Update moderator status of a worker in relation to a category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    workerId: flags.integer({
+      char: 'w',
+      required: true,
+      description: 'Forum working group worker id',
+    }),
+    status: flags.enum<'active' | 'disabled'>({
+      options: ['active', 'disabled'],
+      required: true,
+      description: 'Status of the moderator membership in the category',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, workerId, status } = this.parse(ForumUpdateCategoryModeratorStatusCommand).flags
+    const lead = await this.getRequiredLeadContext()
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureWorkerExists(workerId)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, workerId, status }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.updateCategoryMembershipOfModerator(workerId, categoryId, status === 'active')
+    )
+
+    this.log(
+      chalk.green(
+        `Worker ${chalk.magentaBright(workerId.toString())} moderation permissions for category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully ${status === 'active' ? 'granted' : 'revoked'}`
+      )
+    )
+  }
+}

+ 10 - 2
cli/src/commands/working-groups/application.ts

@@ -1,5 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
+import chalk from 'chalk'
 
 export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
   static description = 'Shows an overview of given application by Working Group Application ID'
@@ -23,13 +24,20 @@ export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
     displayHeader(`Details`)
     const applicationRow = {
       'Application ID': application.applicationId,
+      'Opening ID': application.openingId.toString(),
       'Member handle': memberHandle(application.member),
       'Role account': application.roleAccout.toString(),
       'Reward account': application.rewardAccount.toString(),
       'Staking account': application.stakingAccount.toString(),
-      'Description': application.descriptionHash.toString(),
-      'Opening ID': application.openingId.toString(),
     }
     displayCollapsedRow(applicationRow)
+
+    if (application.answers) {
+      displayHeader(`Application form`)
+      application.answers?.forEach((a) => {
+        this.log(chalk.bold(a.question.question))
+        this.log(a.answer)
+      })
+    }
   }
 }

+ 86 - 23
cli/src/commands/working-groups/apply.ts

@@ -2,38 +2,74 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { Option } from '@polkadot/types'
 import { apiModuleByGroup } from '../../Api'
 import { CreateInterface } from '@joystream/types'
-import { StakeParameters } from '@joystream/types/working-group'
+import { ApplicationId, StakeParameters } from '@joystream/types/working-group'
+import { flags } from '@oclif/command'
+import ExitCodes from '../../ExitCodes'
+import { metadataToBytes } from '../../helpers/serialization'
+import { ApplicationMetadata } from '@joystream/metadata-protobuf'
+import chalk from 'chalk'
 
 export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
   static description = 'Apply to a working group opening (requires a membership)'
-  static args = [
-    {
-      name: 'openingId',
-      description: 'Opening ID',
-      required: false,
-    },
-  ]
 
   static flags = {
+    openingId: flags.integer({
+      description: 'Opening ID',
+      required: true,
+    }),
+    roleAccount: flags.string({
+      description: 'Future worker role account',
+      required: false,
+    }),
+    rewardAccount: flags.string({
+      description: 'Future worker reward account',
+      required: false,
+    }),
+    stakingAccount: flags.string({
+      description: "Account to hold applicant's / worker's stake",
+      required: false,
+    }),
+    answers: flags.string({
+      multiple: true,
+      description: "Answers for opening's application form questions (sorted by question index)",
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {
-    const { openingId } = this.parse(WorkingGroupsApply).args
+    let { openingId, roleAccount, rewardAccount, stakingAccount, answers } = this.parse(WorkingGroupsApply).flags
     const memberContext = await this.getRequiredMemberContext()
 
-    const opening = await this.getApi().groupOpening(this.group, parseInt(openingId))
+    const opening = await this.getApi().groupOpening(this.group, openingId)
 
-    const roleAccount = await this.promptForAnyAddress('Choose role account')
-    const rewardAccount = await this.promptForAnyAddress('Choose reward account')
+    if (!roleAccount) {
+      roleAccount = await this.promptForAnyAddress('Choose role account')
+    }
+
+    if (!rewardAccount) {
+      rewardAccount = await this.promptForAnyAddress('Choose reward account')
+    }
 
     let stakeParams: CreateInterface<Option<StakeParameters>> = null
+    const stakeLockId = this.getOriginalApi().consts[apiModuleByGroup[this.group]].stakingHandlerLockId
     if (opening.stake) {
-      const stakingAccount = await this.promptForStakingAccount(
-        opening.stake.value,
-        memberContext.id,
-        memberContext.membership
-      )
+      if (!stakingAccount) {
+        stakingAccount = await this.promptForStakingAccount(
+          opening.stake.value,
+          memberContext.id,
+          memberContext.membership,
+          stakeLockId
+        )
+      } else {
+        await this.setupStakingAccount(
+          memberContext.id,
+          memberContext.membership,
+          stakingAccount,
+          opening.stake.value,
+          undefined,
+          stakeLockId
+        )
+      }
 
       stakeParams = {
         stake: opening.stake.value,
@@ -41,12 +77,36 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
       }
     }
 
-    // TODO: Custom json?
-    const description = await this.simplePrompt({
-      message: 'Application description',
-    })
+    let applicationFormAnswers = (answers || []).map((answer, i) => ({ question: `Question ${i}`, answer }))
+    if (opening.metadata) {
+      const questions = opening.metadata.applicationFormQuestions
+      if (!answers || !answers.length) {
+        answers = []
+        for (const i in questions) {
+          const { question } = questions[i]
+          const answer = await this.simplePrompt<string>({ message: `Application form question ${i}: ${question}` })
+          answers.push(answer)
+        }
+      }
+      if (answers.length !== questions.length) {
+        this.error(`Unexpected number of answers! Expected: ${questions.length}, Got: ${answers.length}!`, {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      applicationFormAnswers = questions.map(({ question }, i) => ({
+        question: question || '',
+        answer: answers[i],
+      }))
+    } else {
+      this.warn('Could not fetch opening metadata from query node! Application form answers cannot be validated.')
+    }
+
+    this.jsonPrettyPrint(
+      JSON.stringify({ openingId, roleAccount, rewardAccount, stakingAccount, applicationFormAnswers })
+    )
+    await this.requireConfirmation('Do you confirm the provided input?')
 
-    await this.sendAndFollowNamedTx(
+    const result = await this.sendAndFollowNamedTx(
       await this.getDecodedPair(memberContext.membership.controller_account.toString()),
       apiModuleByGroup[this.group],
       'applyOnOpening',
@@ -57,9 +117,12 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
           role_account_id: roleAccount,
           reward_account_id: rewardAccount,
           stake_parameters: stakeParams,
-          description,
+          description: metadataToBytes(ApplicationMetadata, { answers }),
         }),
       ]
     )
+    const applicationId: ApplicationId = this.getEvent(result, apiModuleByGroup[this.group], 'AppliedOnOpening').data[1]
+    this.log(chalk.greenBright(`Application with id ${chalk.magentaBright(applicationId)} succesfully created!`))
+    this.output(applicationId.toString())
   }
 }

+ 175 - 42
cli/src/commands/working-groups/createOpening.ts

@@ -1,23 +1,30 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { GroupMember } from '../../Types'
+import { GroupMember, WorkingGroupOpeningInputParameters } from '../../Types'
+import { WorkingGroupOpeningInputSchema } from '../../schemas/WorkingGroups'
 import chalk from 'chalk'
 import { apiModuleByGroup } from '../../Api'
 import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import OpeningParamsSchema from '../../schemas/json/WorkingGroupOpening.schema.json'
-import { WorkingGroupOpening as OpeningParamsJson } from '../../schemas/typings/WorkingGroupOpening.schema'
 import { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFile } from '../../helpers/InputOutput'
 import ExitCodes from '../../ExitCodes'
 import { flags } from '@oclif/command'
 import { AugmentedSubmittables } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
-import BN from 'bn.js'
 import { CLIError } from '@oclif/errors'
-
-const OPENING_STAKE = new BN(2000)
+import {
+  IOpeningMetadata,
+  IWorkingGroupMetadataAction,
+  OpeningMetadata,
+  WorkingGroupMetadataAction,
+} from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { OpeningId } from '@joystream/types/working-group'
+import Long from 'long'
+import moment from 'moment'
+import { UpcomingWorkingGroupOpeningDetailsFragment } from '../../graphql/generated/queries'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase {
-  static description = 'Create working group opening (requires lead access)'
+  static description = 'Create working group opening / upcoming opening (requires lead access)'
   static flags = {
     input: IOFlags.input,
     output: flags.string({
@@ -39,14 +46,37 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         '(can be used to generate a "draft" which can be provided as input later)',
       dependsOn: ['output'],
     }),
+    stakeTopUpSource: flags.string({
+      required: false,
+      description:
+        "If provided - this account (key) will be used as default funds source for lead stake top up (in case it's needed)",
+    }),
+    upcoming: flags.boolean({
+      description: 'Whether the opening should be an upcoming opening',
+    }),
+    startsAt: flags.string({
+      required: false,
+      description: `If upcoming opening - the expected opening start date (${DEFAULT_DATE_FORMAT})`,
+      dependsOn: ['upcoming'],
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
+  prepareMetadata(openingParamsJson: WorkingGroupOpeningInputParameters): IOpeningMetadata {
+    return {
+      ...openingParamsJson,
+      applicationFormQuestions: openingParamsJson.applicationFormQuestions?.map((q) => ({
+        question: q.question,
+        type: OpeningMetadata.ApplicationFormQuestion.InputType[q.type],
+      })),
+    }
+  }
+
   createTxParams(
-    openingParamsJson: OpeningParamsJson
+    openingParamsJson: WorkingGroupOpeningInputParameters
   ): Parameters<AugmentedSubmittables<'promise'>['membershipWorkingGroup']['addOpening']> {
     return [
-      openingParamsJson.description,
+      metadataToBytes(OpeningMetadata, this.prepareMetadata(openingParamsJson)),
       'Regular',
       {
         stake_amount: openingParamsJson.stakingPolicy.amount,
@@ -57,10 +87,12 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     ]
   }
 
-  async promptForData(lead: GroupMember, rememberedInput?: OpeningParamsJson): Promise<OpeningParamsJson> {
+  async promptForData(
+    rememberedInput?: WorkingGroupOpeningInputParameters
+  ): Promise<WorkingGroupOpeningInputParameters> {
     const openingDefaults = rememberedInput
-    const openingPrompt = new JsonSchemaPrompter<OpeningParamsJson>(
-      (OpeningParamsSchema as unknown) as JSONSchema,
+    const openingPrompt = new JsonSchemaPrompter<WorkingGroupOpeningInputParameters>(
+      WorkingGroupOpeningInputSchema,
       openingDefaults
     )
     const openingParamsJson = await openingPrompt.promptAll()
@@ -68,67 +100,170 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return openingParamsJson
   }
 
-  async getInputFromFile(filePath: string): Promise<OpeningParamsJson> {
-    const inputParams = await getInputJson<OpeningParamsJson>(filePath, (OpeningParamsSchema as unknown) as JSONSchema)
-
-    return inputParams as OpeningParamsJson
+  async getInputFromFile(filePath: string): Promise<WorkingGroupOpeningInputParameters> {
+    return getInputJson<WorkingGroupOpeningInputParameters>(filePath, WorkingGroupOpeningInputSchema)
   }
 
-  async promptForStakeTopUp(stakingAccount: string): Promise<void> {
-    this.log(`You need to stake ${chalk.bold(formatBalance(OPENING_STAKE))} in order to create a new opening.`)
+  async promptForStakeTopUp(stakingAccount: string, fundsSource?: string): Promise<void> {
+    const requiredStake = this.getOriginalApi().consts[apiModuleByGroup[this.group]].leaderOpeningStake
+    this.log(`You need to stake ${chalk.bold(formatBalance(requiredStake))} in order to create a new opening.`)
 
     const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
-    const missingBalance = OPENING_STAKE.sub(balances.availableBalance)
+    const missingBalance = requiredStake.sub(balances.availableBalance)
     if (missingBalance.gtn(0)) {
       await this.requireConfirmation(
         `Do you wish to transfer remaining ${chalk.bold(
           formatBalance(missingBalance)
         )} to your staking account? (${stakingAccount})`
       )
-      const account = await this.promptForAccount('Choose account to transfer the funds from')
-      await this.sendAndFollowNamedTx(await this.getDecodedPair(account), 'balances', 'transferKeepAlive', [
+      if (!fundsSource) {
+        fundsSource = await this.promptForAccount('Choose account to transfer the funds from')
+      }
+      await this.sendAndFollowNamedTx(await this.getDecodedPair(fundsSource), 'balances', 'transferKeepAlive', [
         stakingAccount,
         missingBalance,
       ])
     }
   }
 
+  async createOpening(lead: GroupMember, inputParameters: WorkingGroupOpeningInputParameters): Promise<OpeningId> {
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...this.createTxParams(inputParameters))
+    )
+    const openingId: OpeningId = this.getEvent(result, apiModuleByGroup[this.group], 'OpeningAdded').data[0]
+    this.log(chalk.green(`Opening with id ${chalk.magentaBright(openingId)} successfully created!`))
+    this.output(openingId.toString())
+    return openingId
+  }
+
+  async createUpcomingOpening(
+    lead: GroupMember,
+    actionMetadata: IWorkingGroupMetadataAction
+  ): Promise<UpcomingWorkingGroupOpeningDetailsFragment | undefined> {
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+    const { indexInBlock, blockNumber } = await this.getEventDetails(
+      result,
+      apiModuleByGroup[this.group],
+      'StatusTextChanged'
+    )
+    if (this.isQueryNodeUriSet()) {
+      let createdUpcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment | null = null
+      let currentAttempt = 0
+      const maxRetryAttempts = 5
+      while (!createdUpcomingOpening && currentAttempt <= maxRetryAttempts) {
+        ++currentAttempt
+        createdUpcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningByEvent(blockNumber, indexInBlock)
+        if (!createdUpcomingOpening && currentAttempt <= maxRetryAttempts) {
+          this.log(
+            `Waiting for the upcoming opening to be processed by the query node (${currentAttempt}/${maxRetryAttempts})...`
+          )
+          await new Promise((resolve) => setTimeout(resolve, 6000))
+        }
+      }
+      if (!createdUpcomingOpening) {
+        this.error('Could not fetch the upcoming opening from the query node', { exit: ExitCodes.QueryNodeError })
+      }
+      this.log(
+        chalk.green(`Upcoming opening with id ${chalk.magentaBright(createdUpcomingOpening.id)} successfully created!`)
+      )
+      this.output(createdUpcomingOpening.id)
+      return createdUpcomingOpening
+    } else {
+      this.log(`StatusTextChanged event emitted in block ${blockNumber}, index: ${indexInBlock}`)
+      this.warn('Query node uri not set, cannot confirm whether the upcoming opening was succesfully created')
+    }
+  }
+
+  validateUpcomingOpeningStartDate(dateStr: string): string | true {
+    const momentObj = moment(dateStr, DEFAULT_DATE_FORMAT)
+    if (!momentObj.isValid()) {
+      return `Unrecognized date format: ${dateStr}`
+    }
+    const ts = momentObj.unix()
+    if (ts <= moment().unix()) {
+      return 'Upcoming opening start date should be in the future!'
+    }
+    return true
+  }
+
+  async getUpcomingOpeningExpectedStartTimestamp(dateStr: string | undefined): Promise<number> {
+    if (dateStr) {
+      const validationResult = this.validateUpcomingOpeningStartDate(dateStr)
+      if (validationResult === true) {
+        return moment(dateStr).unix()
+      } else {
+        this.warn(`Invalid opening start date provided: ${validationResult}`)
+      }
+    }
+    dateStr = await this.simplePrompt<string>({
+      message: `Expected upcoming opening start date (${DEFAULT_DATE_FORMAT}):`,
+      validate: (dateStr) => this.validateUpcomingOpeningStartDate(dateStr),
+    })
+    return moment(dateStr).unix()
+  }
+
+  prepareCreateUpcomingOpeningMetadata(
+    inputParameters: WorkingGroupOpeningInputParameters,
+    expectedStartTs: number
+  ): IWorkingGroupMetadataAction {
+    return {
+      addUpcomingOpening: {
+        metadata: {
+          rewardPerBlock: inputParameters.rewardPerBlock ? Long.fromNumber(inputParameters.rewardPerBlock) : undefined,
+          expectedStart: expectedStartTs,
+          minApplicationStake: Long.fromNumber(inputParameters.stakingPolicy.amount),
+          metadata: this.prepareMetadata(inputParameters),
+        },
+      },
+    }
+  }
+
   async run(): Promise<void> {
     // lead-only gate
     const lead = await this.getRequiredLeadContext()
 
     const {
-      flags: { input, output, edit, dryRun },
+      flags: { input, output, edit, dryRun, stakeTopUpSource, upcoming, startsAt },
     } = this.parse(WorkingGroupsCreateOpening)
 
+    const expectedStartTs = upcoming ? await this.getUpcomingOpeningExpectedStartTimestamp(startsAt) : 0
+
     ensureOutputFileIsWriteable(output)
 
     let tryAgain = false
-    let rememberedInput: OpeningParamsJson | undefined
+    let rememberedInput: WorkingGroupOpeningInputParameters | undefined
     do {
       if (edit) {
         rememberedInput = await this.getInputFromFile(input as string)
       }
       // Either prompt for the data or get it from input file
       const openingJson =
-        !input || edit || tryAgain
-          ? await this.promptForData(lead, rememberedInput)
-          : await this.getInputFromFile(input)
+        !input || edit || tryAgain ? await this.promptForData(rememberedInput) : await this.getInputFromFile(input)
 
       // Remember the provided/fetched data in a variable
       rememberedInput = openingJson
 
-      await this.promptForStakeTopUp(lead.stakingAccount.toString())
+      if (!upcoming) {
+        await this.promptForStakeTopUp(lead.stakingAccount.toString(), stakeTopUpSource)
+      }
 
-      // Generate and ask to confirm tx params
-      const txParams = this.createTxParams(openingJson)
-      this.jsonPrettyPrint(JSON.stringify(txParams))
-      const confirmed = await this.simplePrompt({
-        type: 'confirm',
-        message: 'Do you confirm these extrinsic parameters?',
-      })
+      const createUpcomingOpeningActionMeta = this.prepareCreateUpcomingOpeningMetadata(
+        rememberedInput,
+        expectedStartTs
+      )
+
+      this.jsonPrettyPrint(
+        JSON.stringify(upcoming ? { WorkingGroupMetadataAction: createUpcomingOpeningActionMeta } : rememberedInput)
+      )
+      const confirmed = await this.requestConfirmation('Do you confirm the provided input?')
       if (!confirmed) {
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
         continue
       }
 
@@ -148,17 +283,15 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
       // Send the tx
       try {
-        await this.sendAndFollowTx(
-          await this.getDecodedPair(lead.roleAccount),
-          this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams)
-        )
-        this.log(chalk.green('Opening successfully created!'))
+        upcoming
+          ? await this.createUpcomingOpening(lead, createUpcomingOpeningActionMeta)
+          : await this.createOpening(lead, rememberedInput)
         tryAgain = false
       } catch (e) {
         if (e instanceof CLIError) {
           this.warn(e.message)
         }
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
       }
     } while (tryAgain)
   }

+ 13 - 10
cli/src/commands/working-groups/fillOpening.ts

@@ -2,31 +2,34 @@ import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { apiModuleByGroup } from '../../Api'
 import chalk from 'chalk'
 import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
   static description = "Allows filling working group opening that's currently in review. Requires lead access."
-  static args = [
-    {
-      name: 'wgOpeningId',
-      required: true,
-      description: 'Working Group Opening ID',
-    },
-  ]
 
   static flags = {
+    openingId: flags.integer({
+      required: true,
+      description: 'Working Group Opening ID',
+    }),
+    applicationIds: flags.integer({
+      multiple: true,
+      description: 'Accepted application ids',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {
-    const { args } = this.parse(WorkingGroupsFillOpening)
+    let { openingId, applicationIds } = this.parse(WorkingGroupsFillOpening).flags
 
     // Lead-only gate
     const lead = await this.getRequiredLeadContext()
 
-    const openingId = parseInt(args.wgOpeningId)
     const opening = await this.getOpeningForLeadAction(openingId)
 
-    const applicationIds = await this.promptForApplicationsToAccept(opening)
+    if (!applicationIds || !applicationIds.length) {
+      applicationIds = await this.promptForApplicationsToAccept(opening)
+    }
 
     await this.sendAndFollowNamedTx(
       await this.getDecodedPair(lead.roleAccount),

+ 81 - 27
cli/src/commands/working-groups/opening.ts

@@ -1,48 +1,82 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayTable, displayCollapsedRow, displayHeader, shortAddress, memberHandle } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
+import { flags } from '@oclif/command'
+import moment from 'moment'
+import { OpeningDetails } from '../../Types'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { UpcomingWorkingGroupOpeningDetailsFragment } from '../../graphql/generated/queries'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
-  static description = 'Shows an overview of given working group opening by Working Group Opening ID'
-  static args = [
-    {
-      name: 'wgOpeningId',
-      required: true,
-      description: 'Working Group Opening ID',
-    },
-  ]
+  static description = 'Shows detailed information about working group opening / upcoming opening by id'
 
   static flags = {
+    id: flags.string({
+      required: true,
+      description: 'Opening / upcoming opening id (depending on --upcoming flag)',
+    }),
+    upcoming: flags.boolean({
+      description: 'Whether the opening is an upcoming opening',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
-  async run(): Promise<void> {
-    const { args } = this.parse(WorkingGroupsOpening)
-
-    const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))
-
-    // TODO: Opening desc?
-
+  openingDetails(opening: OpeningDetails): void {
     displayHeader('Opening details')
-    const openingRow = {
+    displayCollapsedRow({
       'Opening ID': opening.openingId,
       'Opening type': opening.type.type,
       'Created': `#${opening.createdAtBlock}`,
-      'Reward per block': formatBalance(opening.rewardPerBlock),
-    }
-    displayCollapsedRow(openingRow)
+      'Reward per block': opening.rewardPerBlock ? formatBalance(opening.rewardPerBlock) : '-',
+    })
+  }
 
+  openingStakingPolicy(opening: OpeningDetails): void {
     displayHeader('Staking policy')
-    if (opening.stake) {
-      const stakingRow = {
-        'Stake amount': formatBalance(opening.stake.value),
-        'Unstaking period': opening.stake.unstakingPeriod.toLocaleString() + ' blocks',
-      }
-      displayCollapsedRow(stakingRow)
-    } else {
-      this.log('NONE')
+    displayCollapsedRow({
+      'Stake amount': formatBalance(opening.stake.value),
+      'Unstaking period': opening.stake.unstakingPeriod.toLocaleString() + ' blocks',
+    })
+  }
+
+  upcomingOpeningDetails(upcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment): void {
+    displayHeader('Upcoming opening details')
+    displayCollapsedRow({
+      'Upcoming Opening ID': upcomingOpening.id,
+      'Expected start': upcomingOpening.expectedStart
+        ? moment(upcomingOpening.expectedStart).format(DEFAULT_DATE_FORMAT)
+        : '?',
+      'Reward per block': upcomingOpening.rewardPerBlock ? formatBalance(upcomingOpening.rewardPerBlock) : '?',
+    })
+  }
+
+  upcomingOpeningStakingPolicy(upcomingOpening: UpcomingWorkingGroupOpeningDetailsFragment): void {
+    if (upcomingOpening.stakeAmount) {
+      displayHeader('Staking policy')
+      displayCollapsedRow({
+        'Stake amount': formatBalance(upcomingOpening.stakeAmount),
+      })
+    }
+  }
+
+  openingMetadata(opening: OpeningDetails | UpcomingWorkingGroupOpeningDetailsFragment): void {
+    const { metadata } = opening
+    if (metadata) {
+      displayHeader('Metadata')
+      this.jsonPrettyPrint(
+        JSON.stringify({
+          ...metadata,
+          expectedEnding: metadata.expectedEnding
+            ? moment(metadata.expectedEnding).format(DEFAULT_DATE_FORMAT)
+            : undefined,
+        })
+      )
     }
+  }
 
+  openingApplications(opening: OpeningDetails): void {
     displayHeader(`Applications (${opening.applications.length})`)
     const applicationsRows = opening.applications.map((a) => ({
       'ID': a.applicationId,
@@ -53,4 +87,24 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
     }))
     displayTable(applicationsRows, 5)
   }
+
+  async run(): Promise<void> {
+    const { id, upcoming } = this.parse(WorkingGroupsOpening).flags
+
+    if (upcoming) {
+      const upcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningById(id)
+      if (!upcomingOpening) {
+        this.error(`Upcoming opening by id ${chalk.magentaBright(id)} was not found!`, { exit: ExitCodes.InvalidInput })
+      }
+      this.upcomingOpeningDetails(upcomingOpening)
+      this.upcomingOpeningStakingPolicy(upcomingOpening)
+      this.openingMetadata(upcomingOpening)
+    } else {
+      const opening = await this.getApi().groupOpening(this.group, parseInt(id))
+      this.openingDetails(opening)
+      this.openingStakingPolicy(opening)
+      this.openingMetadata(opening)
+      this.openingApplications(opening)
+    }
+  }
 }

+ 30 - 8
cli/src/commands/working-groups/openings.ts

@@ -1,20 +1,42 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { flags } from '@oclif/command'
 import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
+import { DEFAULT_DATE_FORMAT } from '../../Consts'
 
 export default class WorkingGroupsOpenings extends WorkingGroupsCommandBase {
-  static description = 'Shows an overview of given working group openings'
+  static description = 'Lists active/upcoming openings in a given working group'
   static flags = {
+    upcoming: flags.boolean({
+      description: 'List upcoming openings (active openings are listed by default)',
+    }),
     ...WorkingGroupsCommandBase.flags,
   }
 
   async run(): Promise<void> {
-    const openings = await this.getApi().openingsByGroup(this.group)
+    const { upcoming } = this.parse(WorkingGroupsOpenings).flags
 
-    const openingsRows = openings.map((o) => ({
-      'Opening ID': o.openingId,
-      Type: o.type.type,
-      Applications: o.applications.length,
-    }))
-    displayTable(openingsRows, 5)
+    let rows: { [k: string]: string | number }[]
+    if (upcoming) {
+      const upcomingOpenings = await this.getQNApi().upcomingWorkingGroupOpeningsByGroup(this.group)
+      rows = upcomingOpenings.map((o) => ({
+        'Upcoming opening ID': o.id,
+        'Starts at': o.expectedStart ? moment(o.expectedStart).format(DEFAULT_DATE_FORMAT) : '?',
+        'Reward/block': o.rewardPerBlock ? formatBalance(o.rewardPerBlock) : '?',
+        'Stake': o.stakeAmount ? formatBalance(o.stakeAmount) : '?',
+      }))
+    } else {
+      const openings = await this.getApi().openingsByGroup(this.group)
+      rows = openings.map((o) => ({
+        'Opening ID': o.openingId,
+        Type: o.type.type,
+        Applications: o.applications.length,
+        'Reward/block': formatBalance(o.rewardPerBlock),
+        'Stake': formatBalance(o.stake.value),
+      }))
+    }
+
+    displayTable(rows, 5)
   }
 }

+ 85 - 0
cli/src/commands/working-groups/removeUpcomingOpening.ts

@@ -0,0 +1,85 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import chalk from 'chalk'
+import { apiModuleByGroup } from '../../Api'
+import { flags } from '@oclif/command'
+import { IWorkingGroupMetadataAction, WorkingGroupMetadataAction } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import ExitCodes from '../../ExitCodes'
+
+export default class WorkingGroupsRemoveUpcomingOpening extends WorkingGroupsCommandBase {
+  static description =
+    'Remove an existing upcoming opening by sending RemoveUpcomingOpening metadata signal (requires lead access)'
+
+  static flags = {
+    id: flags.string({
+      char: 'i',
+      required: true,
+      description: `Id of the upcoming opening to remove`,
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async checkIfUpcomingOpeningExists(id: string): Promise<void> {
+    if (this.isQueryNodeUriSet()) {
+      const upcomingOpening = await this.getQNApi().upcomingWorkingGroupOpeningById(id)
+      this.log(`Upcoming opening by id ${id} found:`)
+      this.jsonPrettyPrint(JSON.stringify(upcomingOpening))
+      this.log('\n')
+    } else {
+      this.warn('Query node uri not set, cannot verify if upcoming opening exists!')
+    }
+  }
+
+  async checkIfUpcomingOpeningRemoved(id: string): Promise<void> {
+    if (this.isQueryNodeUriSet()) {
+      let removed = false
+      let currentAttempt = 0
+      const maxRetryAttempts = 5
+      while (!removed && currentAttempt <= maxRetryAttempts) {
+        ++currentAttempt
+        removed = !(await this.getQNApi().upcomingWorkingGroupOpeningById(id))
+        if (!removed && currentAttempt <= maxRetryAttempts) {
+          this.log(
+            `Waiting for the upcoming opening removal to be processed by the query node (${currentAttempt}/${maxRetryAttempts})...`
+          )
+          await new Promise((resolve) => setTimeout(resolve, 6000))
+        }
+      }
+      if (!removed) {
+        this.error('Could not confirm upcoming opening removal against the query node', {
+          exit: ExitCodes.QueryNodeError,
+        })
+      }
+      this.log(chalk.green(`Upcoming opening with id ${chalk.magentaBright(id)} successfully removed!`))
+    } else {
+      this.warn('Query node uri not set, cannot verify if upcoming opening was removed!')
+    }
+  }
+
+  async run(): Promise<void> {
+    const { id } = this.parse(WorkingGroupsRemoveUpcomingOpening).flags
+    // lead-only gate
+    const lead = await this.getRequiredLeadContext()
+
+    await this.checkIfUpcomingOpeningExists(id)
+
+    const actionMetadata: IWorkingGroupMetadataAction = {
+      'removeUpcomingOpening': {
+        id,
+      },
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ WorkingGroupMetadataAction: actionMetadata }))
+
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+
+    await this.checkIfUpcomingOpeningRemoved(id)
+  }
+}

+ 54 - 0
cli/src/commands/working-groups/updateGroupMetadata.ts

@@ -0,0 +1,54 @@
+import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
+import { WorkingGroupUpdateStatusInputParameters } from '../../Types'
+import { WorkingGroupUpdateStatusInputSchema } from '../../schemas/WorkingGroups'
+import chalk from 'chalk'
+import { apiModuleByGroup } from '../../Api'
+import { getInputJson } from '../../helpers/InputOutput'
+import { flags } from '@oclif/command'
+import { IWorkingGroupMetadataAction, WorkingGroupMetadataAction } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+
+export default class WorkingGroupsUpdateMetadata extends WorkingGroupsCommandBase {
+  static description =
+    'Update working group metadata (description, status etc.). The update will be atomic (just like video / channel metadata updates)'
+
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+    ...WorkingGroupsCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    // lead-only gate
+    const lead = await this.getRequiredLeadContext()
+
+    const {
+      flags: { input: inputFilePath },
+    } = this.parse(WorkingGroupsUpdateMetadata)
+
+    const input = await getInputJson<WorkingGroupUpdateStatusInputParameters>(
+      inputFilePath,
+      WorkingGroupUpdateStatusInputSchema
+    )
+    const actionMetadata: IWorkingGroupMetadataAction = {
+      'setGroupMetadata': {
+        newMetadata: input,
+      },
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(actionMetadata))
+
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      this.getOriginalApi().tx[apiModuleByGroup[this.group]].setStatusText(
+        metadataToBytes(WorkingGroupMetadataAction, actionMetadata)
+      )
+    )
+    this.log(chalk.green(`Working group metadata successfully updated!`))
+  }
+}

+ 151 - 0
cli/src/graphql/generated/queries.ts

@@ -52,6 +52,71 @@ export type GetDataObjectsByVideoIdQueryVariables = Types.Exact<{
 
 export type GetDataObjectsByVideoIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 
+export type WorkingGroupOpeningMetadataFieldsFragment = {
+  description?: Types.Maybe<string>
+  shortDescription?: Types.Maybe<string>
+  hiringLimit?: Types.Maybe<number>
+  expectedEnding?: Types.Maybe<any>
+  applicationDetails?: Types.Maybe<string>
+  applicationFormQuestions: Array<{ question?: Types.Maybe<string>; type: Types.ApplicationFormQuestionType }>
+}
+
+export type WorkingGroupOpeningDetailsFragment = { metadata: WorkingGroupOpeningMetadataFieldsFragment }
+
+export type WorkingGroupApplicationDetailsFragment = {
+  answers: Array<{ answer: string; question: { question?: Types.Maybe<string> } }>
+}
+
+export type UpcomingWorkingGroupOpeningDetailsFragment = {
+  id: string
+  groupId: string
+  expectedStart?: Types.Maybe<any>
+  stakeAmount?: Types.Maybe<any>
+  rewardPerBlock?: Types.Maybe<any>
+  metadata: WorkingGroupOpeningMetadataFieldsFragment
+}
+
+export type OpeningDetailsByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type OpeningDetailsByIdQuery = {
+  workingGroupOpeningByUniqueInput?: Types.Maybe<WorkingGroupOpeningDetailsFragment>
+}
+
+export type ApplicationDetailsByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type ApplicationDetailsByIdQuery = {
+  workingGroupApplicationByUniqueInput?: Types.Maybe<WorkingGroupApplicationDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningByEventQueryVariables = Types.Exact<{
+  blockNumber: Types.Scalars['Int']
+  indexInBlock: Types.Scalars['Int']
+}>
+
+export type UpcomingWorkingGroupOpeningByEventQuery = {
+  upcomingWorkingGroupOpenings: Array<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningsByGroupQueryVariables = Types.Exact<{
+  workingGroupId: Types.Scalars['ID']
+}>
+
+export type UpcomingWorkingGroupOpeningsByGroupQuery = {
+  upcomingWorkingGroupOpenings: Array<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
+export type UpcomingWorkingGroupOpeningByIdQueryVariables = Types.Exact<{
+  id: Types.Scalars['ID']
+}>
+
+export type UpcomingWorkingGroupOpeningByIdQuery = {
+  upcomingWorkingGroupOpeningByUniqueInput?: Types.Maybe<UpcomingWorkingGroupOpeningDetailsFragment>
+}
+
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
     name
@@ -106,6 +171,50 @@ export const DataObjectInfo = gql`
     }
   }
 `
+export const WorkingGroupOpeningMetadataFields = gql`
+  fragment WorkingGroupOpeningMetadataFields on WorkingGroupOpeningMetadata {
+    description
+    shortDescription
+    hiringLimit
+    expectedEnding
+    applicationDetails
+    applicationFormQuestions {
+      question
+      type
+    }
+  }
+`
+export const WorkingGroupOpeningDetails = gql`
+  fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+    metadata {
+      ...WorkingGroupOpeningMetadataFields
+    }
+  }
+  ${WorkingGroupOpeningMetadataFields}
+`
+export const WorkingGroupApplicationDetails = gql`
+  fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+    answers {
+      question {
+        question
+      }
+      answer
+    }
+  }
+`
+export const UpcomingWorkingGroupOpeningDetails = gql`
+  fragment UpcomingWorkingGroupOpeningDetails on UpcomingWorkingGroupOpening {
+    id
+    groupId
+    expectedStart
+    stakeAmount
+    rewardPerBlock
+    metadata {
+      ...WorkingGroupOpeningMetadataFields
+    }
+  }
+  ${WorkingGroupOpeningMetadataFields}
+`
 export const GetMembersByIds = gql`
   query getMembersByIds($ids: [ID!]) {
     memberships(where: { id_in: $ids }) {
@@ -152,3 +261,45 @@ export const GetDataObjectsByVideoId = gql`
   }
   ${DataObjectInfo}
 `
+export const OpeningDetailsById = gql`
+  query openingDetailsById($id: ID!) {
+    workingGroupOpeningByUniqueInput(where: { id: $id }) {
+      ...WorkingGroupOpeningDetails
+    }
+  }
+  ${WorkingGroupOpeningDetails}
+`
+export const ApplicationDetailsById = gql`
+  query applicationDetailsById($id: ID!) {
+    workingGroupApplicationByUniqueInput(where: { id: $id }) {
+      ...WorkingGroupApplicationDetails
+    }
+  }
+  ${WorkingGroupApplicationDetails}
+`
+export const UpcomingWorkingGroupOpeningByEvent = gql`
+  query upcomingWorkingGroupOpeningByEvent($blockNumber: Int!, $indexInBlock: Int!) {
+    upcomingWorkingGroupOpenings(
+      where: { createdInEvent: { inBlock_eq: $blockNumber, indexInBlock_eq: $indexInBlock } }
+    ) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`
+export const UpcomingWorkingGroupOpeningsByGroup = gql`
+  query upcomingWorkingGroupOpeningsByGroup($workingGroupId: ID!) {
+    upcomingWorkingGroupOpenings(where: { group: { id_eq: $workingGroupId } }, orderBy: createdAt_DESC) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`
+export const UpcomingWorkingGroupOpeningById = gql`
+  query upcomingWorkingGroupOpeningById($id: ID!) {
+    upcomingWorkingGroupOpeningByUniqueInput(where: { id: $id }) {
+      ...UpcomingWorkingGroupOpeningDetails
+    }
+  }
+  ${UpcomingWorkingGroupOpeningDetails}
+`

+ 69 - 0
cli/src/graphql/queries/workingGroups.graphql

@@ -0,0 +1,69 @@
+fragment WorkingGroupOpeningMetadataFields on WorkingGroupOpeningMetadata {
+  description
+  shortDescription
+  hiringLimit
+  expectedEnding
+  applicationDetails
+  applicationFormQuestions {
+    question
+    type
+  }
+}
+
+fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+  metadata {
+    ...WorkingGroupOpeningMetadataFields
+  }
+}
+
+fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+  answers {
+    question {
+      question
+    }
+    answer
+  }
+}
+
+fragment UpcomingWorkingGroupOpeningDetails on UpcomingWorkingGroupOpening {
+  id
+  groupId
+  expectedStart
+  stakeAmount
+  rewardPerBlock
+  metadata {
+    ...WorkingGroupOpeningMetadataFields
+  }
+}
+
+query openingDetailsById($id: ID!) {
+  workingGroupOpeningByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupOpeningDetails
+  }
+}
+
+query applicationDetailsById($id: ID!) {
+  workingGroupApplicationByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupApplicationDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningByEvent($blockNumber: Int!, $indexInBlock: Int!) {
+  upcomingWorkingGroupOpenings(
+    where: { createdInEvent: { inBlock_eq: $blockNumber, indexInBlock_eq: $indexInBlock } }
+  ) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningsByGroup($workingGroupId: ID!) {
+  upcomingWorkingGroupOpenings(where: { group: { id_eq: $workingGroupId } }, orderBy: createdAt_DESC) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}
+
+query upcomingWorkingGroupOpeningById($id: ID!) {
+  upcomingWorkingGroupOpeningByUniqueInput(where: { id: $id }) {
+    ...UpcomingWorkingGroupOpeningDetails
+  }
+}

+ 6 - 1
cli/src/schemas/ContentDirectory.ts

@@ -94,7 +94,12 @@ export const VideoInputSchema: JsonSchema<VideoInputParameters> = {
         },
       },
     },
-    persons: { type: 'array' },
+    persons: {
+      type: 'array',
+      items: {
+        type: 'integer',
+      },
+    },
     publishedBeforeJoystream: {
       type: 'object',
       properties: {

+ 68 - 0
cli/src/schemas/WorkingGroups.ts

@@ -0,0 +1,68 @@
+import { WorkingGroupOpeningInputParameters, WorkingGroupUpdateStatusInputParameters, JsonSchema } from '../Types'
+
+export const WorkingGroupOpeningInputSchema: JsonSchema<WorkingGroupOpeningInputParameters> = {
+  type: 'object',
+  additionalProperties: false,
+  required: ['stakingPolicy'],
+  properties: {
+    applicationDetails: {
+      type: 'string',
+    },
+    expectedEndingTimestamp: {
+      type: 'integer',
+      minimum: Math.floor(Date.now() / 1000),
+    },
+    hiringLimit: {
+      type: 'integer',
+      minimum: 1,
+    },
+    shortDescription: {
+      type: 'string',
+    },
+    description: {
+      type: 'string',
+    },
+    applicationFormQuestions: {
+      type: 'array',
+      items: {
+        type: 'object',
+        additionalProperties: false,
+        required: ['question'],
+        properties: {
+          question: {
+            type: 'string',
+            minLength: 1,
+          },
+          type: {
+            type: 'string',
+            enum: ['TEXTAREA', 'TEXT'],
+          },
+        },
+      },
+    },
+    stakingPolicy: {
+      type: 'object',
+      additionalProperties: false,
+      required: ['amount', 'unstakingPeriod'],
+      properties: {
+        amount: { type: 'integer', minimum: 2000 },
+        unstakingPeriod: { type: 'integer', minimum: 43201 },
+      },
+    },
+    rewardPerBlock: {
+      type: 'integer',
+      minimum: 1,
+    },
+  },
+}
+
+export const WorkingGroupUpdateStatusInputSchema: JsonSchema<WorkingGroupUpdateStatusInputParameters> = {
+  type: 'object',
+  additionalProperties: false,
+  properties: {
+    about: { type: 'string' },
+    description: { type: 'string' },
+    status: { type: 'string' },
+    statusMessage: { type: 'string' },
+  },
+}

+ 1 - 1
query-node/mappings/src/bootstrap-data/index.ts

@@ -1,4 +1,4 @@
-import { MemberJson, StorageSystemJson, WorkingGroupJson, MembershipSystemJson } from './types'
+import { StorageSystemJson, WorkingGroupJson, MembershipSystemJson, MemberJson } from './types'
 import storageSystemJson from './data/storageSystem.json'
 import membersJson from './data/members.json'
 import workingGroupsJson from './data/workingGroups.json'

+ 5 - 7
query-node/mappings/src/bootstrap-data/types.ts

@@ -1,12 +1,10 @@
 export type MemberJson = {
-  memberId: string
-  rootAccount: string
-  controllerAccount: string
+  member_id: number
+  root_account: string
+  controller_account: string
   handle: string
-  about?: string
-  avatarUri?: string
-  registeredAtTime: number
-  registeredAtBlock: number
+  about: string
+  avatar_uri: string
 }
 
 export type StorageSystemJson = {

+ 24 - 1
query-node/mappings/src/bootstrap.ts

@@ -6,13 +6,17 @@ import {
   WorkingGroup,
   ElectedCouncil,
   ElectionRound,
+  MembershipEntryGenesis,
 } from 'query-node/dist/model'
-import { storageSystemData, membershipSystemData, workingGroupsData } from './bootstrap-data'
+import { storageSystemData, membershipSystemData, workingGroupsData, membersData } from './bootstrap-data'
+import { createNewMember } from './membership'
 
 import { CURRENT_NETWORK } from './common'
+import { MembershipMetadata } from '@joystream/metadata-protobuf'
 
 export async function bootstrapData({ store }: StoreContext): Promise<void> {
   await initMembershipSystem(store)
+  await initMembers(store)
   await initStorageSystem(store)
   await initWorkingGroups(store)
   await initFirstElectionRound(store)
@@ -81,3 +85,22 @@ async function initFirstElectionRound(store: DatabaseManager): Promise<void> {
   })
   await store.save<ElectionRound>(initialElectionRound)
 }
+
+async function initMembers(store: DatabaseManager) {
+  for (const member of membersData) {
+    await createNewMember(
+      store,
+      new Date(0),
+      member.member_id.toString(),
+      new MembershipEntryGenesis(),
+      member.root_account,
+      member.controller_account,
+      member.handle,
+      0,
+      new MembershipMetadata({
+        about: member.about,
+        avatarUri: member.avatar_uri,
+      })
+    )
+  }
+}

+ 50 - 0
query-node/mappings/src/membership.ts

@@ -120,6 +120,56 @@ async function createNewMemberFromParams(
   return member
 }
 
+export async function createNewMember(
+  store: DatabaseManager,
+  eventTime: Date,
+  memberId: string,
+  entryMethod: typeof MembershipEntryMethod,
+  rootAccount: string,
+  controllerAccount: string,
+  handle: string,
+  defaultInviteCount: number,
+  metadata: MembershipMetadata
+): Promise<Membership> {
+  const avatar = new AvatarUri()
+  avatar.avatarUri = metadata?.avatarUri ?? ''
+
+  const metadataEntity = new MemberMetadata({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    name: metadata?.name || undefined,
+    about: metadata?.about || undefined,
+    avatar,
+  })
+
+  const member = new Membership({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    id: memberId,
+    rootAccount: rootAccount.toString(),
+    controllerAccount: controllerAccount.toString(),
+    handle: handle.toString(),
+    metadata: metadataEntity,
+    entry: entryMethod,
+    referredBy: undefined,
+    isVerified: false,
+    inviteCount: defaultInviteCount,
+    boundAccounts: [],
+    invitees: [],
+    referredMembers: [],
+    invitedBy: undefined,
+    isFoundingMember: false,
+    isCouncilMember: false,
+    councilCandidacies: [],
+    councilMembers: [],
+  })
+
+  await store.save<MemberMetadata>(member.metadata)
+  await store.save<Membership>(member)
+
+  return member
+}
+
 export async function members_MembershipBought({ store, event }: EventContext & StoreContext): Promise<void> {
   const [memberId, buyMembershipParameters] = new Members.MembershipBoughtEvent(event).params
 

+ 8 - 0
runtime-modules/common/src/lib.rs

@@ -12,10 +12,12 @@ use codec::{Codec, Decode, Encode};
 #[cfg(feature = "std")]
 use serde::{Deserialize, Serialize};
 
+use frame_support::traits::LockIdentifier;
 use frame_support::Parameter;
 pub use membership::{ActorId, MemberId, MembershipTypes, StakingAccountValidator};
 use sp_arithmetic::traits::BaseArithmetic;
 use sp_runtime::traits::{MaybeSerialize, Member};
+use sp_std::collections::btree_set::BTreeSet;
 use sp_std::vec::Vec;
 
 /// HTTP Url string
@@ -92,3 +94,9 @@ pub fn current_block_time<T: frame_system::Trait + pallet_timestamp::Trait>(
         time: <pallet_timestamp::Module<T>>::now(),
     }
 }
+
+/// Provides allowed locks combination for the accounts.
+pub trait AllowedLockCombinationProvider {
+    /// Return allowed locks combination set.
+    fn get_allowed_lock_combinations() -> BTreeSet<(LockIdentifier, LockIdentifier)>;
+}

+ 0 - 2
runtime-modules/membership/src/genesis.rs

@@ -13,7 +13,6 @@ pub struct Member<MemberId, AccountId> {
     pub handle: String,
     pub avatar_uri: String,
     pub about: String,
-    pub name: String,
 }
 
 /// Builder fo membership module genesis configuration.
@@ -47,7 +46,6 @@ impl<T: Trait> GenesisConfigBuilder<T> {
                 handle: (10000 + ix).to_string(),
                 avatar_uri: "".into(),
                 about: "".into(),
-                name: "".into(),
             })
             .collect()
     }

+ 0 - 24
runtime-modules/memo/Cargo.toml

@@ -1,24 +0,0 @@
-[package]
-name = 'pallet-memo'
-version = '5.0.0'
-authors = ['Joystream contributors']
-edition = '2018'
-
-[dependencies]
-codec = { package = 'parity-scale-codec', version = '1.3.4', default-features = false, features = ['derive'] }
-sp-arithmetic = { package = 'sp-arithmetic', default-features = false, git = 'https://github.com/paritytech/substrate.git', rev = '2cd20966cc09b059817c3ebe12fc130cdd850d62'}
-sp-std = { package = 'sp-std', default-features = false, git = 'https://github.com/paritytech/substrate.git', rev = '2cd20966cc09b059817c3ebe12fc130cdd850d62'}
-frame-support = { package = 'frame-support', default-features = false, git = 'https://github.com/paritytech/substrate.git', rev = '2cd20966cc09b059817c3ebe12fc130cdd850d62'}
-frame-system = { package = 'frame-system', default-features = false, git = 'https://github.com/paritytech/substrate.git', rev = '2cd20966cc09b059817c3ebe12fc130cdd850d62'}
-balances = { package = 'pallet-balances', default-features = false, git = 'https://github.com/paritytech/substrate.git', rev = '2cd20966cc09b059817c3ebe12fc130cdd850d62'}
-
-[features]
-default = ['std']
-std = [
-	'codec/std',
-	'sp-arithmetic/std',
-	'sp-std/std',
-	'frame-support/std',
-	'frame-system/std',
-	'balances/std',
-]

+ 0 - 46
runtime-modules/memo/src/lib.rs

@@ -1,46 +0,0 @@
-// Ensure we're `no_std` when compiling for Wasm.
-#![cfg_attr(not(feature = "std"), no_std)]
-// Internal Substrate warning (decl_event).
-#![allow(clippy::unused_unit)]
-
-use frame_support::traits::Currency;
-use frame_support::{decl_event, decl_module, decl_storage, ensure};
-use frame_system::ensure_signed;
-use sp_arithmetic::traits::Zero;
-use sp_std::vec::Vec;
-
-pub trait Trait: frame_system::Trait + balances::Trait {
-    type Event: From<Event<Self>> + Into<<Self as frame_system::Trait>::Event>;
-}
-
-pub type MemoText = Vec<u8>;
-
-decl_storage! {
-    trait Store for Module<T: Trait> as Memo {
-        Memo get(fn memo) : map hasher(blake2_128_concat) T::AccountId => MemoText;
-        MaxMemoLength get(fn max_memo_length) : u32 = 4096;
-    }
-}
-
-decl_event! {
-    pub enum Event<T> where <T as frame_system::Trait>::AccountId {
-        MemoUpdated(AccountId, MemoText),
-    }
-}
-
-decl_module! {
-    pub struct Module<T: Trait> for enum Call where origin: T::Origin {
-        fn deposit_event() = default;
-
-        #[weight = 10_000_000] // TODO: adjust weight
-        fn update_memo(origin, memo: MemoText) {
-            let sender = ensure_signed(origin)?;
-
-            ensure!(!<balances::Module<T>>::total_balance(&sender).is_zero(), "account must have a balance");
-            ensure!(memo.len() as u32 <= Self::max_memo_length(), "memo too long");
-
-            <Memo<T>>::insert(&sender, memo.clone());
-            Self::deposit_event(RawEvent::MemoUpdated(sender, memo));
-        }
-    }
-}

+ 1 - 1
runtime-modules/utility/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = 'pallet-utility'
-version = '1.0.0'
+version = '1.1.0'
 authors = ['Joystream contributors']
 edition = '2018'
 

+ 10 - 3
runtime-modules/utility/src/lib.rs

@@ -24,16 +24,16 @@ pub(crate) mod tests;
 
 mod benchmarking;
 
-use common::{working_group::WorkingGroup, BalanceKind};
+use common::{working_group::WorkingGroup, AllowedLockCombinationProvider, BalanceKind};
 use council::Module as Council;
-use frame_support::traits::Currency;
-use frame_support::traits::Get;
+use frame_support::traits::{Currency, Get, LockIdentifier};
 use frame_support::weights::{DispatchClass, Weight};
 use frame_support::{decl_error, decl_event, decl_module, ensure, print};
 use frame_system::{ensure_root, ensure_signed};
 use sp_arithmetic::traits::Zero;
 use sp_runtime::traits::Saturating;
 use sp_runtime::SaturatedConversion;
+use sp_std::collections::btree_set::BTreeSet;
 use sp_std::vec::Vec;
 
 type BalanceOf<T> = <T as balances::Trait>::Balance;
@@ -50,6 +50,9 @@ pub trait Trait: frame_system::Trait + balances::Trait + council::Trait {
 
     /// Weight information for extrinsics in this pallet.
     type WeightInfo: WeightInfo;
+
+    /// Exposes allowed lock combinations from the runtime level.
+    type AllowedLockCombinationProvider: AllowedLockCombinationProvider;
 }
 
 /// Utility WeightInfo.
@@ -116,6 +119,10 @@ decl_module! {
         /// Predefined errors
         type Error = Error<T>;
 
+        /// Exposes allowed lock combinations from the runtime level.
+        const AllowedLockCombinations: BTreeSet<(LockIdentifier, LockIdentifier)> =
+            T::AllowedLockCombinationProvider::get_allowed_lock_combinations();
+
         /// Signal proposal extrinsic. Should be used as callable object to pass to the `engine` module.
         ///
         /// <weight>

+ 9 - 0
runtime-modules/utility/src/tests/mocks.rs

@@ -206,6 +206,15 @@ impl Trait for Test {
     fn set_working_group_budget(working_group: WorkingGroup, budget: BalanceOf<Test>) {
         call_wg!(working_group<Test>, set_budget, budget)
     }
+
+    type AllowedLockCombinationProvider = AllowedLockCombinationProvider;
+}
+
+pub struct AllowedLockCombinationProvider;
+impl common::AllowedLockCombinationProvider for AllowedLockCombinationProvider {
+    fn get_allowed_lock_combinations() -> BTreeSet<(LockIdentifier, LockIdentifier)> {
+        Default::default()
+    }
 }
 
 impl WeightInfo for () {

+ 0 - 2
runtime/Cargo.toml

@@ -64,7 +64,6 @@ hex-literal = { optional = true, version = '0.3.1' }
 
 # Joystream
 common = { package = 'pallet-common', default-features = false, path = '../runtime-modules/common'}
-memo = { package = 'pallet-memo', default-features = false, path = '../runtime-modules/memo'}
 forum = { package = 'pallet-forum', default-features = false, path = '../runtime-modules/forum'}
 membership = { package = 'pallet-membership', default-features = false, path = '../runtime-modules/membership'}
 referendum = { package = 'pallet-referendum', default-features = false, path = '../runtime-modules/referendum'}
@@ -139,7 +138,6 @@ std = [
 
     # Joystream
     'common/std',
-    'memo/std',
     'forum/std',
     'membership/std',
     'council/std',

+ 11 - 5
runtime/src/lib.rs

@@ -58,6 +58,7 @@ use sp_runtime::curve::PiecewiseLinear;
 use sp_runtime::traits::{BlakeTwo256, Block as BlockT, IdentityLookup, OpaqueKeys, Saturating};
 use sp_runtime::{create_runtime_str, generic, impl_opaque_keys, ModuleId, Perbill};
 use sp_std::boxed::Box;
+use sp_std::collections::btree_set::BTreeSet;
 use sp_std::vec::Vec;
 #[cfg(feature = "std")]
 use sp_version::NativeVersion;
@@ -71,6 +72,7 @@ pub use runtime_api::*;
 use integration::proposals::{CouncilManager, ExtrinsicProposalEncoder};
 
 use common::working_group::{WorkingGroup, WorkingGroupAuthenticator, WorkingGroupBudgetHandler};
+use common::AllowedLockCombinationProvider;
 use council::ReferendumConnection;
 use referendum::{CastVote, OptionResult};
 use staking_handler::{LockComparator, StakingManager};
@@ -575,10 +577,6 @@ impl common::StorageOwnership for Runtime {
     type DataObjectTypeId = DataObjectTypeId;
 }
 
-impl memo::Trait for Runtime {
-    type Event = Event;
-}
-
 parameter_types! {
     pub const MaxDistributionBucketFamilyNumber: u64 = 200;
     pub const DataObjectDeletionPrize: Balance = 0; //TODO: Change during Olympia release
@@ -983,9 +981,18 @@ impl proposals_discussion::Trait for Runtime {
     type PostLifeTime = ForumPostLifeTime;
 }
 
+pub struct LockCombinationProvider;
+impl AllowedLockCombinationProvider for LockCombinationProvider {
+    fn get_allowed_lock_combinations() -> BTreeSet<(LockIdentifier, LockIdentifier)> {
+        ALLOWED_LOCK_COMBINATIONS.clone()
+    }
+}
+
 impl joystream_utility::Trait for Runtime {
     type Event = Event;
 
+    type AllowedLockCombinationProvider = LockCombinationProvider;
+
     type WeightInfo = weights::joystream_utility::WeightInfo;
 
     fn get_working_group_budget(working_group: WorkingGroup) -> Balance {
@@ -1133,7 +1140,6 @@ construct_runtime!(
         // Joystream
         Council: council::{Module, Call, Storage, Event<T>, Config<T>},
         Referendum: referendum::<Instance1>::{Module, Call, Storage, Event<T>, Config<T>},
-        Memo: memo::{Module, Call, Storage, Event<T>},
         Members: membership::{Module, Call, Storage, Event<T>, Config<T>},
         Forum: forum::{Module, Call, Storage, Event<T>, Config<T>},
         Constitution: pallet_constitution::{Module, Call, Storage, Event},

+ 0 - 1
types/augment/all/defs.json

@@ -24,7 +24,6 @@
             "Membership"
         ]
     },
-    "MemoText": "Text",
     "BalanceKind": {
         "_enum": [
             "Positive",

+ 0 - 3
types/augment/all/types.ts

@@ -591,9 +591,6 @@ export interface Membership extends Struct {
   readonly invites: u32;
 }
 
-/** @name MemoText */
-export interface MemoText extends Text {}
-
 /** @name ModeratorId */
 export interface ModeratorId extends u64 {}
 

+ 0 - 3
types/src/common.ts

@@ -93,8 +93,6 @@ export const WorkingGroupDef = {
 export type WorkingGroupKey = keyof typeof WorkingGroupDef
 export class WorkingGroup extends JoyEnum(WorkingGroupDef) {}
 
-export class MemoText extends Text {}
-
 export class BalanceKind extends JoyEnum({
   Positive: Null,
   Negative: Null,
@@ -115,7 +113,6 @@ export const commonTypes: RegistryTypes = {
   PostId,
   InputValidationLengthConstraint,
   WorkingGroup,
-  MemoText,
   BalanceKind,
   // Customize Address type for joystream chain
   Address,

Some files were not shown because too many files changed in this diff