Explorar o código

WithdrawApplication, CancelOpening, create opening as lead + improve existing mappings and tests

Leszek Wiesner %!s(int64=3) %!d(string=hai) anos
pai
achega
4b4f53ef94

+ 2 - 2
metadata-protobuf/proto/WorkingGroups.proto

@@ -3,8 +3,8 @@ syntax = "proto2";
 message OpeningMetadata {
   required string short_description = 1; // Short description of the opening
   required string description = 2; // Full description of the opening
-  required uint32 hiring_limit = 3; // Expected number of hired applicants
-  required uint64 expected_ending_timestamp = 4; // Expected time when the opening will close (Unix timestamp)
+  optional uint32 hiring_limit = 3; // Expected number of hired applicants
+  optional uint64 expected_ending_timestamp = 4; // Expected time when the opening will close (Unix timestamp)
   required string application_details = 5; // Md-formatted text explaining the application process
   message ApplicationFormQuestion {
     required string question = 1; // The question itself (ie. "What is your name?"")

+ 8 - 8
query-node/manifest.yml

@@ -100,8 +100,8 @@ mappings:
       handler: workingGroups_AppliedOnOpening(DatabaseManager, SubstrateEvent)
     - event: storageWorkingGroup.OpeningFilled
       handler: workingGroups_OpeningFilled(DatabaseManager, SubstrateEvent)
-    - event: storageWorkingGroup.LeaderSet
-      handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
+    # - event: storageWorkingGroup.LeaderSet
+    #   handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
     - event: storageWorkingGroup.WorkerRoleAccountUpdated
       handler: workingGroups_WorkerRoleAccountUpdated(DatabaseManager, SubstrateEvent)
     - event: storageWorkingGroup.LeaderUnset
@@ -139,8 +139,8 @@ mappings:
       handler: workingGroups_AppliedOnOpening(DatabaseManager, SubstrateEvent)
     - event: forumWorkingGroup.OpeningFilled
       handler: workingGroups_OpeningFilled(DatabaseManager, SubstrateEvent)
-    - event: forumWorkingGroup.LeaderSet
-      handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
+    # - event: forumWorkingGroup.LeaderSet
+    #   handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
     - event: forumWorkingGroup.WorkerRoleAccountUpdated
       handler: workingGroups_WorkerRoleAccountUpdated(DatabaseManager, SubstrateEvent)
     - event: forumWorkingGroup.LeaderUnset
@@ -178,8 +178,8 @@ mappings:
       handler: workingGroups_AppliedOnOpening(DatabaseManager, SubstrateEvent)
     - event: membershipWorkingGroup.OpeningFilled
       handler: workingGroups_OpeningFilled(DatabaseManager, SubstrateEvent)
-    - event: membershipWorkingGroup.LeaderSet
-      handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
+    # - event: membershipWorkingGroup.LeaderSet
+    #   handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
     - event: membershipWorkingGroup.WorkerRoleAccountUpdated
       handler: workingGroups_WorkerRoleAccountUpdated(DatabaseManager, SubstrateEvent)
     - event: membershipWorkingGroup.LeaderUnset
@@ -217,8 +217,8 @@ mappings:
       handler: workingGroups_AppliedOnOpening(DatabaseManager, SubstrateEvent)
     - event: contentDirectoryWorkingGroup.OpeningFilled
       handler: workingGroups_OpeningFilled(DatabaseManager, SubstrateEvent)
-    - event: contentDirectoryWorkingGroup.LeaderSet
-      handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
+    # - event: contentDirectoryWorkingGroup.LeaderSet
+    #   handler: workingGroups_LeaderSet(DatabaseManager, SubstrateEvent)
     - event: contentDirectoryWorkingGroup.WorkerRoleAccountUpdated
       handler: workingGroups_WorkerRoleAccountUpdated(DatabaseManager, SubstrateEvent)
     - event: contentDirectoryWorkingGroup.LeaderUnset

+ 2 - 1
query-node/mappings/common.ts

@@ -15,13 +15,14 @@ export function createEvent({ blockNumber, extrinsic, index }: SubstrateEvent, t
 
 type MetadataClass<T> = {
   deserializeBinary: (bytes: Uint8Array) => T
+  name: string
 }
 
 export function deserializeMetadata<T>(metadataType: MetadataClass<T>, metadataBytes: Bytes): T | null {
   try {
     return metadataType.deserializeBinary(metadataBytes.toU8a(true))
   } catch (e) {
-    console.error(`Invalid opening metadata! (${metadataBytes.toHex()})`)
+    console.error(`Cannot deserialize ${metadataType.name}! Provided bytes: (${metadataBytes.toHex()})`)
     return null
   }
 }

+ 216 - 63
query-node/mappings/workingGroups.ts

@@ -30,8 +30,15 @@ import {
   WorkerStatusActive,
   OpeningFilledEvent,
   OpeningStatusFilled,
+  // LeaderSetEvent,
+  OpeningCanceledEvent,
+  OpeningStatusCancelled,
+  ApplicationStatusCancelled,
+  ApplicationWithdrawnEvent,
+  ApplicationStatusWithdrawn,
 } from 'query-node/dist/model'
 import { createType } from '@joystream/types'
+import _ from 'lodash'
 
 // Shortcuts
 type InputTypeMap = OpeningMetadata.ApplicationFormQuestion.InputTypeMap
@@ -93,11 +100,38 @@ function parseQuestionInputType(type: InputTypeMap[keyof InputTypeMap]) {
   return ApplicationFormQuestionType.TEXT
 }
 
-async function createOpeningMeta(db: DatabaseManager, metadataBytes: Bytes): Promise<WorkingGroupOpeningMetadata> {
-  const metadata = await deserializeMetadata(OpeningMetadata, metadataBytes)
-  if (!metadata) {
-    // TODO: Use some defaults?
-  }
+function getDefaultOpeningMetadata(opening: WorkingGroupOpening): OpeningMetadata {
+  const metadata = new OpeningMetadata()
+  metadata.setShortDescription(
+    `${_.startCase(opening.group.name)} ${
+      opening.type === WorkingGroupOpeningType.REGULAR ? 'worker' : 'leader'
+    } opening`
+  )
+  metadata.setDescription(
+    `Apply to this opening in order to be considered for ${_.startCase(opening.group.name)} ${
+      opening.type === WorkingGroupOpeningType.REGULAR ? 'worker' : 'leader'
+    } role!`
+  )
+  metadata.setApplicationDetails(`- Fill the application form`)
+  const applicationFormQuestion = new OpeningMetadata.ApplicationFormQuestion()
+  applicationFormQuestion.setQuestion('What makes you a good candidate?')
+  applicationFormQuestion.setType(OpeningMetadata.ApplicationFormQuestion.InputType.TEXTAREA)
+  metadata.addApplicationFormQuestions(applicationFormQuestion)
+
+  return metadata
+}
+
+async function createOpeningMeta(
+  db: DatabaseManager,
+  event_: SubstrateEvent,
+  opening: WorkingGroupOpening,
+  metadataBytes: Bytes
+): Promise<WorkingGroupOpeningMetadata> {
+  const deserializedMetadata = await deserializeMetadata(OpeningMetadata, metadataBytes)
+  const metadata = deserializedMetadata || (await getDefaultOpeningMetadata(opening))
+  const originallyValid = !!deserializedMetadata
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
   const {
     applicationFormQuestionsList,
     applicationDetails,
@@ -105,9 +139,12 @@ async function createOpeningMeta(db: DatabaseManager, metadataBytes: Bytes): Pro
     expectedEndingTimestamp,
     hiringLimit,
     shortDescription,
-  } = metadata!.toObject()
+  } = metadata.toObject()
 
   const openingMetadata = new WorkingGroupOpeningMetadata({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    originallyValid,
     applicationDetails,
     description,
     shortDescription,
@@ -121,6 +158,8 @@ async function createOpeningMeta(db: DatabaseManager, metadataBytes: Bytes): Pro
   await Promise.all(
     applicationFormQuestionsList.map(async ({ question, type }, index) => {
       const applicationFormQuestion = new ApplicationFormQuestion({
+        createdAt: eventTime,
+        updatedAt: eventTime,
         question,
         type: parseQuestionInputType(type!),
         index,
@@ -141,13 +180,15 @@ async function createApplicationQuestionAnswers(
 ) {
   const metadata = deserializeMetadata(ApplicationMetadata, metadataBytes)
   if (!metadata) {
-    // TODO: Handle invalid state?
+    return
   }
   const questions = await getApplicationFormQuestions(db, application.opening.id)
-  const { answersList } = metadata!.toObject()
+  const { answersList } = metadata.toObject()
   await Promise.all(
     answersList.slice(0, questions.length).map(async (answer, index) => {
       const applicationFormQuestionAnswer = new ApplicationFormQuestionAnswer({
+        createdAt: application.createdAt,
+        updatedAt: application.updatedAt,
         application,
         question: questions[index],
         answer,
@@ -170,17 +211,16 @@ export async function workingGroups_OpeningAdded(db: DatabaseManager, event_: Su
     stakePolicy,
   } = new WorkingGroups.OpeningAddedEvent(event_).data
   const group = await getWorkingGroup(db, event_)
-  const metadata = await createOpeningMeta(db, metadataBytes)
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
 
   const opening = new WorkingGroupOpening({
-    createdAt: new Date(event_.blockTimestamp.toNumber()),
-    updatedAt: new Date(event_.blockTimestamp.toNumber()),
+    createdAt: eventTime,
+    updatedAt: eventTime,
     createdAtBlock: event_.blockNumber,
     id: `${group.name}-${openingRuntimeId.toString()}`,
     runtimeId: openingRuntimeId.toNumber(),
     applications: [],
     group,
-    metadata,
     rewardPerBlock: rewardPerBlock.unwrapOr(new BN(0)),
     stakeAmount: stakePolicy.stake_amount,
     unstakingPeriod: stakePolicy.leaving_unstaking_period.toNumber(),
@@ -188,10 +228,15 @@ export async function workingGroups_OpeningAdded(db: DatabaseManager, event_: Su
     type: openingType.isLeader ? WorkingGroupOpeningType.LEADER : WorkingGroupOpeningType.REGULAR,
   })
 
+  const metadata = await createOpeningMeta(db, event_, opening, metadataBytes)
+  opening.metadata = metadata
+
   await db.save<WorkingGroupOpening>(opening)
 
   const event = await createEvent(event_, EventType.OpeningAdded)
   const openingAddedEvent = new OpeningAddedEvent({
+    createdAt: eventTime,
+    updatedAt: eventTime,
     event,
     group,
     opening,
@@ -203,6 +248,8 @@ export async function workingGroups_OpeningAdded(db: DatabaseManager, event_: Su
 
 export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
   const {
     applicationId: applicationRuntimeId,
     applyOnOpeningParameters: {
@@ -218,8 +265,8 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
   const openingDbId = `${group.name}-${openingRuntimeId.toString()}`
 
   const application = new WorkingGroupApplication({
-    createdAt: new Date(event_.blockTimestamp.toNumber()),
-    updatedAt: new Date(event_.blockTimestamp.toNumber()),
+    createdAt: eventTime,
+    updatedAt: eventTime,
     createdAtBlock: event_.blockNumber,
     id: `${group.name}-${applicationRuntimeId.toString()}`,
     runtimeId: applicationRuntimeId.toNumber(),
@@ -238,8 +285,8 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
 
   const event = await createEvent(event_, EventType.AppliedOnOpening)
   const appliedOnOpeningEvent = new AppliedOnOpeningEvent({
-    createdAt: new Date(event_.blockTimestamp.toNumber()),
-    updatedAt: new Date(event_.blockTimestamp.toNumber()),
+    createdAt: eventTime,
+    updatedAt: eventTime,
     event,
     group,
     opening: new WorkingGroupOpening({ id: openingDbId }),
@@ -252,6 +299,8 @@ export async function workingGroups_AppliedOnOpening(db: DatabaseManager, event_
 
 export async function workingGroups_OpeningFilled(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
   const {
     openingId: openingRuntimeId,
     applicationId: applicationIdsSet,
@@ -268,8 +317,8 @@ export async function workingGroups_OpeningFilled(db: DatabaseManager, event_: S
   // Save the event
   const event = await createEvent(event_, EventType.OpeningFilled)
   const openingFilledEvent = new OpeningFilledEvent({
-    createdAt: new Date(event_.blockTimestamp.toNumber()),
-    updatedAt: new Date(event_.blockTimestamp.toNumber()),
+    createdAt: eventTime,
+    updatedAt: eventTime,
     event,
     group,
     opening,
@@ -278,59 +327,169 @@ export async function workingGroups_OpeningFilled(db: DatabaseManager, event_: S
   await db.save<Event>(event)
   await db.save<OpeningFilledEvent>(openingFilledEvent)
 
+  const hiredWorkers: Worker[] = []
   // Update applications and create new workers
   await Promise.all(
-    (opening.applications || []).map(async (application) => {
-      const isAccepted = acceptedApplicationIds.some((runtimeId) => runtimeId.toNumber() === application.runtimeId)
-      application.status = isAccepted ? new ApplicationStatusAccepted() : new ApplicationStatusRejected()
-      if (isAccepted) {
-        // Cannot use "applicationIdToWorkerIdMap.get" here,
-        // it only works if the passed instance is identical to BTreeMap key instance (=== instead of .eq)
-        const [, workerRuntimeId] =
-          Array.from(applicationIdToWorkerIdMap.entries()).find(
-            ([applicationRuntimeId]) => applicationRuntimeId.toNumber() === application.runtimeId
-          ) || []
-        if (!workerRuntimeId) {
-          throw new Error(
-            `Fatal: No worker id found by accepted application id ${application.id} when handling OpeningFilled event!`
-          )
+    (opening.applications || [])
+      // Skip withdrawn applications
+      .filter((application) => application.status.isTypeOf !== 'ApplicationStatusWithdrawn')
+      .map(async (application) => {
+        const isAccepted = acceptedApplicationIds.some((runtimeId) => runtimeId.toNumber() === application.runtimeId)
+        const applicationStatus = isAccepted ? new ApplicationStatusAccepted() : new ApplicationStatusRejected()
+        applicationStatus.openingFilledEventId = openingFilledEvent.id
+        application.status = applicationStatus
+        application.updatedAt = eventTime
+        if (isAccepted) {
+          // Cannot use "applicationIdToWorkerIdMap.get" here,
+          // it only works if the passed instance is identical to BTreeMap key instance (=== instead of .eq)
+          const [, workerRuntimeId] =
+            Array.from(applicationIdToWorkerIdMap.entries()).find(
+              ([applicationRuntimeId]) => applicationRuntimeId.toNumber() === application.runtimeId
+            ) || []
+          if (!workerRuntimeId) {
+            throw new Error(
+              `Fatal: No worker id found by accepted application id ${application.id} when handling OpeningFilled event!`
+            )
+          }
+          const worker = new Worker({
+            createdAt: eventTime,
+            updatedAt: eventTime,
+            id: `${group.name}-${workerRuntimeId.toString()}`,
+            runtimeId: workerRuntimeId.toNumber(),
+            hiredAtBlock: event_.blockNumber,
+            hiredAtTime: new Date(event_.blockTimestamp.toNumber()),
+            application,
+            group,
+            isLead: opening.type === WorkingGroupOpeningType.LEADER,
+            membership: application.applicant,
+            stake: application.stake,
+            roleAccount: application.roleAccount,
+            rewardAccount: application.rewardAccount,
+            stakeAccount: application.stakingAccount,
+            payouts: [],
+            status: new WorkerStatusActive(),
+            entry: openingFilledEvent,
+          })
+          await db.save<Worker>(worker)
+          hiredWorkers.push(worker)
         }
-        const worker = new Worker({
-          createdAt: new Date(event_.blockTimestamp.toNumber()),
-          updatedAt: new Date(event_.blockTimestamp.toNumber()),
-          id: `${group.name}-${workerRuntimeId.toString()}`,
-          runtimeId: workerRuntimeId.toNumber(),
-          hiredAtBlock: event_.blockNumber,
-          hiredAtTime: new Date(event_.blockTimestamp.toNumber()),
-          application,
-          group,
-          isLead: opening.type === WorkingGroupOpeningType.LEADER,
-          membership: application.applicant,
-          stake: application.stake,
-          roleAccount: application.roleAccount,
-          rewardAccount: application.rewardAccount,
-          stakeAccount: application.stakingAccount,
-          payouts: [],
-          status: new WorkerStatusActive(),
-          entry: openingFilledEvent,
-        })
-        await db.save<Worker>(worker)
-      }
-      await db.save<WorkingGroupApplication>(application)
-    })
+        await db.save<WorkingGroupApplication>(application)
+      })
   )
 
   // Set opening status
   const openingFilled = new OpeningStatusFilled()
   openingFilled.openingFilledEventId = openingFilledEvent.id
   opening.status = openingFilled
+  opening.updatedAt = eventTime
+  await db.save<WorkingGroupOpening>(opening)
+
+  // Update working group if necessary
+  if (opening.type === WorkingGroupOpeningType.LEADER && hiredWorkers.length) {
+    group.leader = hiredWorkers[0]
+    group.updatedAt = eventTime
+    await db.save<WorkingGroup>(group)
+  }
+}
+
+// FIXME: Currently this event cannot be handled directly, because the worker does not yet exist at the time when it is emitted
+// export async function workingGroups_LeaderSet(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
+//   event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+//   const { workerId: workerRuntimeId } = new WorkingGroups.LeaderSetEvent(event_).data
+
+//   const group = await getWorkingGroup(db, event_)
+//   const workerDbId = `${group.name}-${workerRuntimeId.toString()}`
+//   const worker = new Worker({ id: workerDbId })
+//   const eventTime = new Date(event_.blockTimestamp.toNumber())
+
+//   // Create and save event
+//   const event = createEvent(event_, EventType.LeaderSet)
+//   const leaderSetEvent = new LeaderSetEvent({
+//     createdAt: eventTime,
+//     updatedAt: eventTime,
+//     event,
+//     group,
+//     worker,
+//   })
+
+//   await db.save<Event>(event)
+//   await db.save<LeaderSetEvent>(leaderSetEvent)
+// }
+
+export async function workingGroups_OpeningCanceled(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
+  event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const { openingId: openingRuntimeId } = new WorkingGroups.OpeningCanceledEvent(event_).data
+
+  const group = await getWorkingGroup(db, event_)
+  const opening = await getOpening(db, `${group.name}-${openingRuntimeId.toString()}`, ['applications'])
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
+  // Create and save event
+  const event = createEvent(event_, EventType.OpeningCanceled)
+  const openingCanceledEvent = new OpeningCanceledEvent({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    event,
+    group,
+    opening,
+  })
+
+  await db.save<Event>(event)
+  await db.save<OpeningCanceledEvent>(openingCanceledEvent)
+
+  // Set opening status
+  const openingCancelled = new OpeningStatusCancelled()
+  openingCancelled.openingCancelledEventId = openingCanceledEvent.id
+  opening.status = openingCancelled
+  opening.updatedAt = eventTime
 
   await db.save<WorkingGroupOpening>(opening)
+
+  // Set applications status
+  const applicationCancelled = new ApplicationStatusCancelled()
+  applicationCancelled.openingCancelledEventId = openingCanceledEvent.id
+  await Promise.all(
+    (opening.applications || [])
+      // Skip withdrawn applications
+      .filter((application) => application.status.isTypeOf !== 'ApplicationStatusWithdrawn')
+      .map(async (application) => {
+        application.status = applicationCancelled
+        application.updatedAt = eventTime
+        await db.save<WorkingGroupApplication>(application)
+      })
+  )
 }
 
-export async function workingGroups_LeaderSet(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
+export async function workingGroups_ApplicationWithdrawn(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
+  event_.blockTimestamp = new BN(event_.blockTimestamp) // FIXME: Temporary fix for wrong blockTimestamp type
+  const { applicationId: applicationRuntimeId } = new WorkingGroups.ApplicationWithdrawnEvent(event_).data
+
+  const group = await getWorkingGroup(db, event_)
+  const application = await getApplication(db, `${group.name}-${applicationRuntimeId.toString()}`)
+  const eventTime = new Date(event_.blockTimestamp.toNumber())
+
+  // Create and save event
+  const event = createEvent(event_, EventType.ApplicationWithdrawn)
+  const applicationWithdrawnEvent = new ApplicationWithdrawnEvent({
+    createdAt: eventTime,
+    updatedAt: eventTime,
+    event,
+    group,
+    application,
+  })
+
+  await db.save<Event>(event)
+  await db.save<ApplicationWithdrawnEvent>(applicationWithdrawnEvent)
+
+  // Set application status
+  const statusWithdrawn = new ApplicationStatusWithdrawn()
+  statusWithdrawn.applicationWithdrawnEventId = applicationWithdrawnEvent.id
+  application.status = statusWithdrawn
+  application.updatedAt = eventTime
+
+  await db.save<WorkingGroupApplication>(application)
 }
+
 export async function workingGroups_WorkerRoleAccountUpdated(
   db: DatabaseManager,
   event_: SubstrateEvent
@@ -358,12 +517,6 @@ export async function workingGroups_StakeDecreased(db: DatabaseManager, event_:
 export async function workingGroups_StakeIncreased(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   // TBD
 }
-export async function workingGroups_ApplicationWithdrawn(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
-}
-export async function workingGroups_OpeningCanceled(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
-  // TBD
-}
 export async function workingGroups_BudgetSet(db: DatabaseManager, event_: SubstrateEvent): Promise<void> {
   // TBD
 }

+ 3 - 0
query-node/schemas/common.graphql

@@ -31,6 +31,7 @@ enum EventType {
   WorkerExited
   TerminatedWorker
   TerminatedLeader
+  WorkerStartedLeaving
   StakeSlashed
   StakeDecreased
   StakeIncreased
@@ -41,6 +42,8 @@ enum EventType {
   WorkerRewardAmountUpdated
   StatusTextChanged
   BudgetSpending
+  RewardPaid
+  NewMissedRewardLevelReached
 }
 
 type Event @entity {

+ 12 - 5
query-node/schemas/workingGroups.graphql

@@ -5,8 +5,7 @@ type WorkerStatusActive @variant {
 
 type WorkerStatusLeft @variant {
   # TODO: Variant relationships
-  # TODO: This is not yet emitted by runtime
-  workerLeftEventId: ID!
+  workerStartedLeavingEventId: ID!
 
   # Set when the unstaking period is finished
   workerExitedEventId: ID
@@ -55,7 +54,7 @@ type Worker @entity {
   stake: BigInt!
 
   "All related reward payouts"
-  payouts: [WorkerPayoutEvent!] @derivedFrom(field: "worker")
+  payouts: [RewardPaidEvent!] @derivedFrom(field: "worker")
 
   # TODO: should we use createdAt for consistency / doesn't Hydra actually require us to override this field?
   "Blocknumber of the block the worker was hired at"
@@ -139,6 +138,9 @@ enum WorkingGroupOpeningType {
 }
 
 type WorkingGroupOpeningMetadata @entity {
+  "Whether the originally provided metadata was valid"
+  originallyValid: Boolean!
+
   "Opening short description"
   shortDescription: String!
 
@@ -146,10 +148,10 @@ type WorkingGroupOpeningMetadata @entity {
   description: String!
 
   "Expected max. number of applicants that will be hired"
-  hiringLimit: Int!
+  hiringLimit: Int
 
   "Expected time when the opening will close"
-  expectedEnding: DateTime!
+  expectedEnding: DateTime
 
   "Md-formatted text explaining the application process"
   applicationDetails: String!
@@ -211,6 +213,10 @@ type ApplicationStatusRejected @variant {
   openingFilledEventId: ID!
 }
 
+type ApplicationStatusCancelled @variant {
+  openingCancelledEventId: ID!
+}
+
 type ApplicationStatusWithdrawn @variant {
   # TODO: Variant relationships
   applicationWithdrawnEventId: ID!
@@ -221,6 +227,7 @@ union WorkingGroupApplicationStatus =
   | ApplicationStatusAccepted
   | ApplicationStatusRejected
   | ApplicationStatusWithdrawn
+  | ApplicationStatusCancelled
 
 type WorkingGroupApplication @entity {
   "Application id ({workingGroupName}-{applicationId})"

+ 24 - 7
query-node/schemas/workingGroupsEvents.graphql

@@ -119,6 +119,20 @@ type TerminatedLeaderEvent @entity {
   rationale: String
 }
 
+type WorkerStartedLeavingEvent @entity {
+  "Generic event data"
+  event: Event!
+
+  "Related group"
+  group: WorkingGroup!
+
+  "Related worker"
+  worker: Worker!
+
+  "Optional rationale"
+  rationale: String
+}
+
 type StakeSlashedEvent @entity {
   "Generic event data"
   event: Event!
@@ -265,16 +279,19 @@ enum PayoutType {
 }
 
 # TODO: This will be either based on the actual runtime event or be just a custom query-node event generated of preBlock/postBlock
-type WorkerPayoutEvent @entity {
-  "Type of the worker payout"
-  type: PayoutType
+type RewardPaidEvent @entity {
+  "Generic event data"
+  event: Event!
+
+  "Related group"
+  group: WorkingGroup!
 
   "Related worker"
   worker: Worker!
 
-  "Amount recieved"
-  recieved: BigInt!
+  "The account that recieved the reward"
+  rewardAccount: String!
 
-  "Amount missed (due to, for example, empty working group budget)"
-  missed: BigInt!
+  "Amount recieved"
+  amount: BigInt!
 }

+ 7 - 0
tests/integration-tests/.env

@@ -12,6 +12,13 @@ MEMBERSHIP_CREATION_N = 2
 MEMBERS_INVITE_N = 2
 # Amount of staking accounts to add during "add staking accounts" test
 STAKING_ACCOUNTS_ADD_N = 3
+# Amount of applications to create in openingAndApplicationsStatus test
+APPLICATION_STATUS_CREATE_N = 5
+# Amount of applications to withdraw in openingAndApplicationsStatus test
+APPLICATION_STATUS_WITHDRAW_N = 3
+
+
+
 # ID of the membership paid terms used in membership creation test.
 MEMBERSHIP_PAID_TERMS = 0
 # Council stake amount for first K accounts in council election test.

+ 24 - 1
tests/integration-tests/src/Api.ts

@@ -25,7 +25,14 @@ import {
   AppliedOnOpeningEventDetails,
   OpeningFilledEventDetails,
 } from './types'
-import { ApplicationId, Opening, OpeningId, WorkerId, ApplyOnOpeningParameters } from '@joystream/types/working-group'
+import {
+  ApplicationId,
+  Opening,
+  OpeningId,
+  WorkerId,
+  ApplyOnOpeningParameters,
+  Worker,
+} from '@joystream/types/working-group'
 
 export enum WorkingGroups {
   StorageWorkingGroup = 'storageWorkingGroup',
@@ -371,4 +378,20 @@ export class Api {
     }
     return opening
   }
+
+  public async getLeader(group: WorkingGroupModuleName): Promise<Worker> {
+    const leadId = await this.api.query[group].currentLead()
+    if (leadId.isNone) {
+      throw new Error('Cannot get lead role key: Lead not yet hired!')
+    }
+    return await this.api.query[group].workerById(leadId.unwrap())
+  }
+
+  public async getLeadRoleKey(group: WorkingGroupModuleName): Promise<string> {
+    return (await this.getLeader(group)).role_account_id.toString()
+  }
+
+  public async getLeaderStakingKey(group: WorkingGroupModuleName): Promise<string> {
+    return (await this.getLeader(group)).staking_account_id.toString()
+  }
 }

+ 108 - 0
tests/integration-tests/src/QueryNodeApi.ts

@@ -1,12 +1,14 @@
 import { gql, ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client'
 import { MemberId } from '@joystream/types/common'
 import {
+  ApplicationWithdrawnEvent,
   AppliedOnOpeningEvent,
   InitialInvitationBalanceUpdatedEvent,
   InitialInvitationCountUpdatedEvent,
   MembershipPriceUpdatedEvent,
   MembershipSystemSnapshot,
   OpeningAddedEvent,
+  OpeningCanceledEvent,
   OpeningFilledEvent,
   Query,
   ReferralCutUpdatedEvent,
@@ -508,17 +510,38 @@ export class QueryNodeApi {
           runtimeId
           group {
             name
+            leader {
+              runtimeId
+            }
           }
           applications {
             id
             runtimeId
             status {
               __typename
+              ... on ApplicationStatusCancelled {
+                openingCancelledEventId
+              }
+              ... on ApplicationStatusWithdrawn {
+                applicationWithdrawnEventId
+              }
+              ... on ApplicationStatusAccepted {
+                openingFilledEventId
+              }
+              ... on ApplicationStatusRejected {
+                openingFilledEventId
+              }
             }
           }
           type
           status {
             __typename
+            ... on OpeningStatusFilled {
+              openingFilledEventId
+            }
+            ... on OpeningStatusCancelled {
+              openingCancelledEventId
+            }
           }
           metadata {
             shortDescription
@@ -573,6 +596,18 @@ export class QueryNodeApi {
           stakingAccount
           status {
             __typename
+            ... on ApplicationStatusCancelled {
+              openingCancelledEventId
+            }
+            ... on ApplicationStatusWithdrawn {
+              applicationWithdrawnEventId
+            }
+            ... on ApplicationStatusAccepted {
+              openingFilledEventId
+            }
+            ... on ApplicationStatusRejected {
+              openingFilledEventId
+            }
           }
           answers {
             question {
@@ -672,6 +707,7 @@ export class QueryNodeApi {
     const OPENING_FILLED_BY_ID = gql`
       query($eventId: ID!) {
         openingFilledEvents(where: { eventId_eq: $eventId }) {
+          id
           event {
             inBlock
             inExtrinsic
@@ -727,4 +763,76 @@ export class QueryNodeApi {
       })
     ).data.openingFilledEvents[0]
   }
+
+  public async getApplicationWithdrawnEvent(
+    blockNumber: number,
+    indexInBlock: number
+  ): Promise<ApplicationWithdrawnEvent | undefined> {
+    const APPLICATION_WITHDRAWN_BY_ID = gql`
+      query($eventId: ID!) {
+        applicationWithdrawnEvents(where: { eventId_eq: $eventId }) {
+          id
+          event {
+            inBlock
+            inExtrinsic
+            indexInBlock
+            type
+          }
+          group {
+            name
+          }
+          application {
+            id
+            runtimeId
+          }
+        }
+      }
+    `
+
+    const eventId = `${blockNumber}-${indexInBlock}`
+    this.queryDebug(`Executing getApplicationWithdrawnEvent(${eventId})`)
+
+    return (
+      await this.queryNodeProvider.query<Pick<Query, 'applicationWithdrawnEvents'>>({
+        query: APPLICATION_WITHDRAWN_BY_ID,
+        variables: { eventId },
+      })
+    ).data.applicationWithdrawnEvents[0]
+  }
+
+  public async getOpeningCancelledEvent(
+    blockNumber: number,
+    indexInBlock: number
+  ): Promise<OpeningCanceledEvent | undefined> {
+    const OPENING_CANCELLED_BY_ID = gql`
+      query($eventId: ID!) {
+        openingCanceledEvents(where: { eventId_eq: $eventId }) {
+          id
+          event {
+            inBlock
+            inExtrinsic
+            indexInBlock
+            type
+          }
+          group {
+            name
+          }
+          opening {
+            id
+            runtimeId
+          }
+        }
+      }
+    `
+
+    const eventId = `${blockNumber}-${indexInBlock}`
+    this.queryDebug(`Executing getOpeningCancelledEvent(${eventId})`)
+
+    return (
+      await this.queryNodeProvider.query<Pick<Query, 'openingCanceledEvents'>>({
+        query: OPENING_CANCELLED_BY_ID,
+        variables: { eventId },
+      })
+    ).data.openingCanceledEvents[0]
+  }
 }

+ 337 - 148
tests/integration-tests/src/QueryNodeApiSchema.generated.ts

@@ -280,6 +280,55 @@ export type ApplicationStatusAcceptedWhereUniqueInput = {
   id: Scalars['ID']
 }
 
+export type ApplicationStatusCancelled = {
+  __typename?: 'ApplicationStatusCancelled'
+  openingCancelledEventId: Scalars['String']
+}
+
+export type ApplicationStatusCancelledCreateInput = {
+  openingCancelledEventId: Scalars['String']
+}
+
+export type ApplicationStatusCancelledUpdateInput = {
+  openingCancelledEventId?: Maybe<Scalars['String']>
+}
+
+export type ApplicationStatusCancelledWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  openingCancelledEventId_eq?: Maybe<Scalars['String']>
+  openingCancelledEventId_contains?: Maybe<Scalars['String']>
+  openingCancelledEventId_startsWith?: Maybe<Scalars['String']>
+  openingCancelledEventId_endsWith?: Maybe<Scalars['String']>
+  openingCancelledEventId_in?: Maybe<Array<Scalars['String']>>
+}
+
+export type ApplicationStatusCancelledWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type ApplicationStatusPending = {
   __typename?: 'ApplicationStatusPending'
   phantom?: Maybe<Scalars['Int']>
@@ -1385,6 +1434,7 @@ export type Event = BaseGraphQlObject & {
   openingcanceledeventevent?: Maybe<Array<OpeningCanceledEvent>>
   openingfilledeventevent?: Maybe<Array<OpeningFilledEvent>>
   referralcutupdatedeventevent?: Maybe<Array<ReferralCutUpdatedEvent>>
+  rewardpaideventevent?: Maybe<Array<RewardPaidEvent>>
   stakedecreasedeventevent?: Maybe<Array<StakeDecreasedEvent>>
   stakeincreasedeventevent?: Maybe<Array<StakeIncreasedEvent>>
   stakeslashedeventevent?: Maybe<Array<StakeSlashedEvent>>
@@ -1398,6 +1448,7 @@ export type Event = BaseGraphQlObject & {
   workerrewardaccountupdatedeventevent?: Maybe<Array<WorkerRewardAccountUpdatedEvent>>
   workerrewardamountupdatedeventevent?: Maybe<Array<WorkerRewardAmountUpdatedEvent>>
   workerroleaccountupdatedeventevent?: Maybe<Array<WorkerRoleAccountUpdatedEvent>>
+  workerstartedleavingeventevent?: Maybe<Array<WorkerStartedLeavingEvent>>
 }
 
 export type EventConnection = {
@@ -1461,6 +1512,7 @@ export enum EventType {
   WorkerExited = 'WorkerExited',
   TerminatedWorker = 'TerminatedWorker',
   TerminatedLeader = 'TerminatedLeader',
+  WorkerStartedLeaving = 'WorkerStartedLeaving',
   StakeSlashed = 'StakeSlashed',
   StakeDecreased = 'StakeDecreased',
   StakeIncreased = 'StakeIncreased',
@@ -1471,6 +1523,8 @@ export enum EventType {
   WorkerRewardAmountUpdated = 'WorkerRewardAmountUpdated',
   StatusTextChanged = 'StatusTextChanged',
   BudgetSpending = 'BudgetSpending',
+  RewardPaid = 'RewardPaid',
+  NewMissedRewardLevelReached = 'NewMissedRewardLevelReached',
 }
 
 export type EventUpdateInput = {
@@ -3693,11 +3747,6 @@ export type PageInfo = {
   endCursor?: Maybe<Scalars['String']>
 }
 
-export enum PayoutType {
-  StandardReward = 'STANDARD_REWARD',
-  ReturnMissed = 'RETURN_MISSED',
-}
-
 export type ProcessorState = {
   __typename?: 'ProcessorState'
   lastCompleteBlock: Scalars['Float']
@@ -3790,6 +3839,9 @@ export type Query = {
   referralCutUpdatedEvents: Array<ReferralCutUpdatedEvent>
   referralCutUpdatedEventByUniqueInput?: Maybe<ReferralCutUpdatedEvent>
   referralCutUpdatedEventsConnection: ReferralCutUpdatedEventConnection
+  rewardPaidEvents: Array<RewardPaidEvent>
+  rewardPaidEventByUniqueInput?: Maybe<RewardPaidEvent>
+  rewardPaidEventsConnection: RewardPaidEventConnection
   stakeDecreasedEvents: Array<StakeDecreasedEvent>
   stakeDecreasedEventByUniqueInput?: Maybe<StakeDecreasedEvent>
   stakeDecreasedEventsConnection: StakeDecreasedEventConnection
@@ -3820,9 +3872,6 @@ export type Query = {
   workerExitedEvents: Array<WorkerExitedEvent>
   workerExitedEventByUniqueInput?: Maybe<WorkerExitedEvent>
   workerExitedEventsConnection: WorkerExitedEventConnection
-  workerPayoutEvents: Array<WorkerPayoutEvent>
-  workerPayoutEventByUniqueInput?: Maybe<WorkerPayoutEvent>
-  workerPayoutEventsConnection: WorkerPayoutEventConnection
   workerRewardAccountUpdatedEvents: Array<WorkerRewardAccountUpdatedEvent>
   workerRewardAccountUpdatedEventByUniqueInput?: Maybe<WorkerRewardAccountUpdatedEvent>
   workerRewardAccountUpdatedEventsConnection: WorkerRewardAccountUpdatedEventConnection
@@ -3832,6 +3881,9 @@ export type Query = {
   workerRoleAccountUpdatedEvents: Array<WorkerRoleAccountUpdatedEvent>
   workerRoleAccountUpdatedEventByUniqueInput?: Maybe<WorkerRoleAccountUpdatedEvent>
   workerRoleAccountUpdatedEventsConnection: WorkerRoleAccountUpdatedEventConnection
+  workerStartedLeavingEvents: Array<WorkerStartedLeavingEvent>
+  workerStartedLeavingEventByUniqueInput?: Maybe<WorkerStartedLeavingEvent>
+  workerStartedLeavingEventsConnection: WorkerStartedLeavingEventConnection
   workers: Array<Worker>
   workerByUniqueInput?: Maybe<Worker>
   workersConnection: WorkerConnection
@@ -4399,6 +4451,26 @@ export type QueryReferralCutUpdatedEventsConnectionArgs = {
   orderBy?: Maybe<ReferralCutUpdatedEventOrderByInput>
 }
 
+export type QueryRewardPaidEventsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<RewardPaidEventWhereInput>
+  orderBy?: Maybe<RewardPaidEventOrderByInput>
+}
+
+export type QueryRewardPaidEventByUniqueInputArgs = {
+  where: RewardPaidEventWhereUniqueInput
+}
+
+export type QueryRewardPaidEventsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<RewardPaidEventWhereInput>
+  orderBy?: Maybe<RewardPaidEventOrderByInput>
+}
+
 export type QueryStakeDecreasedEventsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -4599,26 +4671,6 @@ export type QueryWorkerExitedEventsConnectionArgs = {
   orderBy?: Maybe<WorkerExitedEventOrderByInput>
 }
 
-export type QueryWorkerPayoutEventsArgs = {
-  offset?: Maybe<Scalars['Int']>
-  limit?: Maybe<Scalars['Int']>
-  where?: Maybe<WorkerPayoutEventWhereInput>
-  orderBy?: Maybe<WorkerPayoutEventOrderByInput>
-}
-
-export type QueryWorkerPayoutEventByUniqueInputArgs = {
-  where: WorkerPayoutEventWhereUniqueInput
-}
-
-export type QueryWorkerPayoutEventsConnectionArgs = {
-  first?: Maybe<Scalars['Int']>
-  after?: Maybe<Scalars['String']>
-  last?: Maybe<Scalars['Int']>
-  before?: Maybe<Scalars['String']>
-  where?: Maybe<WorkerPayoutEventWhereInput>
-  orderBy?: Maybe<WorkerPayoutEventOrderByInput>
-}
-
 export type QueryWorkerRewardAccountUpdatedEventsArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -4679,6 +4731,26 @@ export type QueryWorkerRoleAccountUpdatedEventsConnectionArgs = {
   orderBy?: Maybe<WorkerRoleAccountUpdatedEventOrderByInput>
 }
 
+export type QueryWorkerStartedLeavingEventsArgs = {
+  offset?: Maybe<Scalars['Int']>
+  limit?: Maybe<Scalars['Int']>
+  where?: Maybe<WorkerStartedLeavingEventWhereInput>
+  orderBy?: Maybe<WorkerStartedLeavingEventOrderByInput>
+}
+
+export type QueryWorkerStartedLeavingEventByUniqueInputArgs = {
+  where: WorkerStartedLeavingEventWhereUniqueInput
+}
+
+export type QueryWorkerStartedLeavingEventsConnectionArgs = {
+  first?: Maybe<Scalars['Int']>
+  after?: Maybe<Scalars['String']>
+  last?: Maybe<Scalars['Int']>
+  before?: Maybe<Scalars['String']>
+  where?: Maybe<WorkerStartedLeavingEventWhereInput>
+  orderBy?: Maybe<WorkerStartedLeavingEventOrderByInput>
+}
+
 export type QueryWorkersArgs = {
   offset?: Maybe<Scalars['Int']>
   limit?: Maybe<Scalars['Int']>
@@ -4890,6 +4962,124 @@ export type ReferralCutUpdatedEventWhereUniqueInput = {
   id: Scalars['ID']
 }
 
+export type RewardPaidEvent = BaseGraphQlObject & {
+  __typename?: 'RewardPaidEvent'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  event: Event
+  eventId: Scalars['String']
+  group: WorkingGroup
+  groupId: Scalars['String']
+  worker: Worker
+  workerId: Scalars['String']
+  /** The account that recieved the reward */
+  rewardAccount: Scalars['String']
+  /** Amount recieved */
+  amount: Scalars['BigInt']
+}
+
+export type RewardPaidEventConnection = {
+  __typename?: 'RewardPaidEventConnection'
+  totalCount: Scalars['Int']
+  edges: Array<RewardPaidEventEdge>
+  pageInfo: PageInfo
+}
+
+export type RewardPaidEventCreateInput = {
+  eventId: Scalars['ID']
+  groupId: Scalars['ID']
+  workerId: Scalars['ID']
+  rewardAccount: Scalars['String']
+  amount: Scalars['BigInt']
+}
+
+export type RewardPaidEventEdge = {
+  __typename?: 'RewardPaidEventEdge'
+  node: RewardPaidEvent
+  cursor: Scalars['String']
+}
+
+export enum RewardPaidEventOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  EventIdAsc = 'eventId_ASC',
+  EventIdDesc = 'eventId_DESC',
+  GroupIdAsc = 'groupId_ASC',
+  GroupIdDesc = 'groupId_DESC',
+  WorkerIdAsc = 'workerId_ASC',
+  WorkerIdDesc = 'workerId_DESC',
+  RewardAccountAsc = 'rewardAccount_ASC',
+  RewardAccountDesc = 'rewardAccount_DESC',
+  AmountAsc = 'amount_ASC',
+  AmountDesc = 'amount_DESC',
+}
+
+export type RewardPaidEventUpdateInput = {
+  eventId?: Maybe<Scalars['ID']>
+  groupId?: Maybe<Scalars['ID']>
+  workerId?: Maybe<Scalars['ID']>
+  rewardAccount?: Maybe<Scalars['String']>
+  amount?: Maybe<Scalars['BigInt']>
+}
+
+export type RewardPaidEventWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  eventId_eq?: Maybe<Scalars['ID']>
+  eventId_in?: Maybe<Array<Scalars['ID']>>
+  groupId_eq?: Maybe<Scalars['ID']>
+  groupId_in?: Maybe<Array<Scalars['ID']>>
+  workerId_eq?: Maybe<Scalars['ID']>
+  workerId_in?: Maybe<Array<Scalars['ID']>>
+  rewardAccount_eq?: Maybe<Scalars['String']>
+  rewardAccount_contains?: Maybe<Scalars['String']>
+  rewardAccount_startsWith?: Maybe<Scalars['String']>
+  rewardAccount_endsWith?: Maybe<Scalars['String']>
+  rewardAccount_in?: Maybe<Array<Scalars['String']>>
+  amount_eq?: Maybe<Scalars['BigInt']>
+  amount_gt?: Maybe<Scalars['BigInt']>
+  amount_gte?: Maybe<Scalars['BigInt']>
+  amount_lt?: Maybe<Scalars['BigInt']>
+  amount_lte?: Maybe<Scalars['BigInt']>
+  amount_in?: Maybe<Array<Scalars['BigInt']>>
+}
+
+export type RewardPaidEventWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type StakeDecreasedEvent = BaseGraphQlObject & {
   __typename?: 'StakeDecreasedEvent'
   id: Scalars['ID']
@@ -5897,7 +6087,7 @@ export type Worker = BaseGraphQlObject & {
   isLead: Scalars['Boolean']
   /** Current role stake (in JOY) */
   stake: Scalars['BigInt']
-  payouts: Array<WorkerPayoutEvent>
+  payouts: Array<RewardPaidEvent>
   /** Blocknumber of the block the worker was hired at */
   hiredAtBlock: Scalars['Int']
   /** Time the worker was hired at */
@@ -5919,6 +6109,7 @@ export type Worker = BaseGraphQlObject & {
   workerrewardaccountupdatedeventworker?: Maybe<Array<WorkerRewardAccountUpdatedEvent>>
   workerrewardamountupdatedeventworker?: Maybe<Array<WorkerRewardAmountUpdatedEvent>>
   workerroleaccountupdatedeventworker?: Maybe<Array<WorkerRoleAccountUpdatedEvent>>
+  workerstartedleavingeventworker?: Maybe<Array<WorkerStartedLeavingEvent>>
   workinggroupleader?: Maybe<Array<WorkingGroup>>
 }
 
@@ -6082,117 +6273,6 @@ export enum WorkerOrderByInput {
   StorageDesc = 'storage_DESC',
 }
 
-export type WorkerPayoutEvent = BaseGraphQlObject & {
-  __typename?: 'WorkerPayoutEvent'
-  id: Scalars['ID']
-  createdAt: Scalars['DateTime']
-  createdById: Scalars['String']
-  updatedAt?: Maybe<Scalars['DateTime']>
-  updatedById?: Maybe<Scalars['String']>
-  deletedAt?: Maybe<Scalars['DateTime']>
-  deletedById?: Maybe<Scalars['String']>
-  version: Scalars['Int']
-  /** Type of the worker payout */
-  type?: Maybe<PayoutType>
-  worker: Worker
-  workerId: Scalars['String']
-  /** Amount recieved */
-  recieved: Scalars['BigInt']
-  /** Amount missed (due to, for example, empty working group budget) */
-  missed: Scalars['BigInt']
-}
-
-export type WorkerPayoutEventConnection = {
-  __typename?: 'WorkerPayoutEventConnection'
-  totalCount: Scalars['Int']
-  edges: Array<WorkerPayoutEventEdge>
-  pageInfo: PageInfo
-}
-
-export type WorkerPayoutEventCreateInput = {
-  type?: Maybe<PayoutType>
-  workerId: Scalars['ID']
-  recieved: Scalars['BigInt']
-  missed: Scalars['BigInt']
-}
-
-export type WorkerPayoutEventEdge = {
-  __typename?: 'WorkerPayoutEventEdge'
-  node: WorkerPayoutEvent
-  cursor: Scalars['String']
-}
-
-export enum WorkerPayoutEventOrderByInput {
-  CreatedAtAsc = 'createdAt_ASC',
-  CreatedAtDesc = 'createdAt_DESC',
-  UpdatedAtAsc = 'updatedAt_ASC',
-  UpdatedAtDesc = 'updatedAt_DESC',
-  DeletedAtAsc = 'deletedAt_ASC',
-  DeletedAtDesc = 'deletedAt_DESC',
-  TypeAsc = 'type_ASC',
-  TypeDesc = 'type_DESC',
-  WorkerIdAsc = 'workerId_ASC',
-  WorkerIdDesc = 'workerId_DESC',
-  RecievedAsc = 'recieved_ASC',
-  RecievedDesc = 'recieved_DESC',
-  MissedAsc = 'missed_ASC',
-  MissedDesc = 'missed_DESC',
-}
-
-export type WorkerPayoutEventUpdateInput = {
-  type?: Maybe<PayoutType>
-  workerId?: Maybe<Scalars['ID']>
-  recieved?: Maybe<Scalars['BigInt']>
-  missed?: Maybe<Scalars['BigInt']>
-}
-
-export type WorkerPayoutEventWhereInput = {
-  id_eq?: Maybe<Scalars['ID']>
-  id_in?: Maybe<Array<Scalars['ID']>>
-  createdAt_eq?: Maybe<Scalars['DateTime']>
-  createdAt_lt?: Maybe<Scalars['DateTime']>
-  createdAt_lte?: Maybe<Scalars['DateTime']>
-  createdAt_gt?: Maybe<Scalars['DateTime']>
-  createdAt_gte?: Maybe<Scalars['DateTime']>
-  createdById_eq?: Maybe<Scalars['ID']>
-  createdById_in?: Maybe<Array<Scalars['ID']>>
-  updatedAt_eq?: Maybe<Scalars['DateTime']>
-  updatedAt_lt?: Maybe<Scalars['DateTime']>
-  updatedAt_lte?: Maybe<Scalars['DateTime']>
-  updatedAt_gt?: Maybe<Scalars['DateTime']>
-  updatedAt_gte?: Maybe<Scalars['DateTime']>
-  updatedById_eq?: Maybe<Scalars['ID']>
-  updatedById_in?: Maybe<Array<Scalars['ID']>>
-  deletedAt_all?: Maybe<Scalars['Boolean']>
-  deletedAt_eq?: Maybe<Scalars['DateTime']>
-  deletedAt_lt?: Maybe<Scalars['DateTime']>
-  deletedAt_lte?: Maybe<Scalars['DateTime']>
-  deletedAt_gt?: Maybe<Scalars['DateTime']>
-  deletedAt_gte?: Maybe<Scalars['DateTime']>
-  deletedById_eq?: Maybe<Scalars['ID']>
-  deletedById_in?: Maybe<Array<Scalars['ID']>>
-  type_eq?: Maybe<PayoutType>
-  type_in?: Maybe<Array<PayoutType>>
-  workerId_eq?: Maybe<Scalars['ID']>
-  workerId_in?: Maybe<Array<Scalars['ID']>>
-  recieved_eq?: Maybe<Scalars['BigInt']>
-  recieved_gt?: Maybe<Scalars['BigInt']>
-  recieved_gte?: Maybe<Scalars['BigInt']>
-  recieved_lt?: Maybe<Scalars['BigInt']>
-  recieved_lte?: Maybe<Scalars['BigInt']>
-  recieved_in?: Maybe<Array<Scalars['BigInt']>>
-  missed_eq?: Maybe<Scalars['BigInt']>
-  missed_gt?: Maybe<Scalars['BigInt']>
-  missed_gte?: Maybe<Scalars['BigInt']>
-  missed_lt?: Maybe<Scalars['BigInt']>
-  missed_lte?: Maybe<Scalars['BigInt']>
-  missed_in?: Maybe<Array<Scalars['BigInt']>>
-}
-
-export type WorkerPayoutEventWhereUniqueInput = {
-  id: Scalars['ID']
-}
-
 export type WorkerRewardAccountUpdatedEvent = BaseGraphQlObject & {
   __typename?: 'WorkerRewardAccountUpdatedEvent'
   id: Scalars['ID']
@@ -6512,6 +6592,112 @@ export type WorkerRoleAccountUpdatedEventWhereUniqueInput = {
   id: Scalars['ID']
 }
 
+export type WorkerStartedLeavingEvent = BaseGraphQlObject & {
+  __typename?: 'WorkerStartedLeavingEvent'
+  id: Scalars['ID']
+  createdAt: Scalars['DateTime']
+  createdById: Scalars['String']
+  updatedAt?: Maybe<Scalars['DateTime']>
+  updatedById?: Maybe<Scalars['String']>
+  deletedAt?: Maybe<Scalars['DateTime']>
+  deletedById?: Maybe<Scalars['String']>
+  version: Scalars['Int']
+  event: Event
+  eventId: Scalars['String']
+  group: WorkingGroup
+  groupId: Scalars['String']
+  worker: Worker
+  workerId: Scalars['String']
+  /** Optional rationale */
+  rationale?: Maybe<Scalars['String']>
+}
+
+export type WorkerStartedLeavingEventConnection = {
+  __typename?: 'WorkerStartedLeavingEventConnection'
+  totalCount: Scalars['Int']
+  edges: Array<WorkerStartedLeavingEventEdge>
+  pageInfo: PageInfo
+}
+
+export type WorkerStartedLeavingEventCreateInput = {
+  eventId: Scalars['ID']
+  groupId: Scalars['ID']
+  workerId: Scalars['ID']
+  rationale?: Maybe<Scalars['String']>
+}
+
+export type WorkerStartedLeavingEventEdge = {
+  __typename?: 'WorkerStartedLeavingEventEdge'
+  node: WorkerStartedLeavingEvent
+  cursor: Scalars['String']
+}
+
+export enum WorkerStartedLeavingEventOrderByInput {
+  CreatedAtAsc = 'createdAt_ASC',
+  CreatedAtDesc = 'createdAt_DESC',
+  UpdatedAtAsc = 'updatedAt_ASC',
+  UpdatedAtDesc = 'updatedAt_DESC',
+  DeletedAtAsc = 'deletedAt_ASC',
+  DeletedAtDesc = 'deletedAt_DESC',
+  EventIdAsc = 'eventId_ASC',
+  EventIdDesc = 'eventId_DESC',
+  GroupIdAsc = 'groupId_ASC',
+  GroupIdDesc = 'groupId_DESC',
+  WorkerIdAsc = 'workerId_ASC',
+  WorkerIdDesc = 'workerId_DESC',
+  RationaleAsc = 'rationale_ASC',
+  RationaleDesc = 'rationale_DESC',
+}
+
+export type WorkerStartedLeavingEventUpdateInput = {
+  eventId?: Maybe<Scalars['ID']>
+  groupId?: Maybe<Scalars['ID']>
+  workerId?: Maybe<Scalars['ID']>
+  rationale?: Maybe<Scalars['String']>
+}
+
+export type WorkerStartedLeavingEventWhereInput = {
+  id_eq?: Maybe<Scalars['ID']>
+  id_in?: Maybe<Array<Scalars['ID']>>
+  createdAt_eq?: Maybe<Scalars['DateTime']>
+  createdAt_lt?: Maybe<Scalars['DateTime']>
+  createdAt_lte?: Maybe<Scalars['DateTime']>
+  createdAt_gt?: Maybe<Scalars['DateTime']>
+  createdAt_gte?: Maybe<Scalars['DateTime']>
+  createdById_eq?: Maybe<Scalars['ID']>
+  createdById_in?: Maybe<Array<Scalars['ID']>>
+  updatedAt_eq?: Maybe<Scalars['DateTime']>
+  updatedAt_lt?: Maybe<Scalars['DateTime']>
+  updatedAt_lte?: Maybe<Scalars['DateTime']>
+  updatedAt_gt?: Maybe<Scalars['DateTime']>
+  updatedAt_gte?: Maybe<Scalars['DateTime']>
+  updatedById_eq?: Maybe<Scalars['ID']>
+  updatedById_in?: Maybe<Array<Scalars['ID']>>
+  deletedAt_all?: Maybe<Scalars['Boolean']>
+  deletedAt_eq?: Maybe<Scalars['DateTime']>
+  deletedAt_lt?: Maybe<Scalars['DateTime']>
+  deletedAt_lte?: Maybe<Scalars['DateTime']>
+  deletedAt_gt?: Maybe<Scalars['DateTime']>
+  deletedAt_gte?: Maybe<Scalars['DateTime']>
+  deletedById_eq?: Maybe<Scalars['ID']>
+  deletedById_in?: Maybe<Array<Scalars['ID']>>
+  eventId_eq?: Maybe<Scalars['ID']>
+  eventId_in?: Maybe<Array<Scalars['ID']>>
+  groupId_eq?: Maybe<Scalars['ID']>
+  groupId_in?: Maybe<Array<Scalars['ID']>>
+  workerId_eq?: Maybe<Scalars['ID']>
+  workerId_in?: Maybe<Array<Scalars['ID']>>
+  rationale_eq?: Maybe<Scalars['String']>
+  rationale_contains?: Maybe<Scalars['String']>
+  rationale_startsWith?: Maybe<Scalars['String']>
+  rationale_endsWith?: Maybe<Scalars['String']>
+  rationale_in?: Maybe<Array<Scalars['String']>>
+}
+
+export type WorkerStartedLeavingEventWhereUniqueInput = {
+  id: Scalars['ID']
+}
+
 export type WorkerStatus = WorkerStatusActive | WorkerStatusLeft | WorkerStatusTerminated
 
 export type WorkerStatusActive = {
@@ -6566,17 +6752,17 @@ export type WorkerStatusActiveWhereUniqueInput = {
 
 export type WorkerStatusLeft = {
   __typename?: 'WorkerStatusLeft'
-  workerLeftEventId: Scalars['String']
+  workerStartedLeavingEventId: Scalars['String']
   workerExitedEventId?: Maybe<Scalars['String']>
 }
 
 export type WorkerStatusLeftCreateInput = {
-  workerLeftEventId: Scalars['String']
+  workerStartedLeavingEventId: Scalars['String']
   workerExitedEventId?: Maybe<Scalars['String']>
 }
 
 export type WorkerStatusLeftUpdateInput = {
-  workerLeftEventId?: Maybe<Scalars['String']>
+  workerStartedLeavingEventId?: Maybe<Scalars['String']>
   workerExitedEventId?: Maybe<Scalars['String']>
 }
 
@@ -6605,11 +6791,11 @@ export type WorkerStatusLeftWhereInput = {
   deletedAt_gte?: Maybe<Scalars['DateTime']>
   deletedById_eq?: Maybe<Scalars['ID']>
   deletedById_in?: Maybe<Array<Scalars['ID']>>
-  workerLeftEventId_eq?: Maybe<Scalars['String']>
-  workerLeftEventId_contains?: Maybe<Scalars['String']>
-  workerLeftEventId_startsWith?: Maybe<Scalars['String']>
-  workerLeftEventId_endsWith?: Maybe<Scalars['String']>
-  workerLeftEventId_in?: Maybe<Array<Scalars['String']>>
+  workerStartedLeavingEventId_eq?: Maybe<Scalars['String']>
+  workerStartedLeavingEventId_contains?: Maybe<Scalars['String']>
+  workerStartedLeavingEventId_startsWith?: Maybe<Scalars['String']>
+  workerStartedLeavingEventId_endsWith?: Maybe<Scalars['String']>
+  workerStartedLeavingEventId_in?: Maybe<Array<Scalars['String']>>
   workerExitedEventId_eq?: Maybe<Scalars['String']>
   workerExitedEventId_contains?: Maybe<Scalars['String']>
   workerExitedEventId_startsWith?: Maybe<Scalars['String']>
@@ -6809,6 +6995,7 @@ export type WorkingGroup = BaseGraphQlObject & {
   openingaddedeventgroup?: Maybe<Array<OpeningAddedEvent>>
   openingcanceledeventgroup?: Maybe<Array<OpeningCanceledEvent>>
   openingfilledeventgroup?: Maybe<Array<OpeningFilledEvent>>
+  rewardpaideventgroup?: Maybe<Array<RewardPaidEvent>>
   stakedecreasedeventgroup?: Maybe<Array<StakeDecreasedEvent>>
   stakeincreasedeventgroup?: Maybe<Array<StakeIncreasedEvent>>
   stakeslashedeventgroup?: Maybe<Array<StakeSlashedEvent>>
@@ -6819,6 +7006,7 @@ export type WorkingGroup = BaseGraphQlObject & {
   workerrewardaccountupdatedeventgroup?: Maybe<Array<WorkerRewardAccountUpdatedEvent>>
   workerrewardamountupdatedeventgroup?: Maybe<Array<WorkerRewardAmountUpdatedEvent>>
   workerroleaccountupdatedeventgroup?: Maybe<Array<WorkerRoleAccountUpdatedEvent>>
+  workerstartedleavingeventgroup?: Maybe<Array<WorkerStartedLeavingEvent>>
 }
 
 export type WorkingGroupApplication = BaseGraphQlObject & {
@@ -6912,6 +7100,7 @@ export type WorkingGroupApplicationStatus =
   | ApplicationStatusAccepted
   | ApplicationStatusRejected
   | ApplicationStatusWithdrawn
+  | ApplicationStatusCancelled
 
 export type WorkingGroupApplicationUpdateInput = {
   createdAt?: Maybe<Scalars['DateTime']>

+ 219 - 31
tests/integration-tests/src/fixtures/workingGroupsModule.ts

@@ -14,6 +14,8 @@ import {
   WorkingGroupOpening,
   WorkingGroupOpeningType,
   Worker,
+  ApplicationWithdrawnEvent,
+  OpeningCanceledEvent,
 } from '../QueryNodeApiSchema.generated'
 import { ApplicationMetadata, OpeningMetadata } from '@joystream/metadata-protobuf'
 import {
@@ -23,6 +25,7 @@ import {
   OpeningAddedEventDetails,
   OpeningFilledEventDetails,
   lockIdByWorkingGroup,
+  EventDetails,
 } from '../types'
 import { Application, ApplicationId, Opening, OpeningId } from '@joystream/types/working-group'
 import { Utils } from '../utils'
@@ -34,6 +37,7 @@ import { registry } from '@joystream/types'
 // TODO: Fetch from runtime when possible!
 const MIN_APPLICATION_STAKE = new BN(2000)
 const MIN_USTANKING_PERIOD = 43201
+export const LEADER_OPENING_STAKE = new BN(2000)
 
 export type OpeningParams = {
   stake: BN
@@ -50,11 +54,12 @@ const queryNodeQuestionTypeToMetadataQuestionType = (type: ApplicationFormQuesti
   return OpeningMetadata.ApplicationFormQuestion.InputType.TEXTAREA
 }
 
-export class SudoCreateLeadOpeningFixture extends BaseFixture {
+export class CreateOpeningFixture extends BaseFixture {
   private query: QueryNodeApi
   private group: WorkingGroupModuleName
   private debug: Debugger.Debugger
   private openingParams: OpeningParams
+  private asSudo: boolean
 
   private event?: OpeningAddedEventDetails
   private tx?: SubmittableExtrinsic<'promise'>
@@ -64,8 +69,8 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
     unstakingPeriod: MIN_USTANKING_PERIOD,
     reward: new BN(10),
     metadata: {
-      shortDescription: 'Test sudo lead opening',
-      description: '# Test sudo lead opening',
+      shortDescription: 'Test opening',
+      description: '# Test opening',
       expectedEndingTimestamp: Date.now() + 60,
       hiringLimit: 1,
       applicationDetails: '- This is automatically created opening, do not apply!',
@@ -109,13 +114,15 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
     api: Api,
     query: QueryNodeApi,
     group: WorkingGroupModuleName,
-    openingParams?: Partial<OpeningParams>
+    openingParams?: Partial<OpeningParams>,
+    asSudo = false
   ) {
     super(api)
     this.query = query
-    this.debug = Debugger('fixture:SudoCreateLeadOpeningFixture')
+    this.debug = Debugger('fixture:CreateOpeningFixture')
     this.group = group
     this.openingParams = _.merge(this.defaultOpeningParams, openingParams)
+    this.asSudo = asSudo
   }
 
   private assertOpeningMatchQueriedResult(
@@ -129,7 +136,7 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
     assert.equal(qOpening.createdAtBlock, eventDetails.blockNumber)
     assert.equal(qOpening.group.name, this.group)
     assert.equal(qOpening.rewardPerBlock, this.openingParams.reward.toString())
-    assert.equal(qOpening.type, WorkingGroupOpeningType.Leader)
+    assert.equal(qOpening.type, this.asSudo ? WorkingGroupOpeningType.Leader : WorkingGroupOpeningType.Regular)
     assert.equal(qOpening.status.__typename, 'OpeningStatusOpen')
     assert.equal(qOpening.stakeAmount, this.openingParams.stake.toString())
     assert.equal(qOpening.unstakingPeriod, this.openingParams.unstakingPeriod)
@@ -168,19 +175,24 @@ export class SudoCreateLeadOpeningFixture extends BaseFixture {
   }
 
   async execute(): Promise<void> {
-    this.tx = this.api.tx.sudo.sudo(
-      this.api.tx[this.group].addOpening(
-        Utils.metadataToBytes(this.getMetadata()),
-        'Leader',
-        { stake_amount: this.openingParams.stake, leaving_unstaking_period: this.openingParams.unstakingPeriod },
-        this.openingParams.reward
-      )
+    const account = this.asSudo
+      ? await (await this.api.query.sudo.key()).toString()
+      : await this.api.getLeadRoleKey(this.group)
+    this.tx = this.api.tx[this.group].addOpening(
+      Utils.metadataToBytes(this.getMetadata()),
+      this.asSudo ? 'Leader' : 'Regular',
+      { stake_amount: this.openingParams.stake, leaving_unstaking_period: this.openingParams.unstakingPeriod },
+      this.openingParams.reward
     )
-    const sudoKey = await this.api.query.sudo.key()
-    const result = await this.api.signAndSend(this.tx, sudoKey)
+    if (this.asSudo) {
+      this.tx = this.api.tx.sudo.sudo(this.tx)
+    }
+    const txFee = await this.api.estimateTxFee(this.tx, account)
+    await this.api.treasuryTransferBalance(account, txFee)
+    const result = await this.api.signAndSend(this.tx, account)
     this.event = await this.api.retrieveOpeningAddedEventDetails(result, this.group)
 
-    this.debug(`Lead opening created (id: ${this.event.openingId.toString()})`)
+    this.debug(`Opening created (id: ${this.event.openingId.toString()})`)
   }
 
   async runQueryNodeChecks(): Promise<void> {
@@ -333,7 +345,7 @@ export class ApplyOnOpeningHappyCaseFixture extends BaseFixture {
   }
 }
 
-export class SudoFillLeadOpening extends BaseFixture {
+export class SudoFillLeadOpeningFixture extends BaseFixture {
   private query: QueryNodeApi
   private group: WorkingGroupModuleName
   private debug: Debugger.Debugger
@@ -354,7 +366,7 @@ export class SudoFillLeadOpening extends BaseFixture {
   ) {
     super(api)
     this.query = query
-    this.debug = Debugger('fixture:SudoFillLeadOpening')
+    this.debug = Debugger('fixture:SudoFillLeadOpeningFixture')
     this.group = group
     this.openingId = openingId
     this.acceptedApplicationIds = acceptedApplicationIds
@@ -440,10 +452,10 @@ export class SudoFillLeadOpening extends BaseFixture {
     const eventDetails = this.event!
     const tx = this.tx!
     // Query the event and check event + hiredWorkers
-    await this.query.tryQueryWithTimeout(
+    const qEvent = (await this.query.tryQueryWithTimeout(
       () => this.query.getOpeningFilledEvent(eventDetails.blockNumber, eventDetails.indexInBlock),
-      (event) => this.assertQueriedOpeningFilledEventIsValid(eventDetails, tx.hash.toString(), event)
-    )
+      (qEvent) => this.assertQueriedOpeningFilledEventIsValid(eventDetails, tx.hash.toString(), qEvent)
+    )) as OpeningFilledEvent
 
     // Check opening status
     const {
@@ -453,22 +465,198 @@ export class SudoFillLeadOpening extends BaseFixture {
       throw new Error(`Query node: Opening ${this.openingId.toString()} not found!`)
     }
     assert.equal(qOpening.status.__typename, 'OpeningStatusFilled')
+    if (qOpening.status.__typename === 'OpeningStatusFilled') {
+      assert.equal(qOpening.status.openingFilledEventId, qEvent.id)
+    }
 
     // Check application statuses
-    const acceptedApplications = this.acceptedApplicationIds.map((id) => {
-      const application = qOpening.applications.find((a) => a.runtimeId === id.toNumber())
-      if (!application) {
-        throw new Error(`Application not found by id ${id.toString()} in opening ${qOpening.id}`)
+    const acceptedQApplications = this.acceptedApplicationIds.map((id) => {
+      const qApplication = qOpening.applications.find((a) => a.runtimeId === id.toNumber())
+      if (!qApplication) {
+        throw new Error(`Query node: Application not found by id ${id.toString()} in opening ${qOpening.id}`)
+      }
+      assert.equal(qApplication.status.__typename, 'ApplicationStatusAccepted')
+      if (qApplication.status.__typename === 'ApplicationStatusAccepted') {
+        assert.equal(qApplication.status.openingFilledEventId, qEvent.id)
       }
-      assert.equal(application.status.__typename, 'ApplicationStatusAccepted')
-      return application
+      return qApplication
     })
 
     qOpening.applications
-      .filter((a) => !acceptedApplications.some((acceptedA) => acceptedA.id === a.id))
-      .forEach((a) => assert.equal(a.status.__typename, 'ApplicationStatusRejected'))
+      .filter((a) => !acceptedQApplications.some((acceptedQA) => acceptedQA.id === a.id))
+      .forEach((qApplication) => {
+        assert.equal(qApplication.status.__typename, 'ApplicationStatusRejected')
+        if (qApplication.status.__typename === 'ApplicationStatusRejected') {
+          assert.equal(qApplication.status.openingFilledEventId, qEvent.id)
+        }
+      })
+
+    // Check working group lead
+    if (!qOpening.group.leader) {
+      throw new Error('Query node: Group leader not set!')
+    }
+    assert.equal(qOpening.group.leader.runtimeId, qEvent.workersHired[0].runtimeId)
+  }
+}
+
+export class WithdrawApplicationsFixture extends BaseFixture {
+  private query: QueryNodeApi
+  private group: WorkingGroupModuleName
+  private debug: Debugger.Debugger
+  private applicationIds: ApplicationId[]
+  private accounts: string[]
+
+  private txs: SubmittableExtrinsic<'promise'>[] = []
+  private events: EventDetails[] = []
+
+  public constructor(
+    api: Api,
+    query: QueryNodeApi,
+    group: WorkingGroupModuleName,
+    accounts: string[],
+    applicationIds: ApplicationId[]
+  ) {
+    super(api)
+    this.query = query
+    this.debug = Debugger('fixture:WithdrawApplicationsFixture')
+    this.group = group
+    this.accounts = accounts
+    this.applicationIds = applicationIds
+  }
+
+  async execute() {
+    this.txs = this.applicationIds.map((id) => this.api.tx[this.group].withdrawApplication(id))
+    const txFee = await this.api.estimateTxFee(this.txs[0], this.accounts[0])
+    await Promise.all(this.accounts.map((a) => this.api.treasuryTransferBalance(a, txFee)))
+    const results = await Promise.all(this.txs.map((tx, i) => this.api.signAndSend(tx, this.accounts[i])))
+    this.events = await Promise.all(
+      results.map((r) => this.api.retrieveWorkingGroupsEventDetails(r, this.group, 'ApplicationWithdrawn'))
+    )
+  }
+
+  private assertQueriedApplicationWithdrawnEventIsValid(
+    applicationId: ApplicationId,
+    txHash: string,
+    qEvent?: ApplicationWithdrawnEvent
+  ) {
+    if (!qEvent) {
+      throw new Error('Query node: ApplicationWithdrawnEvent not found!')
+    }
+    assert.equal(qEvent.event.inExtrinsic, txHash)
+    assert.equal(qEvent.event.type, EventType.ApplicationWithdrawn)
+    assert.equal(qEvent.application.runtimeId, applicationId.toNumber())
+    assert.equal(qEvent.group.name, this.group)
+  }
+
+  private assertApplicationStatusIsValid(
+    qEvent: ApplicationWithdrawnEvent,
+    qApplication?: WorkingGroupApplication | null
+  ) {
+    if (!qApplication) {
+      throw new Error('Query node: Application not found!')
+    }
+    assert.equal(qApplication.status.__typename, 'ApplicationStatusWithdrawn')
+    if (qApplication.status.__typename === 'ApplicationStatusWithdrawn') {
+      assert.equal(qApplication.status.applicationWithdrawnEventId, qEvent.id)
+    }
+  }
 
-    // TODO: LeadSet event
-    // TODO: check WorkingGroup.lead
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+
+    // Query the evens
+    const qEvents = (await Promise.all(
+      this.events.map((eventDetails, i) =>
+        this.query.tryQueryWithTimeout(
+          () => this.query.getApplicationWithdrawnEvent(eventDetails.blockNumber, eventDetails.indexInBlock),
+          (qEvent) =>
+            this.assertQueriedApplicationWithdrawnEventIsValid(
+              this.applicationIds[i],
+              this.txs[i].hash.toString(),
+              qEvent
+            )
+        )
+      )
+    )) as ApplicationWithdrawnEvent[]
+
+    // Check application statuses
+    await Promise.all(
+      this.applicationIds.map(async (id, i) => {
+        const {
+          data: { workingGroupApplicationByUniqueInput: qApplication },
+        } = await this.query.getApplicationById(id, this.group)
+        this.assertApplicationStatusIsValid(qEvents[i], qApplication)
+      })
+    )
+  }
+}
+
+export class CancelOpeningFixture extends BaseFixture {
+  private query: QueryNodeApi
+  private group: WorkingGroupModuleName
+  private debug: Debugger.Debugger
+  private openingId: OpeningId
+
+  private tx?: SubmittableExtrinsic<'promise'>
+  private event?: EventDetails
+
+  public constructor(api: Api, query: QueryNodeApi, group: WorkingGroupModuleName, openingId: OpeningId) {
+    super(api)
+    this.query = query
+    this.debug = Debugger('fixture:CancelOpeningFixture')
+    this.group = group
+    this.openingId = openingId
+  }
+
+  async execute() {
+    const account = await this.api.getLeadRoleKey(this.group)
+    this.tx = this.api.tx[this.group].cancelOpening(this.openingId)
+    const txFee = await this.api.estimateTxFee(this.tx, account)
+    await this.api.treasuryTransferBalance(account, txFee)
+    const result = await this.api.signAndSend(this.tx, account)
+    this.event = await this.api.retrieveWorkingGroupsEventDetails(result, this.group, 'OpeningCanceled')
+  }
+
+  private assertQueriedOpeningIsValid(qEvent: OpeningCanceledEvent, qOpening?: WorkingGroupOpening | null) {
+    if (!qOpening) {
+      throw new Error('Query node: Opening not found!')
+    }
+    assert.equal(qOpening.status.__typename, 'OpeningStatusCancelled')
+    if (qOpening.status.__typename === 'OpeningStatusCancelled') {
+      assert.equal(qOpening.status.openingCancelledEventId, qEvent.id)
+    }
+    qOpening.applications.forEach((a) => this.assertApplicationStatusIsValid(qEvent, a))
+  }
+
+  private assertApplicationStatusIsValid(qEvent: OpeningCanceledEvent, qApplication: WorkingGroupApplication) {
+    // It's possible that some of the applications have been withdrawn
+    assert.oneOf(qApplication.status.__typename, ['ApplicationStatusWithdrawn', 'ApplicationStatusCancelled'])
+    if (qApplication.status.__typename === 'ApplicationStatusCancelled') {
+      assert.equal(qApplication.status.openingCancelledEventId, qEvent.id)
+    }
+  }
+
+  private assertQueriedOpeningCancelledEventIsValid(txHash: string, qEvent?: OpeningCanceledEvent) {
+    if (!qEvent) {
+      throw new Error('Query node: OpeningCancelledEvent not found!')
+    }
+    assert.equal(qEvent.event.inExtrinsic, txHash)
+    assert.equal(qEvent.event.type, EventType.OpeningCanceled)
+    assert.equal(qEvent.group.name, this.group)
+    assert.equal(qEvent.opening.runtimeId, this.openingId.toNumber())
+  }
+
+  async runQueryNodeChecks(): Promise<void> {
+    await super.runQueryNodeChecks()
+    const tx = this.tx!
+    const event = this.event!
+    const qEvent = (await this.query.tryQueryWithTimeout(
+      () => this.query.getOpeningCancelledEvent(event.blockNumber, event.indexInBlock),
+      (qEvent) => this.assertQueriedOpeningCancelledEventIsValid(tx.hash.toString(), qEvent)
+    )) as OpeningCanceledEvent
+    const {
+      data: { workingGroupOpeningByUniqueInput: qOpening },
+    } = await this.query.getOpeningById(this.openingId, this.group)
+    this.assertQueriedOpeningIsValid(qEvent, qOpening)
   }
 }

+ 5 - 6
tests/integration-tests/src/flows/working-groups/leadOpening.ts

@@ -1,8 +1,8 @@
 import { FlowProps } from '../../Flow'
 import {
   ApplyOnOpeningHappyCaseFixture,
-  SudoCreateLeadOpeningFixture,
-  SudoFillLeadOpening,
+  CreateOpeningFixture,
+  SudoFillLeadOpeningFixture,
 } from '../../fixtures/workingGroupsModule'
 
 import Debugger from 'debug'
@@ -17,7 +17,7 @@ export default async function leadOpening({ api, query, env }: FlowProps): Promi
       debug('Started')
       api.enableDebugTxLogs()
 
-      const sudoLeadOpeningFixture = new SudoCreateLeadOpeningFixture(api, query, group)
+      const sudoLeadOpeningFixture = new CreateOpeningFixture(api, query, group, undefined, true)
       const openingRunner = new FixtureRunner(sudoLeadOpeningFixture)
       await openingRunner.run()
       const openingId = sudoLeadOpeningFixture.getCreatedOpeningId()
@@ -52,11 +52,10 @@ export default async function leadOpening({ api, query, env }: FlowProps): Promi
       const applicationId = applyOnOpeningFixture.getCreatedApplicationId()
 
       // Run query node checks once this part of the flow is done
-      await openingRunner.runQueryNodeChecks()
-      await applicationRunner.runQueryNodeChecks()
+      await Promise.all([openingRunner.runQueryNodeChecks(), applicationRunner.runQueryNodeChecks()])
 
       // Fill opening
-      const fillOpeningFixture = new SudoFillLeadOpening(api, query, group, openingId, [applicationId])
+      const fillOpeningFixture = new SudoFillLeadOpeningFixture(api, query, group, openingId, [applicationId])
       await new FixtureRunner(fillOpeningFixture).runWithQueryNodeChecks()
 
       debug('Done')

+ 101 - 0
tests/integration-tests/src/flows/working-groups/openingAndApplicationStatus.ts

@@ -0,0 +1,101 @@
+import { FlowProps } from '../../Flow'
+import {
+  ApplyOnOpeningHappyCaseFixture,
+  CancelOpeningFixture,
+  CreateOpeningFixture,
+  WithdrawApplicationsFixture,
+  LEADER_OPENING_STAKE,
+} from '../../fixtures/workingGroupsModule'
+
+import Debugger from 'debug'
+import { FixtureRunner } from '../../Fixture'
+import { AddStakingAccountsHappyCaseFixture, BuyMembershipHappyCaseFixture } from '../../fixtures/membershipModule'
+import { workingGroups } from '../../types'
+import { assert } from 'chai'
+
+export default async function openingAndApplicationStatusFlow({ api, query, env }: FlowProps): Promise<void> {
+  const APPLICATION_CREATE_N = parseInt(env.APPLICATION_STATUS_CREATE_N || '')
+  const APPLICATION_WITHDRAW_N = parseInt(env.APPLICATION_STATUS_WITHDRAW_N || '')
+  assert.isAbove(APPLICATION_CREATE_N, 0)
+  assert.isAbove(APPLICATION_WITHDRAW_N, 0)
+  assert.isBelow(APPLICATION_WITHDRAW_N, APPLICATION_CREATE_N)
+
+  await Promise.all(
+    workingGroups.map(async (group) => {
+      const debug = Debugger(`flow:opening-and-application-status:${group}`)
+      debug('Started')
+      api.enableDebugTxLogs()
+
+      // Transfer funds to leader staking acc to cover opening stake
+      const leaderStakingAcc = await api.getLeaderStakingKey(group)
+      await api.treasuryTransferBalance(leaderStakingAcc, LEADER_OPENING_STAKE)
+
+      // Create an opening
+      const sudoLeadOpeningFixture = new CreateOpeningFixture(api, query, group)
+      const openingRunner = new FixtureRunner(sudoLeadOpeningFixture)
+      await openingRunner.run()
+      const openingId = sudoLeadOpeningFixture.getCreatedOpeningId()
+      const openingParams = sudoLeadOpeningFixture.getDefaultOpeningParams()
+
+      // Create some applications
+      const applicantAccounts = (await api.createKeyPairs(APPLICATION_CREATE_N)).map((kp) => kp.address)
+      const stakingAccounts = (await api.createKeyPairs(APPLICATION_CREATE_N)).map((kp) => kp.address)
+
+      const buyMembershipFixture = new BuyMembershipHappyCaseFixture(api, query, applicantAccounts)
+      await new FixtureRunner(buyMembershipFixture).run()
+      const applicantMemberIds = buyMembershipFixture.getCreatedMembers()
+
+      const applicantContexts = applicantAccounts.map((account, i) => ({
+        account,
+        memberId: applicantMemberIds[i],
+      }))
+
+      await Promise.all(
+        applicantContexts.map((applicantContext, i) => {
+          const addStakingAccFixture = new AddStakingAccountsHappyCaseFixture(api, query, applicantContext, [
+            stakingAccounts[i],
+          ])
+          return new FixtureRunner(addStakingAccFixture).run()
+        })
+      )
+      await Promise.all(stakingAccounts.map((a) => api.treasuryTransferBalance(a, openingParams.stake)))
+      const applicationIds = await Promise.all(
+        applicantContexts.map(async (context, i) => {
+          const applyOnOpeningFixture = new ApplyOnOpeningHappyCaseFixture(
+            api,
+            query,
+            group,
+            context,
+            stakingAccounts[i],
+            openingId,
+            openingParams.metadata
+          )
+          const applicationRunner = new FixtureRunner(applyOnOpeningFixture)
+          await applicationRunner.run()
+          return applyOnOpeningFixture.getCreatedApplicationId()
+        })
+      )
+
+      // Withdraw some applications
+      const withdrawApplicationsFixture = new WithdrawApplicationsFixture(
+        api,
+        query,
+        group,
+        applicantAccounts.slice(0, APPLICATION_WITHDRAW_N),
+        applicationIds.slice(0, APPLICATION_WITHDRAW_N)
+      )
+      const withdrawApplicationsRunner = new FixtureRunner(withdrawApplicationsFixture)
+      await withdrawApplicationsRunner.run()
+
+      // Cancel the opening
+      const cancelOpeningFixture = new CancelOpeningFixture(api, query, group, openingId)
+      const cancelOpeningRunner = new FixtureRunner(cancelOpeningFixture)
+      await cancelOpeningRunner.run()
+
+      // Run query node check
+      await Promise.all([withdrawApplicationsRunner.runQueryNodeChecks(), cancelOpeningRunner.runQueryNodeChecks()])
+
+      debug('Done')
+    })
+  )
+}

+ 3 - 1
tests/integration-tests/src/scenarios/workingGroups.ts

@@ -1,6 +1,8 @@
 import leadOpening from '../flows/working-groups/leadOpening'
+import openingAndApplicationStatus from '../flows/working-groups/openingAndApplicationStatus'
 import { scenario } from '../Scenario'
 
 scenario(async ({ job }) => {
-  job('sudo lead opening', leadOpening)
+  const sudoHireLead = job('sudo lead opening', leadOpening)
+  job('opening and application status', openingAndApplicationStatus).requires(sudoHireLead)
 })

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 578 - 17
yarn.lock


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio