Browse Source

CLI: Working groups openings / applications metadata support, lock handling fix

Leszek Wiesner 3 years ago
parent
commit
2cd87c7659

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

+ 1 - 1
cli/package.json

@@ -131,7 +131,7 @@
     "posttest": "yarn lint",
     "posttest": "yarn lint",
     "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
     "prepack": "rm -rf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
     "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
     "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",
     "version": "oclif-dev readme && git add README.md",
     "lint": "eslint ./src --ext .ts",
     "lint": "eslint ./src --ext .ts",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",
     "checks": "tsc --noEmit --pretty && prettier ./ --check && yarn lint",

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

@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+echo "{}" > ~/tmp/empty.json
+
+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
+# 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
+${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 { ApiPromise, WsProvider } from '@polkadot/api'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { SubmittableExtrinsic, AugmentedQuery } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
 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 { KeyringPair } from '@polkadot/keyring/types'
 import { Codec, Observable } from '@polkadot/types/types'
 import { Codec, Observable } from '@polkadot/types/types'
 import { UInt } from '@polkadot/types'
 import { UInt } from '@polkadot/types'
@@ -16,6 +16,7 @@ import {
   OpeningDetails,
   OpeningDetails,
   UnaugmentedApiPromise,
   UnaugmentedApiPromise,
   MemberDetails,
   MemberDetails,
+  AvailableGroups,
 } from './Types'
 } from './Types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 import { CLIError } from '@oclif/errors'
@@ -313,9 +314,11 @@ export default class Api {
   }
   }
 
 
   protected async fetchApplicationDetails(
   protected async fetchApplicationDetails(
+    group: WorkingGroups,
     applicationId: number,
     applicationId: number,
     application: Application
     application: Application
   ): Promise<ApplicationDetails> {
   ): Promise<ApplicationDetails> {
+    const qnData = await this._qnApi?.applicationDetailsById(group, applicationId)
     return {
     return {
       applicationId,
       applicationId,
       member: await this.expectedMemberDetailsById(application.member_id),
       member: await this.expectedMemberDetailsById(application.member_id),
@@ -324,12 +327,13 @@ export default class Api {
       stakingAccount: application.staking_account_id,
       stakingAccount: application.staking_account_id,
       descriptionHash: application.description_hash.toString(),
       descriptionHash: application.description_hash.toString(),
       openingId: application.opening_id.toNumber(),
       openingId: application.opening_id.toNumber(),
+      answers: qnData?.answers,
     }
     }
   }
   }
 
 
   async groupApplication(group: WorkingGroups, applicationId: number): Promise<ApplicationDetails> {
   async groupApplication(group: WorkingGroups, applicationId: number): Promise<ApplicationDetails> {
     const application = await this.applicationById(group, applicationId)
     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[]> {
   protected async groupOpeningApplications(group: WorkingGroups, openingId: number): Promise<ApplicationDetails[]> {
@@ -340,7 +344,7 @@ export default class Api {
     return Promise.all(
     return Promise.all(
       applicationEntries
       applicationEntries
         .filter(([, application]) => application.opening_id.eqn(openingId))
         .filter(([, application]) => application.opening_id.eqn(openingId))
-        .map(([id, application]) => this.fetchApplicationDetails(id.toNumber(), application))
+        .map(([id, application]) => this.fetchApplicationDetails(group, id.toNumber(), application))
     )
     )
   }
   }
 
 
@@ -361,6 +365,7 @@ export default class Api {
   }
   }
 
 
   async fetchOpeningDetails(group: WorkingGroups, opening: Opening, openingId: number): Promise<OpeningDetails> {
   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 applications = await this.groupOpeningApplications(group, openingId)
     const type = opening.opening_type
     const type = opening.opening_type
     const stake = {
     const stake = {
@@ -375,6 +380,7 @@ export default class Api {
       stake,
       stake,
       createdAtBlock: opening.created.toNumber(),
       createdAtBlock: opening.created.toNumber(),
       rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
       rewardPerBlock: opening.reward_per_block.unwrapOr(undefined),
+      metadata: qnData?.metadata || undefined,
     }
     }
   }
   }
 
 
@@ -462,4 +468,70 @@ export default class Api {
     const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
     const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
     return !existingMeber.isEmpty
     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)))
+  }
 }
 }

