WorkingGroupsCommandBase.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import ExitCodes from '../ExitCodes'
  2. import AccountsCommandBase from './AccountsCommandBase'
  3. import { flags } from '@oclif/command'
  4. import {
  5. WorkingGroups,
  6. AvailableGroups,
  7. NamedKeyringPair,
  8. GroupMember,
  9. GroupOpening,
  10. ApiMethodArg,
  11. ApiMethodNamedArgs,
  12. OpeningStatus,
  13. GroupApplication,
  14. } from '../Types'
  15. import { apiModuleByGroup } from '../Api'
  16. import { CLIError } from '@oclif/errors'
  17. import fs from 'fs'
  18. import path from 'path'
  19. import _ from 'lodash'
  20. import { ApplicationStageKeys } from '@joystream/types/hiring'
  21. import chalk from 'chalk'
  22. const DEFAULT_GROUP = WorkingGroups.StorageProviders
  23. const DRAFTS_FOLDER = 'opening-drafts'
  24. /**
  25. * Abstract base class for commands related to working groups
  26. */
  27. export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
  28. group: WorkingGroups = DEFAULT_GROUP
  29. static flags = {
  30. group: flags.string({
  31. char: 'g',
  32. description:
  33. 'The working group context in which the command should be executed\n' +
  34. `Available values are: ${AvailableGroups.join(', ')}.`,
  35. required: true,
  36. default: DEFAULT_GROUP,
  37. }),
  38. }
  39. // Use when lead access is required in given command
  40. async getRequiredLead(): Promise<GroupMember> {
  41. const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
  42. const lead = await this.getApi().groupLead(this.group)
  43. if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
  44. this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
  45. }
  46. return lead
  47. }
  48. // Use when worker access is required in given command
  49. async getRequiredWorker(): Promise<GroupMember> {
  50. const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
  51. const groupMembers = await this.getApi().groupMembers(this.group)
  52. const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
  53. if (!groupMembersByAccount.length) {
  54. this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
  55. } else if (groupMembersByAccount.length === 1) {
  56. return groupMembersByAccount[0]
  57. } else {
  58. return await this.promptForWorker(groupMembersByAccount)
  59. }
  60. }
  61. // Use when member controller access is required, but one of the associated roles is expected to be selected
  62. async getRequiredWorkerByMemberController(): Promise<GroupMember> {
  63. const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
  64. const memberIds = await this.getApi().getMemberIdsByControllerAccount(selectedAccount.address)
  65. const controlledWorkers = (await this.getApi().groupMembers(this.group)).filter((groupMember) =>
  66. memberIds.some((memberId) => groupMember.memberId.eq(memberId))
  67. )
  68. if (!controlledWorkers.length) {
  69. this.error(`Member controller account with some associated ${this.group} group roles needs to be selected!`, {
  70. exit: ExitCodes.AccessDenied,
  71. })
  72. } else if (controlledWorkers.length === 1) {
  73. return controlledWorkers[0]
  74. } else {
  75. return await this.promptForWorker(controlledWorkers)
  76. }
  77. }
  78. async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
  79. const chosenWorkerIndex = await this.simplePrompt({
  80. message: 'Choose the intended worker context:',
  81. type: 'list',
  82. choices: groupMembers.map((groupMember, index) => ({
  83. name: `Worker ID ${groupMember.workerId.toString()}`,
  84. value: index,
  85. })),
  86. })
  87. return groupMembers[chosenWorkerIndex]
  88. }
  89. async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
  90. const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)
  91. const acceptedApplications = await this.simplePrompt({
  92. message: 'Select succesful applicants',
  93. type: 'checkbox',
  94. choices: acceptableApplications.map((a) => ({
  95. name: ` ${a.wgApplicationId}: ${a.member?.handle.toString()}`,
  96. value: a.wgApplicationId,
  97. })),
  98. })
  99. return acceptedApplications
  100. }
  101. async promptForNewOpeningDraftName() {
  102. let draftName = ''
  103. let fileExists = false
  104. let overrideConfirmed = false
  105. do {
  106. draftName = await this.simplePrompt({
  107. type: 'input',
  108. message: 'Provide the draft name',
  109. validate: (val) => (typeof val === 'string' && val.length >= 1) || 'Draft name is required!',
  110. })
  111. fileExists = fs.existsSync(this.getOpeningDraftPath(draftName))
  112. if (fileExists) {
  113. overrideConfirmed = await this.simplePrompt({
  114. type: 'confirm',
  115. message: 'Such draft already exists. Do you wish to override it?',
  116. default: false,
  117. })
  118. }
  119. } while (fileExists && !overrideConfirmed)
  120. return draftName
  121. }
  122. async promptForOpeningDraft() {
  123. let draftFiles: string[] = []
  124. try {
  125. draftFiles = fs.readdirSync(this.getOpeingDraftsPath())
  126. } catch (e) {
  127. throw this.createDataReadError(DRAFTS_FOLDER)
  128. }
  129. if (!draftFiles.length) {
  130. throw new CLIError('No drafts available!', { exit: ExitCodes.FileNotFound })
  131. }
  132. const draftNames = draftFiles.map((fileName) => _.startCase(fileName.replace('.json', '')))
  133. const selectedDraftName = await this.simplePrompt({
  134. message: 'Select a draft',
  135. type: 'list',
  136. choices: draftNames,
  137. })
  138. return selectedDraftName
  139. }
  140. async getOpeningForLeadAction(id: number, requiredStatus?: OpeningStatus): Promise<GroupOpening> {
  141. const opening = await this.getApi().groupOpening(this.group, id)
  142. if (!opening.type.isOfType('Worker')) {
  143. this.error('A lead can only manage Worker openings!', { exit: ExitCodes.AccessDenied })
  144. }
  145. if (requiredStatus && opening.stage.status !== requiredStatus) {
  146. this.error(
  147. `The opening needs to be in "${_.startCase(requiredStatus)}" stage! ` +
  148. `This one is: "${_.startCase(opening.stage.status)}"`,
  149. { exit: ExitCodes.InvalidInput }
  150. )
  151. }
  152. return opening
  153. }
  154. // An alias for better code readibility in case we don't need the actual return value
  155. validateOpeningForLeadAction = this.getOpeningForLeadAction
  156. async getApplicationForLeadAction(id: number, requiredStatus?: ApplicationStageKeys): Promise<GroupApplication> {
  157. const application = await this.getApi().groupApplication(this.group, id)
  158. const opening = await this.getApi().groupOpening(this.group, application.wgOpeningId)
  159. if (!opening.type.isOfType('Worker')) {
  160. this.error('A lead can only manage Worker opening applications!', { exit: ExitCodes.AccessDenied })
  161. }
  162. if (requiredStatus && application.stage !== requiredStatus) {
  163. this.error(
  164. `The application needs to have "${_.startCase(requiredStatus)}" status! ` +
  165. `This one has: "${_.startCase(application.stage)}"`,
  166. { exit: ExitCodes.InvalidInput }
  167. )
  168. }
  169. return application
  170. }
  171. async getWorkerForLeadAction(id: number, requireStakeProfile = false) {
  172. const groupMember = await this.getApi().groupMember(this.group, id)
  173. const groupLead = await this.getApi().groupLead(this.group)
  174. if (groupLead?.workerId.eq(groupMember.workerId)) {
  175. this.error('A lead cannot manage his own role this way!', { exit: ExitCodes.AccessDenied })
  176. }
  177. if (requireStakeProfile && !groupMember.stake) {
  178. this.error('This worker has no associated role stake profile!', { exit: ExitCodes.InvalidInput })
  179. }
  180. return groupMember
  181. }
  182. // Helper for better TS handling.
  183. // We could also use some magic with conditional types instead, but those don't seem be very well supported yet.
  184. async getWorkerWithStakeForLeadAction(id: number) {
  185. return (await this.getWorkerForLeadAction(id, true)) as GroupMember & Required<Pick<GroupMember, 'stake'>>
  186. }
  187. loadOpeningDraftParams(draftName: string): ApiMethodNamedArgs {
  188. const draftFilePath = this.getOpeningDraftPath(draftName)
  189. const params = this.extrinsicArgsFromDraft(apiModuleByGroup[this.group], 'addOpening', draftFilePath)
  190. return params
  191. }
  192. getOpeingDraftsPath() {
  193. return path.join(this.getAppDataPath(), DRAFTS_FOLDER)
  194. }
  195. getOpeningDraftPath(draftName: string) {
  196. return path.join(this.getOpeingDraftsPath(), _.snakeCase(draftName) + '.json')
  197. }
  198. saveOpeningDraft(draftName: string, params: ApiMethodArg[]) {
  199. const paramsJson = JSON.stringify(
  200. params.map((p) => p.toJSON()),
  201. null,
  202. 2
  203. )
  204. try {
  205. fs.writeFileSync(this.getOpeningDraftPath(draftName), paramsJson)
  206. } catch (e) {
  207. throw this.createDataWriteError(DRAFTS_FOLDER)
  208. }
  209. }
  210. private initOpeningDraftsDir(): void {
  211. if (!fs.existsSync(this.getOpeingDraftsPath())) {
  212. fs.mkdirSync(this.getOpeingDraftsPath())
  213. }
  214. }
  215. async init() {
  216. await super.init()
  217. try {
  218. this.initOpeningDraftsDir()
  219. } catch (e) {
  220. throw this.createDataDirInitError()
  221. }
  222. const { flags } = this.parse(this.constructor as typeof WorkingGroupsCommandBase)
  223. if (!AvailableGroups.includes(flags.group as any)) {
  224. throw new CLIError(`Invalid group! Available values are: ${AvailableGroups.join(', ')}`, {
  225. exit: ExitCodes.InvalidInput,
  226. })
  227. }
  228. this.group = flags.group as WorkingGroups
  229. this.log(chalk.white('Group: ' + flags.group))
  230. }
  231. }