diff --git a/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.ts b/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.ts new file mode 100644 index 00000000..ea98e45f --- /dev/null +++ b/packages/backend/src/db/migrations/20231025101146_add_flow_id_index_in_executions.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('executions', (table) => { + table.index('flow_id'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table('executions', (table) => { + table.dropIndex('flow_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.ts b/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.ts new file mode 100644 index 00000000..6dc51bb6 --- /dev/null +++ b/packages/backend/src/db/migrations/20231025101923_add_updated_at_index_in_executions.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('executions', (table) => { + table.index('updated_at'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table('executions', (table) => { + table.dropIndex('updated_at'); + }); +} diff --git a/packages/backend/src/graphql/queries/get-executions.test.ts b/packages/backend/src/graphql/queries/get-executions.test.ts new file mode 100644 index 00000000..32758cbd --- /dev/null +++ b/packages/backend/src/graphql/queries/get-executions.test.ts @@ -0,0 +1,494 @@ +import request from 'supertest'; +import app from '../../app'; +import appConfig from '../../config/app'; +import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id'; +import { IJSONObject } from '@automatisch/types'; +import { createRole } from '../../../test/factories/role'; +import { createPermission } from '../../../test/factories/permission'; +import { createUser } from '../../../test/factories/user'; +import { createFlow } from '../../../test/factories/flow'; +import { createStep } from '../../../test/factories/step'; +import { createExecution } from '../../../test/factories/execution'; +import { createExecutionStep } from '../../../test/factories/execution-step'; +import { IRole, IUser, IExecution, IFlow, IStep } from '@automatisch/types'; + +describe('graphQL getExecutions query', () => { + const query = ` + query { + getExecutions(limit: 10, offset: 0) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + testRun + createdAt + updatedAt + status + flow { + id + name + active + steps { + iconUrl + } + } + } + } + } + } + `; + + const invalidToken = 'invalid-token'; + + describe('with unauthenticated user', () => { + it('should throw not authorized error', async () => { + const response = await request(app) + .post('/graphql') + .set('Authorization', invalidToken) + .send({ query }) + .expect(200); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toEqual('Not Authorised!'); + }); + }); + + describe('with authenticated user', () => { + describe('and without permissions', () => { + it('should throw not authorized error', async () => { + const userWithoutPermissions = await createUser(); + const token = createAuthTokenByUserId(userWithoutPermissions.id); + + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toEqual('Not authorized!'); + }); + }); + + describe('and with correct permission', () => { + let role: IRole, + currentUser: IUser, + anotherUser: IUser, + token: string, + flowOne: IFlow, + stepOneForFlowOne: IStep, + stepTwoForFlowOne: IStep, + executionOne: IExecution, + flowTwo: IFlow, + stepOneForFlowTwo: IStep, + stepTwoForFlowTwo: IStep, + executionTwo: IExecution, + flowThree: IFlow, + stepOneForFlowThree: IStep, + stepTwoForFlowThree: IStep, + executionThree: IExecution, + expectedResponseForExecutionOne: IJSONObject, + expectedResponseForExecutionTwo: IJSONObject, + expectedResponseForExecutionThree: IJSONObject; + + beforeEach(async () => { + role = await createRole({ + key: 'sample', + name: 'sample', + }); + + currentUser = await createUser({ + roleId: role.id, + fullName: 'Current User', + }); + + anotherUser = await createUser(); + + token = createAuthTokenByUserId(currentUser.id); + + flowOne = await createFlow({ + userId: currentUser.id, + }); + + stepOneForFlowOne = await createStep({ + flowId: flowOne.id, + }); + + stepTwoForFlowOne = await createStep({ + flowId: flowOne.id, + }); + + executionOne = await createExecution({ + flowId: flowOne.id, + }); + + await createExecutionStep({ + executionId: executionOne.id, + stepId: stepOneForFlowOne.id, + status: 'success', + }); + + await createExecutionStep({ + executionId: executionOne.id, + stepId: stepTwoForFlowOne.id, + status: 'success', + }); + + flowTwo = await createFlow({ + userId: currentUser.id, + }); + + stepOneForFlowTwo = await createStep({ + flowId: flowTwo.id, + }); + + stepTwoForFlowTwo = await createStep({ + flowId: flowTwo.id, + }); + + executionTwo = await createExecution({ + flowId: flowTwo.id, + }); + + await createExecutionStep({ + executionId: executionTwo.id, + stepId: stepOneForFlowTwo.id, + status: 'success', + }); + + await createExecutionStep({ + executionId: executionTwo.id, + stepId: stepTwoForFlowTwo.id, + status: 'failure', + }); + + flowThree = await createFlow({ + userId: anotherUser.id, + }); + + stepOneForFlowThree = await createStep({ + flowId: flowThree.id, + }); + + stepTwoForFlowThree = await createStep({ + flowId: flowThree.id, + }); + + executionThree = await createExecution({ + flowId: flowThree.id, + }); + + await createExecutionStep({ + executionId: executionThree.id, + stepId: stepOneForFlowThree.id, + status: 'success', + }); + + await createExecutionStep({ + executionId: executionThree.id, + stepId: stepTwoForFlowThree.id, + status: 'failure', + }); + + expectedResponseForExecutionOne = { + node: { + createdAt: (executionOne.createdAt as Date).getTime().toString(), + flow: { + active: flowOne.active, + id: flowOne.id, + name: flowOne.name, + steps: [ + { + iconUrl: `${appConfig.baseUrl}/apps/${stepOneForFlowOne.appKey}/assets/favicon.svg`, + }, + { + iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowOne.appKey}/assets/favicon.svg`, + }, + ], + }, + id: executionOne.id, + status: 'success', + testRun: executionOne.testRun, + updatedAt: (executionOne.updatedAt as Date).getTime().toString(), + }, + }; + + expectedResponseForExecutionTwo = { + node: { + createdAt: (executionTwo.createdAt as Date).getTime().toString(), + flow: { + active: flowTwo.active, + id: flowTwo.id, + name: flowTwo.name, + steps: [ + { + iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowTwo.appKey}/assets/favicon.svg`, + }, + { + iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowTwo.appKey}/assets/favicon.svg`, + }, + ], + }, + id: executionTwo.id, + status: 'failure', + testRun: executionTwo.testRun, + updatedAt: (executionTwo.updatedAt as Date).getTime().toString(), + }, + }; + + expectedResponseForExecutionThree = { + node: { + createdAt: (executionThree.createdAt as Date).getTime().toString(), + flow: { + active: flowThree.active, + id: flowThree.id, + name: flowThree.name, + steps: [ + { + iconUrl: `${appConfig.baseUrl}/apps/${stepOneForFlowThree.appKey}/assets/favicon.svg`, + }, + { + iconUrl: `${appConfig.baseUrl}/apps/${stepTwoForFlowThree.appKey}/assets/favicon.svg`, + }, + ], + }, + id: executionThree.id, + status: 'failure', + testRun: executionThree.testRun, + updatedAt: (executionThree.updatedAt as Date).getTime().toString(), + }, + }; + }); + + describe('and with isCreator condition', () => { + beforeEach(async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: role.id, + conditions: ['isCreator'], + }); + }); + + it('should return executions data of the current user', async () => { + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + const expectedResponsePayload = { + data: { + getExecutions: { + edges: [ + expectedResponseForExecutionTwo, + expectedResponseForExecutionOne, + ], + pageInfo: { currentPage: 1, totalPages: 1 }, + }, + }, + }; + + expect(response.body).toEqual(expectedResponsePayload); + }); + }); + + describe('and without isCreator condition', () => { + beforeEach(async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: role.id, + conditions: [], + }); + }); + + it('should return executions data of all users', async () => { + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + const expectedResponsePayload = { + data: { + getExecutions: { + edges: [ + expectedResponseForExecutionThree, + expectedResponseForExecutionTwo, + expectedResponseForExecutionOne, + ], + pageInfo: { currentPage: 1, totalPages: 1 }, + }, + }, + }; + + expect(response.body).toEqual(expectedResponsePayload); + }); + }); + + describe('and with filters', () => { + beforeEach(async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: role.id, + conditions: [], + }); + }); + + it('should return executions data for the specified flow', async () => { + const query = ` + query { + getExecutions(limit: 10, offset: 0, filters: { flowId: "${flowOne.id}" }) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + testRun + createdAt + updatedAt + status + flow { + id + name + active + steps { + iconUrl + } + } + } + } + } + } + `; + + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + const expectedResponsePayload = { + data: { + getExecutions: { + edges: [expectedResponseForExecutionOne], + pageInfo: { currentPage: 1, totalPages: 1 }, + }, + }, + }; + + expect(response.body).toEqual(expectedResponsePayload); + }); + + it('should return only executions data with success status', async () => { + const query = ` + query { + getExecutions(limit: 10, offset: 0, filters: { status: "success" }) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + testRun + createdAt + updatedAt + status + flow { + id + name + active + steps { + iconUrl + } + } + } + } + } + } + `; + + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + const expectedResponsePayload = { + data: { + getExecutions: { + edges: [expectedResponseForExecutionOne], + pageInfo: { currentPage: 1, totalPages: 1 }, + }, + }, + }; + + expect(response.body).toEqual(expectedResponsePayload); + }); + + it('should return only executions data within date range', async () => { + const createdAtFrom = (executionOne.createdAt as Date) + .getTime() + .toString(); + + const createdAtTo = (executionOne.createdAt as Date) + .getTime() + .toString(); + + const query = ` + query { + getExecutions(limit: 10, offset: 0, filters: { createdAt: { from: "${createdAtFrom}", to: "${createdAtTo}" }}) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + testRun + createdAt + updatedAt + status + flow { + id + name + active + steps { + iconUrl + } + } + } + } + } + } + `; + + const response = await request(app) + .post('/graphql') + .set('Authorization', token) + .send({ query }) + .expect(200); + + const expectedResponsePayload = { + data: { + getExecutions: { + edges: [expectedResponseForExecutionOne], + pageInfo: { currentPage: 1, totalPages: 1 }, + }, + }, + }; + + expect(response.body).toEqual(expectedResponsePayload); + }); + }); + }); + }); +}); diff --git a/packages/backend/src/graphql/queries/get-executions.ts b/packages/backend/src/graphql/queries/get-executions.ts index dec2ed38..64b2d6b3 100644 --- a/packages/backend/src/graphql/queries/get-executions.ts +++ b/packages/backend/src/graphql/queries/get-executions.ts @@ -1,11 +1,22 @@ import { raw } from 'objection'; +import { DateTime } from 'luxon'; import Context from '../../types/express/context'; import Execution from '../../models/execution'; import paginate from '../../helpers/pagination'; +type Filters = { + flowId?: string; + status?: string; + createdAt?: { + from?: string; + to?: string; + }; +} + type Params = { limit: number; offset: number; + filters?: Filters; }; const getExecutions = async ( @@ -15,6 +26,8 @@ const getExecutions = async ( ) => { const conditions = context.currentUser.can('read', 'Execution'); + const filters = params.filters; + const userExecutions = context.currentUser.$relatedQuery('executions'); const allExecutions = Execution.query(); const executionBaseQuery = conditions.isCreator ? userExecutions : allExecutions; @@ -32,16 +45,49 @@ const getExecutions = async ( .clone() .joinRelated('executionSteps as execution_steps') .select('executions.*', raw(selectStatusStatement)) + .groupBy('executions.id') + .orderBy('created_at', 'desc'); + + const computedExecutions = Execution + .query() + .with('executions', executions) .withSoftDeleted() .withGraphFetched({ flow: { steps: true, }, - }) - .groupBy('executions.id') - .orderBy('updated_at', 'desc'); + }); - return paginate(executions, params.limit, params.offset); + if (filters?.flowId) { + computedExecutions.where('executions.flow_id', filters.flowId); + } + + if (filters?.status) { + computedExecutions.where('executions.status', filters.status); + } + + if (filters?.createdAt) { + const createdAtFilter = filters.createdAt; + if (createdAtFilter.from) { + const isoFromDateTime = DateTime + .fromMillis( + parseInt(createdAtFilter.from, 10) + ) + .toISO(); + computedExecutions.where('executions.created_at', '>=', isoFromDateTime); + } + + if (createdAtFilter.to) { + const isoToDateTime = DateTime + .fromMillis( + parseInt(createdAtFilter.to, 10) + ) + .toISO(); + computedExecutions.where('executions.created_at', '<=', isoToDateTime); + } + } + + return paginate(computedExecutions, params.limit, params.offset); }; export default getExecutions; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 1c98a8b3..f5b50500 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -20,7 +20,11 @@ type Query { ): FlowConnection getStepWithTestExecutions(stepId: String!): [Step] getExecution(executionId: String!): Execution - getExecutions(limit: Int!, offset: Int!): ExecutionConnection + getExecutions( + limit: Int! + offset: Int! + filters: ExecutionFiltersInput + ): ExecutionConnection getExecutionSteps( executionId: String! limit: Int! @@ -795,6 +799,17 @@ type Notification { description: String } +input ExecutionCreatedAtFilterInput { + from: String + to: String +} + +input ExecutionFiltersInput { + flowId: String + createdAt: ExecutionCreatedAtFilterInput + status: String +} + schema { query: Query mutation: Mutation diff --git a/packages/backend/test/factories/execution.ts b/packages/backend/test/factories/execution.ts index 65597e9f..34b45fa0 100644 --- a/packages/backend/test/factories/execution.ts +++ b/packages/backend/test/factories/execution.ts @@ -4,6 +4,8 @@ import { createFlow } from './flow'; export const createExecution = async (params: Partial = {}) => { params.flowId = params?.flowId || (await createFlow()).id; params.testRun = params?.testRun || false; + params.createdAt = params?.createdAt || new Date().toISOString(); + params.updatedAt = params?.updatedAt || new Date().toISOString(); const [execution] = await global.knex .table('executions') diff --git a/packages/backend/test/factories/flow.ts b/packages/backend/test/factories/flow.ts index c33581d1..b43a2b62 100644 --- a/packages/backend/test/factories/flow.ts +++ b/packages/backend/test/factories/flow.ts @@ -4,6 +4,8 @@ import { createUser } from './user'; export const createFlow = async (params: Partial = {}) => { params.userId = params?.userId || (await createUser()).id; params.name = params?.name || 'Name your flow!'; + params.createdAt = params?.createdAt || new Date().toISOString(); + params.updatedAt = params?.updatedAt || new Date().toISOString(); const [flow] = await global.knex.table('flows').insert(params).returning('*'); diff --git a/packages/backend/test/factories/permission.ts b/packages/backend/test/factories/permission.ts index d82d96f6..aae670ea 100644 --- a/packages/backend/test/factories/permission.ts +++ b/packages/backend/test/factories/permission.ts @@ -1,24 +1,15 @@ -import { IPermission } from '@automatisch/types'; +import Permission from '../../src/models/permission'; import { createRole } from './role'; -type PermissionParams = { - roleId?: string; - action?: string; - subject?: string; -}; - -export const createPermission = async ( - params: PermissionParams = {} -): Promise => { - const permissionData = { - roleId: params?.roleId || (await createRole()).id, - action: params?.action || 'read', - subject: params?.subject || 'User', - }; +export const createPermission = async (params: Partial = {}) => { + params.roleId = params?.roleId || (await createRole()).id; + params.action = params?.action || 'read'; + params.subject = params?.subject || 'User'; + params.conditions = params?.conditions || ['isCreator']; const [permission] = await global.knex .table('permissions') - .insert(permissionData) + .insert(params) .returning('*'); return permission; diff --git a/packages/backend/test/factories/step.ts b/packages/backend/test/factories/step.ts index 409adda2..baaaf090 100644 --- a/packages/backend/test/factories/step.ts +++ b/packages/backend/test/factories/step.ts @@ -13,7 +13,9 @@ export const createStep = async (params: Partial = {}) => { .first(); params.position = params?.position || (lastStep?.position || 0) + 1; - params.status = params?.status || 'incomplete'; + params.status = params?.status || 'completed'; + params.appKey = + params?.appKey || (params.type === 'action' ? 'webhook' : 'deepl'); const [step] = await global.knex.table('steps').insert(params).returning('*'); diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index e8a70979..2e4e97d7 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -51,8 +51,8 @@ export interface IExecution { testRun: boolean; status: 'success' | 'failure'; executionSteps: IExecutionStep[]; - updatedAt: string; - createdAt: string; + updatedAt: string | Date; + createdAt: string | Date; } export interface IStep { @@ -83,8 +83,8 @@ export interface IFlow { active: boolean; status: 'paused' | 'published' | 'draft'; steps: IStep[]; - createdAt: string; - updatedAt: string; + createdAt: string | Date; + updatedAt: string | Date; remoteWebhookId: string; lastInternalId: () => Promise; } diff --git a/packages/web/src/components/ExecutionHeader/index.tsx b/packages/web/src/components/ExecutionHeader/index.tsx index 15fde3e4..71745ffa 100644 --- a/packages/web/src/components/ExecutionHeader/index.tsx +++ b/packages/web/src/components/ExecutionHeader/index.tsx @@ -39,11 +39,15 @@ function ExecutionId(props: Pick) { } function ExecutionDate(props: Pick) { - const createdAt = DateTime.fromMillis(parseInt(props.createdAt, 10)); + const createdAt = DateTime.fromMillis( + parseInt(props.createdAt as string, 10) + ); const relativeCreatedAt = createdAt.toRelative(); return ( - + {relativeCreatedAt} diff --git a/packages/web/src/components/ExecutionRow/index.tsx b/packages/web/src/components/ExecutionRow/index.tsx index 1baa49a3..31cef9b6 100644 --- a/packages/web/src/components/ExecutionRow/index.tsx +++ b/packages/web/src/components/ExecutionRow/index.tsx @@ -23,8 +23,10 @@ export default function ExecutionRow( const { execution } = props; const { flow } = execution; - const updatedAt = DateTime.fromMillis(parseInt(execution.updatedAt, 10)); - const relativeUpdatedAt = updatedAt.toRelative(); + const createdAt = DateTime.fromMillis( + parseInt(execution.createdAt as string, 10) + ); + const relativeCreatedAt = createdAt.toRelative(); return ( @@ -41,8 +43,8 @@ export default function ExecutionRow( - {formatMessage('execution.updatedAt', { - datetime: relativeUpdatedAt, + {formatMessage('execution.createdAt', { + datetime: relativeCreatedAt, })} diff --git a/packages/web/src/components/FlowRow/index.tsx b/packages/web/src/components/FlowRow/index.tsx index 0a6858e3..194f1869 100644 --- a/packages/web/src/components/FlowRow/index.tsx +++ b/packages/web/src/components/FlowRow/index.tsx @@ -65,8 +65,8 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement { setAnchorEl(contextButtonRef.current); }; - const createdAt = DateTime.fromMillis(parseInt(flow.createdAt, 10)); - const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt, 10)); + const createdAt = DateTime.fromMillis(parseInt(flow.createdAt as string, 10)); + const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt as string, 10)); const isUpdated = updatedAt > createdAt; const relativeCreatedAt = createdAt.toRelative(); const relativeUpdatedAt = updatedAt.toRelative(); diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 0931fe67..4c082efb 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -89,7 +89,7 @@ "executions.title": "Executions", "executions.noExecutions": "There is no execution data point to show.", "execution.id": "Execution ID: {id}", - "execution.updatedAt": "updated {datetime}", + "execution.createdAt": "created {datetime}", "execution.test": "Test run", "execution.statusSuccess": "Success", "execution.statusFailure": "Failure",