+ 33 - 1
cli/src/QueryNodeApi.ts

@@ -1,4 +1,4 @@
-import { StorageNodeInfo } from './Types'
+import { StorageNodeInfo, WorkingGroups } from './Types'
 import {
 import {
   ApolloClient,
   ApolloClient,
   InMemoryCache,
   InMemoryCache,
@@ -28,10 +28,20 @@ import {
   GetMembersByIdsQuery,
   GetMembersByIdsQuery,
   GetMembersByIdsQueryVariables,
   GetMembersByIdsQueryVariables,
   MembershipFieldsFragment,
   MembershipFieldsFragment,
+  WorkingGroupOpeningDetailsFragment,
+  OpeningDetailsByIdQuery,
+  OpeningDetailsByIdQueryVariables,
+  OpeningDetailsById,
+  WorkingGroupApplicationDetailsFragment,
+  ApplicationDetailsByIdQuery,
+  ApplicationDetailsByIdQueryVariables,
+  ApplicationDetailsById,
 } from './graphql/generated/queries'
 } from './graphql/generated/queries'
 import { URL } from 'url'
 import { URL } from 'url'
 import fetch from 'cross-fetch'
 import fetch from 'cross-fetch'
 import { MemberId } from '@joystream/types/common'
 import { MemberId } from '@joystream/types/common'
+import { ApplicationId, OpeningId } from '@joystream/types/working-group'
+import { apiModuleByGroup } from './Api'
 
 
 export default class QueryNodeApi {
 export default class QueryNodeApi {
   private _qnClient: ApolloClient<NormalizedCacheObject>
   private _qnClient: ApolloClient<NormalizedCacheObject>
@@ -137,4 +147,26 @@ export default class QueryNodeApi {
       'memberships'
       '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'
+    )
+  }
 }
 }

+ 28 - 5
cli/src/Types.ts

@@ -15,9 +15,14 @@ import {
   IVideoMetadata,
   IVideoMetadata,
   IVideoCategoryMetadata,
   IVideoCategoryMetadata,
   IChannelCategoryMetadata,
   IChannelCategoryMetadata,
+  IOpeningMetadata,
 } from '@joystream/metadata-protobuf'
 } from '@joystream/metadata-protobuf'
 import { DataObjectCreationParameters } from '@joystream/types/storage'
 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"
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
 // It's used for accounts/keys management within CLI.
@@ -88,6 +93,7 @@ export type ApplicationDetails = {
   rewardAccount: AccountId
   rewardAccount: AccountId
   descriptionHash: string
   descriptionHash: string
   openingId: number
   openingId: number
+  answers?: WorkingGroupApplicationDetailsFragment['answers']
 }
 }
 
 
 export type OpeningDetails = {
 export type OpeningDetails = {
@@ -100,6 +106,7 @@ export type OpeningDetails = {
   type: OpeningType
   type: OpeningType
   createdAtBlock: number
   createdAtBlock: number
   rewardPerBlock?: Balance
   rewardPerBlock?: Balance
+  metadata?: WorkingGroupOpeningDetailsFragment['metadata']
 }
 }
 
 
 // Extended membership information (including optional query node data)
 // Extended membership information (including optional query node data)
@@ -185,7 +192,19 @@ export type ChannelCategoryInputParameters = IChannelCategoryMetadata
 
 
 export type VideoCategoryInputParameters = IVideoCategoryMetadata
 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'
+  }[]
+}
+
+type AnyPrimitive = string | number | boolean | Long
 
 
 // JSONSchema utility types
 // JSONSchema utility types
 
 
@@ -199,18 +218,22 @@ type AnyJSONSchema = RemoveIndex<JSONSchema4>
 export type JSONTypeName<T> = T extends string
 export type JSONTypeName<T> = T extends string
   ? 'string' | ['string', 'null']
   ? 'string' | ['string', 'null']
   : T extends number
   : T extends number
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : T extends boolean
   : T extends boolean
   ? 'boolean' | ['boolean', 'null']
   ? 'boolean' | ['boolean', 'null']
   : T extends any[]
   : T extends any[]
   ? 'array' | ['array', 'null']
   ? 'array' | ['array', 'null']
   : T extends Long
   : T extends Long
