Przeglądaj źródła

Merge pull request #3190 from Lezek123/olympia-cli-wg-metadata

Olympia CLI: Working groups metadata / metaprotocol support
Lezek123 3 lat temu
rodzic
commit
2b95361532

+ 110 - 22
cli/README.md

@@ -132,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)
@@ -911,7 +913,7 @@ _See code: [src/commands/forum/addPost.ts](https://github.com/Joystream/joystrea
 
 ## `joystream-cli forum:categories`
 
-List existing forum categories.
+List existing forum categories by parent id (root categories by default) or displays a category tree.
 
 ```
 USAGE
@@ -1283,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, 
@@ -1302,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
 
@@ -1341,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
@@ -1368,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
 
@@ -1440,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, 
@@ -1459,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
 
@@ -1523,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, 
@@ -1542,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
 
@@ -1553,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
@@ -1567,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
 
@@ -1601,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.
@@ -1657,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)

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

+ 1 - 1
cli/package.json

@@ -138,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",

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

+ 75 - 3
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,6 +16,7 @@ import {
   OpeningDetails,
   UnaugmentedApiPromise,
   MemberDetails,
+  AvailableGroups,
 } from './Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
@@ -308,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),
@@ -319,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[]> {
@@ -335,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))
     )
   }
 
@@ -356,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 = {
@@ -370,6 +375,7 @@ export default class Api {
       stake,
       createdAtBlock: opening.created.toNumber(),
       rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
+      metadata: qnData?.metadata || undefined,
     }
   }
 
@@ -458,6 +464,72 @@ export default class Api {
     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)

+ 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

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

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