Procházet zdrojové kódy

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

Leszek Wiesner před 3 roky
rodič
revize
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",
     "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",

+ 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 { 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'
@@ -313,9 +314,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),
@@ -324,12 +327,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[]> {
@@ -340,7 +344,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))
     )
   }
 
@@ -361,6 +365,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 = {
@@ -375,6 +380,7 @@ export default class Api {
       stake,
       createdAtBlock: opening.created.toNumber(),
       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)
     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 {
   ApolloClient,
   InMemoryCache,
@@ -28,10 +28,20 @@ import {
   GetMembersByIdsQuery,
   GetMembersByIdsQueryVariables,
   MembershipFieldsFragment,
+  WorkingGroupOpeningDetailsFragment,
+  OpeningDetailsByIdQuery,
+  OpeningDetailsByIdQueryVariables,
+  OpeningDetailsById,
+  WorkingGroupApplicationDetailsFragment,
+  ApplicationDetailsByIdQuery,
+  ApplicationDetailsByIdQueryVariables,
+  ApplicationDetailsById,
 } 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>
@@ -137,4 +147,26 @@ 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'
+    )
+  }
 }

+ 28 - 5
cli/src/Types.ts

@@ -15,9 +15,14 @@ import {
   IVideoMetadata,
   IVideoCategoryMetadata,
   IChannelCategoryMetadata,
+  IOpeningMetadata,
 } 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.
@@ -88,6 +93,7 @@ export type ApplicationDetails = {
   rewardAccount: AccountId
   descriptionHash: string
   openingId: number
+  answers?: WorkingGroupApplicationDetailsFragment['answers']
 }
 
 export type OpeningDetails = {
@@ -100,6 +106,7 @@ export type OpeningDetails = {
   type: OpeningType
   createdAtBlock: number
   rewardPerBlock?: Balance
+  metadata?: WorkingGroupOpeningDetailsFragment['metadata']
 }
 
 // Extended membership information (including optional query node data)
@@ -185,7 +192,19 @@ 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'
+  }[]
+}
+
+type AnyPrimitive = string | number | boolean | Long
 
 // JSONSchema utility types
 
@@ -199,18 +218,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]>

+ 26 - 10
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)) {
@@ -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)
     if (missingStakingAccountBalance.gtn(0)) {
       this.warn(
         `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(
         `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
   }
 
-  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) {

+ 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 QueryNodeApi from '../QueryNodeApi'
 import { formatBalance } from '@polkadot/util'
+import cli from 'cli-ux'
 import BN from 'bn.js'
 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!")
         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 +116,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
         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 { 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())
   }
 }

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

@@ -1,11 +1,9 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
-import { GroupMember } from '../../Types'
+import { 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'
@@ -13,6 +11,9 @@ import { AugmentedSubmittables } from '@polkadot/api/types'
 import { formatBalance } from '@polkadot/util'
 import BN from 'bn.js'
 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)
 
@@ -39,14 +40,29 @@ 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)",
+    }),
     ...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 +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 openingPrompt = new JsonSchemaPrompter<OpeningParamsJson>(
-      (OpeningParamsSchema as unknown) as JSONSchema,
+    const openingPrompt = new JsonSchemaPrompter<WorkingGroupOpeningInputParameters>(
+      WorkingGroupOpeningInputSchema,
       openingDefaults
     )
     const openingParamsJson = await openingPrompt.promptAll()
@@ -68,13 +86,11 @@ 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> {
+  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.`)
 
     const [balances] = await this.getApi().getAccountsBalancesInfo([stakingAccount])
@@ -85,8 +101,10 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
           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,
       ])
@@ -98,37 +116,30 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
     const lead = await this.getRequiredLeadContext()
 
     const {
-      flags: { input, output, edit, dryRun },
+      flags: { input, output, edit, dryRun, stakeTopUpSource },
     } = this.parse(WorkingGroupsCreateOpening)
 
     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())
+      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) {
-        tryAgain = await this.simplePrompt({ type: 'confirm', message: 'Try again with remembered input?' })
+        tryAgain = await this.requestConfirmation('Try again with remembered input?')
         continue
       }
 
@@ -148,17 +159,19 @@ export default class WorkingGroupsCreateOpening extends WorkingGroupsCommandBase
 
       // Send the tx
       try {
-        await this.sendAndFollowTx(
+        const result = await this.sendAndFollowTx(
           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
       } 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),

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

@@ -1,6 +1,7 @@
 import WorkingGroupsCommandBase from '../../base/WorkingGroupsCommandBase'
 import { displayTable, displayCollapsedRow, displayHeader, shortAddress, memberHandle } from '../../helpers/display'
 import { formatBalance } from '@polkadot/util'
+import moment from 'moment'
 
 export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
   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))
 
-    // TODO: Opening desc?
-
     displayHeader('Opening details')
     const openingRow = {
       'Opening ID': opening.openingId,
@@ -43,6 +42,23 @@ export default class WorkingGroupsOpening extends WorkingGroupsCommandBase {
       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})`)
     const applicationsRows = opening.applications.map((a) => ({
       '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 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`
   fragment MemberMetadataFields on MemberMetadata {
     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`
   query getMembersByIds($ids: [ID!]) {
     memberships(where: { id_in: $ids }) {
@@ -152,3 +208,19 @@ 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}
+`

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