-  ? 'number' | ['number', 'null']
+  ? 'number' | ['number', 'null'] | 'integer' | ['integer', 'null']
   : 'object' | ['object', 'null']
   : 'object' | ['object', 'null']
 
 
 export type PropertySchema<P> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
 export type PropertySchema<P> = Omit<AnyJSONSchema, 'type' | 'properties'> & {
   type: JSONTypeName<P>
   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> = {
 export type JsonSchemaProperties<T> = {
   [K in keyof Required<T>]: PropertySchema<Required<T>[K]>
   [K in keyof Required<T>]: PropertySchema<Required<T>[K]>

+ 26 - 10
cli/src/base/AccountsCommandBase.ts

@@ -18,6 +18,7 @@ import { mnemonicGenerate } from '@polkadot/util-crypto'
 import { validateAddress } from '../helpers/validation'
 import { validateAddress } from '../helpers/validation'
 import slug from 'slug'
 import slug from 'slug'
 import { Membership } from '@joystream/types/members'
 import { Membership } from '@joystream/types/members'
+import { LockIdentifier } from '@polkadot/types/interfaces'
 import BN from 'bn.js'
 import BN from 'bn.js'
 
 
 const ACCOUNTS_DIRNAME = 'accounts'
 const ACCOUNTS_DIRNAME = 'accounts'
@@ -333,7 +334,8 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     member: Membership,
     member: Membership,
     address?: string,
     address?: string,
     requiredStake: BN = new BN(0),
     requiredStake: BN = new BN(0),
-    fundsSource?: string
+    fundsSource?: string,
+    lockId?: LockIdentifier
   ): Promise<string> {
   ): Promise<string> {
     if (fundsSource && !this.isKeyAvailable(fundsSource)) {
     if (fundsSource && !this.isKeyAvailable(fundsSource)) {
       throw new CLIError(`Key ${chalk.magentaBright(fundsSource)} is not available!`)
       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 { balances } = await this.getApi().getAccountSummary(address)
     const stakingStatus = await this.getApi().stakingAccountStatus(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)) {
     if (stakingStatus && !stakingStatus.member_id.eq(memberId)) {
@@ -374,15 +378,22 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
       }
       }
     }
     }
 
 
-    const requiredStakingAccountBalance = requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+    const requiredStakingAccountBalance = !stakingStatus
+      ? requiredStake.add(candidateTxFee).add(STAKING_ACCOUNT_CANDIDATE_STAKE)
+      : requiredStake
     const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
     const missingStakingAccountBalance = requiredStakingAccountBalance.sub(balances.availableBalance)
     if (missingStakingAccountBalance.gtn(0)) {
     if (missingStakingAccountBalance.gtn(0)) {
       this.warn(
       this.warn(
         `Not enough available staking account balance! Missing: ${chalk.cyanBright(
         `Not enough available staking account balance! Missing: ${chalk.cyanBright(
-          formatBalance(candidateTxFee.add(STAKING_ACCOUNT_CANDIDATE_STAKE))
-        )}. (includes ${chalk.cyanBright(formatBalance(candidateTxFee))} transaction fee and ${chalk.cyanBright(
-          formatBalance(STAKING_ACCOUNT_CANDIDATE_STAKE)
-        )} staking account candidate stake)`
+          formatBalance(missingStakingAccountBalance)
+        )}.` +
+          (!stakingStatus
+            ? ` (required balance includes ${chalk.cyanBright(
+                formatBalance(candidateTxFee)
+              )} transaction fee and ${chalk.cyanBright(
+                formatBalance(STAKING_ACCOUNT_CANDIDATE_STAKE)
+              )} staking account candidate stake)`
+            : '')
       )
       )
       const transferTokens = await this.requestConfirmation(
       const transferTokens = await this.requestConfirmation(
         `Do you want to transfer ${chalk.cyan(formatBalance(missingStakingAccountBalance))} from another account?`
         `Do you want to transfer ${chalk.cyan(formatBalance(missingStakingAccountBalance))} from another account?`
@@ -416,12 +427,17 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     return address
     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)}`)
     this.log(`Required stake: ${formatBalance(requiredStake)}`)
     while (true) {
     while (true) {
       const stakingAccount = await this.promptForAnyAddress('Choose staking account')
       const stakingAccount = await this.promptForAnyAddress('Choose staking account')
       try {
       try {
-        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake)
+        await this.setupStakingAccount(memberId, member, stakingAccount.toString(), requiredStake, undefined, lockId)
         return stakingAccount
         return stakingAccount
       } catch (e) {
       } catch (e) {
         if (e instanceof CLIError) {
         if (e instanceof CLIError) {

+ 12 - 5
cli/src/base/ApiCommandBase.ts

@@ -17,6 +17,7 @@ import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import QueryNodeApi from '../QueryNodeApi'
 import QueryNodeApi from '../QueryNodeApi'
 import { formatBalance } from '@polkadot/util'
 import { formatBalance } from '@polkadot/util'
+import cli from 'cli-ux'
 import BN from 'bn.js'
 import BN from 'bn.js'
 import _ from 'lodash'
 import _ from 'lodash'
 
 
@@ -93,13 +94,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!")
         this.warn("You haven't provided a Joystream query node uri for the CLI to connect to yet!")
         queryNodeUri = await this.promptForQueryNodeUri()
         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
       // Substrate api
+      cli.action.start(`Initializing the api connection (${apiUri})...`)
       const { metadataCache } = this.getPreservedState()
       const { metadataCache } = this.getPreservedState()
       this.api = await Api.create(apiUri, metadataCache, this.queryNodeApi || undefined)
       this.api = await Api.create(apiUri, metadataCache, this.queryNodeApi || undefined)
 
 
@@ -110,6 +116,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
         metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
         await this.setPreservedState({ metadataCache })
         await this.setPreservedState({ metadataCache })
       }
       }
+      cli.action.stop()
     }
     }
   }
   }
 
 

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

@@ -1,5 +1,6 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
 import { displayCollapsedRow, displayHeader, memberHandle } from '../../helpers/display'
+import chalk from 'chalk'
 
 
 export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
 export default class WorkingGroupsApplication extends WorkingGroupsCommandBase {
   static description = 'Shows an overview of given application by Working Group Application ID'
   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`)
     displayHeader(`Details`)
     const applicationRow = {
     const applicationRow = {
       'Application ID': application.applicationId,
       'Application ID': application.applicationId,
+      'Opening ID': application.openingId.toString(),
       'Member handle': memberHandle(application.member),
       'Member handle': memberHandle(application.member),
       'Role account': application.roleAccout.toString(),
       'Role account': application.roleAccout.toString(),
       'Reward account': application.rewardAccount.toString(),
       'Reward account': application.rewardAccount.toString(),
       'Staking account': application.stakingAccount.toString(),
       'Staking account': application.stakingAccount.toString(),
-      'Description': application.descriptionHash.toString(),
-      'Opening ID': application.openingId.toString(),
     }
     }
     displayCollapsedRow(applicationRow)
     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 { Option } from '@polkadot/types'
 import { apiModuleByGroup } from '../../Api'
 import { apiModuleByGroup } from '../../Api'
 import { CreateInterface } from '@joystream/types'
 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 {
 export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
   static description = 'Apply to a working group opening (requires a membership)'
   static description = 'Apply to a working group opening (requires a membership)'
-  static args = [
-    {
-      name: 'openingId',
-      description: 'Opening ID',
-      required: false,
-    },
-  ]
 
 
   static flags = {
   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,
     ...WorkingGroupsCommandBase.flags,
   }
   }
 
 
   async run(): Promise<void> {
   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 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
     let stakeParams: CreateInterface<Option<StakeParameters>> = null
+    const stakeLockId = this.getOriginalApi().consts[apiModuleByGroup[this.group]].stakingHandlerLockId
     if (opening.stake) {
     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 = {
       stakeParams = {
         stake: opening.stake.value,
         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()),
       await this.getDecodedPair(memberContext.membership.controller_account.toString()),
       apiModuleByGroup[this.group],
       apiModuleByGroup[this.group],
       'applyOnOpening',
       'applyOnOpening',
@@ -57,9 +117,12 @@ export default class WorkingGroupsApply extends WorkingGroupsCommandBase {
           role_account_id: roleAccount,
           role_account_id: roleAccount,
           reward_account_id: rewardAccount,
           reward_account_id: rewardAccount,
           stake_parameters: stakeParams,
           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())
   }
   }
 }
 }

+ 47 - 34
cli/src/commands/working-groups/createOpening.ts

@@ -1,11 +1,9 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { GroupMember } from '../../Types'
+import { WorkingGroupOpeningInputParameters } from '../../Types'
+import { WorkingGroupOpeningInputSchema } from '../../schemas/WorkingGroups'
 import chalk from 'chalk'
 import chalk from 'chalk'
 import { apiModuleByGroup } from '../../Api'
 import { apiModuleByGroup } from '../../Api'
 import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
 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 { IOFlags, getInputJson, ensureOutputFileIsWriteable, saveOutputJsonToFile } from '../../helpers/InputOutput'
 import ExitCodes from '../../ExitCodes'
 import ExitCodes from '../../ExitCodes'
 import { flags } from '@oclif/command'
 import { flags } from '@oclif/command'
@@ -13,6 +11,9 @@ import { AugmentedSubmittables } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
 import { formatBalance } from '@polkadot/util'
 import BN from 'bn.js'
 import BN from 'bn.js'
 import { CLIError } from '@oclif/errors'
 import { CLIError } from '@oclif/errors'
+import { IOpeningMetadata, OpeningMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { OpeningId } from '@joystream/types/working-group'
 
 
 const OPENING_STAKE = new BN(2000)
 const OPENING_STAKE = new BN(2000)
 
 
@@ -39,14 +40,29 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
         '(can be used to generate a "draft" which can be provided as input later)',
         '(can be used to generate a "draft" which can be provided as input later)',
       dependsOn: ['output'],
       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)",
+    }),
     ...WorkingGroupsCommandBase.flags,
     ...WorkingGroupsCommandBase.flags,
   }
   }
 
 
