ContentDirectoryCommandBase.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import ExitCodes from '../ExitCodes'
  2. import { WorkingGroups } from '../Types'
  3. import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
  4. import { FlattenRelations } from 'cd-schemas/types/utility'
  5. import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
  6. import {
  7. Class,
  8. ClassId,
  9. CuratorGroup,
  10. CuratorGroupId,
  11. Entity,
  12. EntityId,
  13. Actor,
  14. } from '@joystream/types/content-directory'
  15. import { Worker } from '@joystream/types/working-group'
  16. import { CLIError } from '@oclif/errors'
  17. import { Codec } from '@polkadot/types/types'
  18. import _ from 'lodash'
  19. import { RolesCommandBase } from './WorkingGroupsCommandBase'
  20. import { createType } from '@joystream/types'
  21. import chalk from 'chalk'
  22. /**
  23. * Abstract base class for commands related to content directory
  24. */
  25. export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
  26. group = WorkingGroups.Curators // override group for RolesCommandBase
  27. // Use when lead access is required in given command
  28. async requireLead(): Promise<void> {
  29. await this.getRequiredLead()
  30. }
  31. async getCuratorContext(classNames: string[] = []): Promise<Actor> {
  32. const curator = await this.getRequiredWorker()
  33. const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
  34. const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
  35. const groups = await this.getApi().availableCuratorGroups()
  36. const availableGroupIds = groups
  37. .filter(
  38. ([groupId, group]) =>
  39. group.active.valueOf() &&
  40. classMaintainers.every((maintainers) => maintainers.some((m) => m.eq(groupId))) &&
  41. group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
  42. )
  43. .map(([id]) => id)
  44. let groupId: number
  45. if (!availableGroupIds.length) {
  46. this.error(
  47. 'You do not have the required maintainer access to at least one of the following classes: ' +
  48. classNames.join(', '),
  49. { exit: ExitCodes.AccessDenied }
  50. )
  51. } else if (availableGroupIds.length === 1) {
  52. groupId = availableGroupIds[0].toNumber()
  53. } else {
  54. groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
  55. }
  56. return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
  57. }
  58. async promptForClass(message = 'Select a class'): Promise<Class> {
  59. const classes = await this.getApi().availableClasses()
  60. const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
  61. if (!choices.length) {
  62. this.warn('No classes exist to choose from!')
  63. this.exit(ExitCodes.InvalidInput)
  64. }
  65. const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
  66. return selectedClass
  67. }
  68. async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
  69. const classes = await this.getApi().availableClasses()
  70. const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
  71. if (!foundClass) {
  72. this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
  73. }
  74. return foundClass
  75. }
  76. private async curatorGroupChoices(ids?: CuratorGroupId[]) {
  77. const groups = await this.getApi().availableCuratorGroups()
  78. return groups
  79. .filter(([id]) => (ids ? ids.some((allowedId) => allowedId.eq(id)) : true))
  80. .map(([id, group]) => ({
  81. name:
  82. `Group ${id.toString()} (` +
  83. `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
  84. `${group.curators.toArray().length} member(s), ` +
  85. `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
  86. value: id.toNumber(),
  87. }))
  88. }
  89. async promptForCuratorGroup(message = 'Select a Curator Group', ids?: CuratorGroupId[]): Promise<number> {
  90. const choices = await this.curatorGroupChoices(ids)
  91. if (!choices.length) {
  92. this.warn('No Curator Groups to choose from!')
  93. this.exit(ExitCodes.InvalidInput)
  94. }
  95. const selectedId = await this.simplePrompt({ message, type: 'list', choices })
  96. return selectedId
  97. }
  98. async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
  99. const choices = await this.curatorGroupChoices()
  100. if (!choices.length) {
  101. return []
  102. }
  103. const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
  104. return selectedIds
  105. }
  106. async promptForClassReference(): Promise<ReferenceProperty['Reference']> {
  107. const selectedClass = await this.promptForClass()
  108. const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
  109. return { className: selectedClass.name.toString(), sameOwner }
  110. }
  111. async promptForCurator(message = 'Choose a Curator', ids?: number[]): Promise<number> {
  112. const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
  113. const choices = curators
  114. .filter((c) => (ids ? ids.includes(c.workerId.toNumber()) : true))
  115. .map((c) => ({
  116. name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
  117. value: c.workerId.toNumber(),
  118. }))
  119. if (!choices.length) {
  120. this.warn('No Curators to choose from!')
  121. this.exit(ExitCodes.InvalidInput)
  122. }
  123. const selectedCuratorId = await this.simplePrompt({
  124. message,
  125. type: 'list',
  126. choices,
  127. })
  128. return selectedCuratorId
  129. }
  130. async getCurator(id: string | number): Promise<Worker> {
  131. if (typeof id === 'string') {
  132. id = parseInt(id)
  133. }
  134. let curator
  135. try {
  136. curator = await this.getApi().workerByWorkerId(WorkingGroups.Curators, id)
  137. } catch (e) {
  138. if (e instanceof CLIError) {
  139. throw new CLIError('Invalid Curator id!')
  140. }
  141. throw e
  142. }
  143. return curator
  144. }
  145. async getCuratorGroup(id: string | number): Promise<CuratorGroup> {
  146. if (typeof id === 'string') {
  147. id = parseInt(id)
  148. }
  149. const group = await this.getApi().curatorGroupById(id)
  150. if (!group) {
  151. this.error('Invalid Curator Group id!', { exit: ExitCodes.InvalidInput })
  152. }
  153. return group
  154. }
  155. async getEntity(
  156. id: string | number,
  157. requiredClass?: string,
  158. ownerMemberId?: number,
  159. requireSchema = true
  160. ): Promise<Entity> {
  161. if (typeof id === 'string') {
  162. id = parseInt(id)
  163. }
  164. const entity = await this.getApi().entityById(id)
  165. if (!entity) {
  166. this.error(`Entity not found by id: ${id}`, { exit: ExitCodes.InvalidInput })
  167. }
  168. if (requiredClass) {
  169. const [classId] = await this.classEntryByNameOrId(requiredClass)
  170. if (entity.class_id.toNumber() !== classId.toNumber()) {
  171. this.error(`Entity of id ${id} is not of class ${requiredClass}!`, { exit: ExitCodes.InvalidInput })
  172. }
  173. }
  174. const { controller } = entity.entity_permissions
  175. if (
  176. ownerMemberId !== undefined &&
  177. (!controller.isOfType('Member') || controller.asType('Member').toNumber() !== ownerMemberId)
  178. ) {
  179. this.error('Cannot execute this action for specified entity - invalid ownership.', {
  180. exit: ExitCodes.AccessDenied,
  181. })
  182. }
  183. if (requireSchema && !entity.supported_schemas.toArray().length) {
  184. this.error(`${requiredClass || ''}Entity of id ${id} has no schema support added!`)
  185. }
  186. return entity
  187. }
  188. async getAndParseKnownEntity<T>(id: string | number): Promise<FlattenRelations<T>> {
  189. const entity = await this.getEntity(id)
  190. return this.parseToKnownEntityJson<T>(entity)
  191. }
  192. async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
  193. const classId =
  194. typeof classNameOrId === 'number' ? classNameOrId : (await this.classEntryByNameOrId(classNameOrId))[0].toNumber()
  195. return (await this.getApi().entitiesByClassId(classId)).filter(([, entity]) => {
  196. const controller = entity.entity_permissions.controller
  197. return ownerMemberId !== undefined
  198. ? controller.isOfType('Member') && controller.asType('Member').toNumber() === ownerMemberId
  199. : true
  200. })
  201. }
  202. async promptForEntityEntry(
  203. message: string,
  204. className: string,
  205. propName?: string,
  206. ownerMemberId?: number,
  207. defaultId?: number
  208. ): Promise<[EntityId, Entity]> {
  209. const [classId, entityClass] = await this.classEntryByNameOrId(className)
  210. const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
  211. if (!entityEntries.length) {
  212. this.log(`${message}:`)
  213. this.error(`No choices available! Exiting...`, { exit: ExitCodes.UnexpectedException })
  214. }
  215. const choosenEntityId = await this.simplePrompt({
  216. message,
  217. type: 'list',
  218. choices: entityEntries.map(([id, entity]) => {
  219. const parsedEntityPropertyValues = this.parseEntityPropertyValues(entity, entityClass)
  220. return {
  221. name: (propName && parsedEntityPropertyValues[propName]?.value.toString()) || `ID:${id.toString()}`,
  222. value: id.toString(), // With numbers there are issues with "default"
  223. }
  224. }),
  225. default: defaultId?.toString(),
  226. })
  227. return entityEntries.find(([id]) => choosenEntityId === id.toString())!
  228. }
  229. async promptForEntityId(
  230. message: string,
  231. className: string,
  232. propName?: string,
  233. ownerMemberId?: number,
  234. defaultId?: number
  235. ): Promise<number> {
  236. return (await this.promptForEntityEntry(message, className, propName, ownerMemberId, defaultId))[0].toNumber()
  237. }
  238. parseEntityPropertyValues(
  239. entity: Entity,
  240. entityClass: Class,
  241. includedProperties?: string[]
  242. ): Record<string, { value: Codec; type: string }> {
  243. const { properties } = entityClass
  244. return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
  245. const prop = properties[propId.toNumber()]
  246. const propName = prop.name.toString()
  247. const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
  248. if (included) {
  249. columns[propName] = {
  250. value: propValue.getValue(),
  251. type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
  252. }
  253. }
  254. return columns
  255. }, {} as Record<string, { value: Codec; type: string }>)
  256. }
  257. async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
  258. const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
  259. return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
  260. v.value.toJSON()
  261. ) as unknown) as FlattenRelations<T>
  262. }
  263. async createEntityList(
  264. className: string,
  265. includedProps?: string[],
  266. filters: [string, string][] = [],
  267. ownerMemberId?: number
  268. ): Promise<Record<string, string>[]> {
  269. const [classId, entityClass] = await this.classEntryByNameOrId(className)
  270. // Create object of default "[not set]" values (prevents breaking the table if entity has no schema support)
  271. const defaultValues = entityClass.properties
  272. .map((p) => p.name.toString())
  273. .reduce((d, propName) => {
  274. if (includedProps?.includes(propName)) {
  275. d[propName] = chalk.grey('[not set]')
  276. }
  277. return d
  278. }, {} as Record<string, string>)
  279. const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
  280. const parsedEntities = (await Promise.all(
  281. entityEntries.map(([id, entity]) => ({
  282. 'ID': id.toString(),
  283. ...defaultValues,
  284. ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, includedProps), (v) =>
  285. v.value.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()
  286. ),
  287. }))
  288. )) as Record<string, string>[]
  289. return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
  290. }
  291. }