WorkingGroupsCommandBase.ts 10 KB

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