+  prepareMetadata(openingParamsJson: WorkingGroupOpeningInputParameters): IOpeningMetadata {
+    return {
+      ...openingParamsJson,
+      applicationFormQuestions: openingParamsJson.applicationFormQuestions?.map((q) => ({
+        question: q.question,
+        type: OpeningMetadata.ApplicationFormQuestion.InputType[q.type],
+      })),
+    }
+  }
+
   createTxParams(
   createTxParams(
-    openingParamsJson: OpeningParamsJson
+    openingParamsJson: WorkingGroupOpeningInputParameters
   ): Parameters<AugmentedSubmittables<'promise'>['membershipWorkingGroup']['addOpening']> {
   ): Parameters<AugmentedSubmittables<'promise'>['membershipWorkingGroup']['addOpening']> {
     return [
     return [
-      openingParamsJson.description,
+      metadataToBytes(OpeningMetadata, this.prepareMetadata(openingParamsJson)),
       'Regular',
       'Regular',
       {
       {
         stake_amount: openingParamsJson.stakingPolicy.amount,
         stake_amount: openingParamsJson.stakingPolicy.amount,
@@ -57,10 +73,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 openingDefaults = rememberedInput
-    const openingPrompt = new JsonSchemaPrompter<OpeningParamsJson>(
-      (OpeningParamsSchema as unknown) as JSONSchema,
+    const openingPrompt = new JsonSchemaPrompter<WorkingGroupOpeningInputParameters>(
+      WorkingGroupOpeningInputSchema,
       openingDefaults
       openingDefaults
     )
     )
     const openingParamsJson = await openingPrompt.promptAll()
     const openingParamsJson = await openingPrompt.promptAll()
@@ -68,13 +86,11 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     return openingParamsJson
     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> {
+  async promptForStakeTopUp(stakingAccount: string, fundsSource?: string): Promise<void> {
     this.log(`You need to stake ${chalk.bold(formatBalance(OPENING_STAKE))} in order to create a new opening.`)
     this.log(`You need to stake ${chalk.bold(formatBalance(OPENING_STAKE))} in order to create a new opening.`)
 
 
     const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
     const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
@@ -85,8 +101,10 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
           formatBalance(missingBalance)
           formatBalance(missingBalance)
         )} to your staking account? (${stakingAccount})`
         )} 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,
         stakingAccount,
         missingBalance,
         missingBalance,
       ])
       ])
@@ -98,37 +116,30 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     const lead = await this.getRequiredLeadContext()
     const lead = await this.getRequiredLeadContext()
 
 
     const {
     const {
-      flags: { input, output, edit, dryRun },
+      flags: { input, output, edit, dryRun, stakeTopUpSource },
     } = this.parse(WorkingGroupsCreateOpening)
     } = this.parse(WorkingGroupsCreateOpening)
 
 
     ensureOutputFileIsWriteable(output)
     ensureOutputFileIsWriteable(output)
 
 
     let tryAgain = false
     let tryAgain = false
-    let rememberedInput: OpeningParamsJson | undefined
+    let rememberedInput: WorkingGroupOpeningInputParameters | undefined
     do {
     do {
       if (edit) {
       if (edit) {
         rememberedInput = await this.getInputFromFile(input as string)
         rememberedInput = await this.getInputFromFile(input as string)
       }
       }
       // Either prompt for the data or get it from input file
       // Either prompt for the data or get it from input file
       const openingJson =
       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
       // Remember the provided/fetched data in a variable
       rememberedInput = openingJson
       rememberedInput = openingJson
 
 
-      await this.promptForStakeTopUp(lead.stakingAccount.toString())
+      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?',
-      })
+      this.jsonPrettyPrint(JSON.stringify(rememberedInput))
+      const confirmed = await this.requestConfirmation('Do you confirm the provided input?')
       if (!confirmed) {
       if (!confirmed) {
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
         continue
         continue
       }
       }
 
 
@@ -148,17 +159,19 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
 
       // Send the tx
       // Send the tx
       try {
       try {
-        await this.sendAndFollowTx(
+        const result = await this.sendAndFollowTx(
           await this.getDecodedPair(lead.roleAccount),
           await this.getDecodedPair(lead.roleAccount),
-          this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...txParams)
+          this.getOriginalApi().tx[apiModuleByGroup[this.group]].addOpening(...this.createTxParams(rememberedInput))
         )
         )
-        this.log(chalk.green('Opening successfully created!'))
+        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())
         tryAgain = false
         tryAgain = false
       } catch (e) {
       } catch (e) {
         if (e instanceof CLIError) {
         if (e instanceof CLIError) {
           this.warn(e.message)
           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)
     } 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 { apiModuleByGroup } from '../../Api'
 import chalk from 'chalk'
 import chalk from 'chalk'
 import { createType } from '@joystream/types'
 import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
 
 
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
 export default class WorkingGroupsFillOpening extends WorkingGroupsCommandBase {
   static description = "Allows filling working group opening that's currently in review. Requires lead access."
   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 = {
   static flags = {
+    openingId: flags.integer({
+      required: true,
+      description: 'Working Group Opening ID',
+    }),
+    applicationIds: flags.integer({
+      multiple: true,
+      description: 'Accepted application ids',
+    }),
     ...WorkingGroupsCommandBase.flags,
     ...WorkingGroupsCommandBase.flags,
   }
   }
 
 
   async run(): Promise<void> {
   async run(): Promise<void> {
-    const { args } = this.parse(WorkingGroupsFillOpening)
+    let { openingId, applicationIds } = this.parse(WorkingGroupsFillOpening).flags
 
 
     // Lead-only gate
     // Lead-only gate
     const lead = await this.getRequiredLeadContext()
     const lead = await this.getRequiredLeadContext()
 
 
-    const openingId = parseInt(args.wgOpeningId)
     const opening = await this.getOpeningForLeadAction(openingId)
     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.sendAndFollowNamedTx(
       await this.getDecodedPair(lead.roleAccount),
       await this.getDecodedPair(lead.roleAccount),

+ 18 - 2
cli/src/commands/working-groups/opening.ts

@@ -1,6 +1,7 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayTable, displayCollapsedRow, displayHeader, shortAddress, memberHandle } from '../../helpers/display'
 import { displayTable, displayCollapsedRow, displayHeader, shortAddress, memberHandle } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
 import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
 
 
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
   static description = 'Shows an overview of given working group opening by Working Group Opening ID'
   static description = 'Shows an overview of given working group opening by Working Group Opening ID'
@@ -21,8 +22,6 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
 
 
     const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))
     const opening = await this.getApi().groupOpening(this.group, parseInt(args.wgOpeningId))
 
 
-    // TODO: Opening desc?
-
     displayHeader('Opening details')
     displayHeader('Opening details')
     const openingRow = {
     const openingRow = {
       'Opening ID': opening.openingId,
       'Opening ID': opening.openingId,
@@ -43,6 +42,23 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
       this.log('NONE')
       this.log('NONE')
     }
     }
 
 
+    if (opening.metadata) {
+      delete (opening.metadata as any).__typename
+      displayHeader('Metadata')
+      this.jsonPrettyPrint(
+        JSON.stringify({
+          ...opening.metadata,
+          applicationFormQuestions: opening.metadata.applicationFormQuestions.map(({ question, type }) => ({
+            question,
+            type,
+          })),
+          expectedEnding: opening.metadata.expectedEnding
+            ? moment(opening.metadata.expectedEnding).format('YYYY-MM-DD HH:mm:ss')
+            : undefined,
+        })
+      )
+    }
+
     displayHeader(`Applications (${opening.applications.length})`)
     displayHeader(`Applications (${opening.applications.length})`)
     const applicationsRows = opening.applications.map((a) => ({
     const applicationsRows = opening.applications.map((a) => ({
       'ID': a.applicationId,
       'ID': a.applicationId,

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

@@ -52,6 +52,37 @@ export type GetDataObjectsByVideoIdQueryVariables = Types.Exact<{
 
 
 export type GetDataObjectsByVideoIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 export type GetDataObjectsByVideoIdQuery = { storageDataObjects: Array<DataObjectInfoFragment> }
 
 
+export type WorkingGroupOpeningDetailsFragment = {
+  metadata: {
+    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 WorkingGroupApplicationDetailsFragment = {
+  answers: Array<{ answer: string; question: { question?: Types.Maybe<string> } }>
+}
+
+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 const MemberMetadataFields = gql`
 export const MemberMetadataFields = gql`
   fragment MemberMetadataFields on MemberMetadata {
   fragment MemberMetadataFields on MemberMetadata {
     name
     name
@@ -106,6 +137,31 @@ export const DataObjectInfo = gql`
     }
     }
   }
   }
 `
 `
+export const WorkingGroupOpeningDetails = gql`
+  fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+    metadata {
+      description
+      shortDescription
+      hiringLimit
+      expectedEnding
+      applicationDetails
+      applicationFormQuestions {
+        question
+        type
+      }
+    }
+  }
+`
+export const WorkingGroupApplicationDetails = gql`
+  fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+    answers {
+      question {
+        question
+      }
+      answer
+    }
+  }
+`
 export const GetMembersByIds = gql`
 export const GetMembersByIds = gql`
   query getMembersByIds($ids: [ID!]) {
   query getMembersByIds($ids: [ID!]) {
     memberships(where: { id_in: $ids }) {
     memberships(where: { id_in: $ids }) {
@@ -152,3 +208,19 @@ export const GetDataObjectsByVideoId = gql`
   }
   }
   ${DataObjectInfo}
   ${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}
+`

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

@@ -0,0 +1,34 @@
+fragment WorkingGroupOpeningDetails on WorkingGroupOpening {
+  metadata {
+    description
+    shortDescription
+    hiringLimit
+    expectedEnding
+    applicationDetails
+    applicationFormQuestions {
+      question
+      type
+    }
+  }
+}
+
+fragment WorkingGroupApplicationDetails on WorkingGroupApplication {
+  answers {
+    question {
+      question
+    }
+    answer
+  }
+}
+
+query openingDetailsById($id: ID!) {
+  workingGroupOpeningByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupOpeningDetails
+  }
+}
+
+query applicationDetailsById($id: ID!) {
+  workingGroupApplicationByUniqueInput(where: { id: $id }) {
+    ...WorkingGroupApplicationDetails
+  }
+}

+ 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: {
     publishedBeforeJoystream: {
       type: 'object',
       type: 'object',
       properties: {
       properties: {

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

@@ -0,0 +1,57 @@
+import { WorkingGroupOpeningInputParameters, 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,
+    },
+  },
+}