Skip to content

Commit e57e27b

Browse files
committed
feat: calculate new step position for reject branch
1 parent 278ec48 commit e57e27b

File tree

3 files changed

+157
-44
lines changed

3 files changed

+157
-44
lines changed

packages/backend/src/apps/formsg/common/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ export interface ParsedMrfWorkflow {
5050
trigger: Omit<ParsedMrfWorkflowStep, 'approvalField'>
5151
actions: ParsedMrfWorkflowStep[]
5252
}
53+
54+
export const stepApprovalConfigSchema = z.object({
55+
branch: z.enum(['approve', 'reject']),
56+
stepId: z.string().uuid(),
57+
})

packages/backend/src/graphql/mutations/create-step.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,26 +31,26 @@ const createStep: MutationResolvers['createStep'] = async (
3131
throw new BadUserInputError('Action can only be created by system')
3232
}
3333
}
34-
return await Step.transaction(async (trx) => {
35-
await trx.raw('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;')
36-
37-
if (input.connection?.id) {
38-
// if connectionId is specified, verify that the connection exists and belongs to the user
39-
const connection = await context.currentUser
40-
.$relatedQuery('connections')
41-
.findOne({ id: input.connection.id })
42-
if (!connection) {
43-
throw new BadUserInputError('Connection not found')
44-
}
34+
if (input.connection?.id) {
35+
// if connectionId is specified, verify that the connection exists and belongs to the user
36+
const connection = await context.currentUser
37+
.$relatedQuery('connections')
38+
.findOne({ id: input.connection.id })
39+
if (!connection) {
40+
throw new BadUserInputError('Connection not found')
4541
}
42+
}
4643

47-
// Put SELECTs in transaction just in case there's concurrent modification.
48-
const flow = await context.currentUser
49-
.$relatedQuery('flows', trx)
50-
.findOne({
51-
id: input.flow.id,
52-
})
53-
.throwIfNotFound()
44+
// Put SELECTs in transaction just in case there's concurrent modification.
45+
const flow = await context.currentUser
46+
.$relatedQuery('flows')
47+
.findOne({
48+
id: input.flow.id,
49+
})
50+
.throwIfNotFound()
51+
52+
return await Step.transaction(async (trx) => {
53+
await trx.raw('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;')
5454

5555
const previousStep = await flow
5656
.$relatedQuery('steps', trx)
@@ -59,11 +59,11 @@ const createStep: MutationResolvers['createStep'] = async (
5959
})
6060
.throwIfNotFound()
6161

62-
const isApprovalConfigValid = validateApprovalConfig(
62+
const validationResult = await validateApprovalConfig(
6363
input.config,
6464
previousStep,
6565
)
66-
if (!isApprovalConfigValid) {
66+
if (!validationResult.isApprovalConfigValid) {
6767
throw new BadUserInputError('Invalid approval config')
6868
}
6969

@@ -72,13 +72,13 @@ const createStep: MutationResolvers['createStep'] = async (
7272
.patch({
7373
position: raw(`position + 1`),
7474
})
75-
.where('position', '>=', previousStep.position + 1)
75+
.where('position', '>=', validationResult.newStepPosition)
7676

7777
const step = await flow.$relatedQuery('steps', trx).insertAndFetch({
7878
key: input.key,
7979
appKey: input.appKey,
8080
type: 'action',
81-
position: previousStep.position + 1,
81+
position: validationResult.newStepPosition,
8282
parameters: input.parameters,
8383
connectionId: input.connection?.id,
8484
config: input.config,

packages/backend/src/helpers/validate-approval-config.ts

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,150 @@ import { IStep, IStepConfig } from '@plumber/types'
22

33
import get from 'lodash.get'
44

5-
export function validateApprovalConfig(config: IStepConfig, prevStep: IStep) {
5+
import { stepApprovalConfigSchema } from '@/apps/formsg/common/types'
6+
import Step from '@/models/step'
7+
8+
import logger from './logger'
9+
10+
/**
11+
* TODO: write unit test for this function
12+
*/
13+
export async function validateApprovalConfig(
14+
config: IStepConfig,
15+
prevStep: IStep,
16+
): Promise<
17+
| {
18+
isApprovalConfigValid: true
19+
newStepPosition: number
20+
}
21+
| {
22+
isApprovalConfigValid: false
23+
}
24+
> {
625
/**
7-
* if approval config is not present, or is a trigger, return true
26+
* Case 1: new step is a trigger step, return early
827
*/
9-
if (!config?.approval || !prevStep) {
10-
return true
28+
if (!prevStep) {
29+
return {
30+
isApprovalConfigValid: true,
31+
newStepPosition: 1,
32+
}
1133
}
1234

35+
const isPreviousStepMrfApprovalStep =
36+
prevStep.appKey === 'formsg' &&
37+
prevStep.key === 'mrfSubmission' &&
38+
// if no approval field, it's just a normal mrf step
39+
!!get(prevStep.parameters, 'mrf.approvalField')
40+
1341
/**
14-
* Both approval branch and approval step id must come together
42+
* Case 2: Prev step has no approval config and is not an mrf approval step,
43+
* current step should have no approval config either
1544
*/
16-
if (!(config?.approval?.branch && config?.approval?.stepId)) {
17-
return false
45+
if (!prevStep.config.approval && !isPreviousStepMrfApprovalStep) {
46+
if (!config?.approval) {
47+
return {
48+
isApprovalConfigValid: true,
49+
newStepPosition: prevStep.position + 1,
50+
}
51+
}
52+
logger.error(
53+
'Invalid approval config: previous step has no approval config and is not an mrf approval step',
54+
)
55+
return {
56+
isApprovalConfigValid: false,
57+
}
1858
}
1959

2060
/**
21-
* If previous step an approval step return true
61+
* Case 3: Prev step is part of an approval branch, check that
62+
* the new step has the same approval config
2263
*/
23-
if (
24-
prevStep.id === config.approval?.stepId &&
25-
prevStep.appKey === 'formsg' &&
26-
prevStep.key === 'mrfSubmission' &&
27-
!!get(prevStep.parameters, 'mrf.approvalField')
28-
) {
29-
return true
64+
if (prevStep.config.approval) {
65+
const isSameApprovalConfig =
66+
prevStep.config.approval?.branch === config?.approval?.branch &&
67+
prevStep.config.approval?.stepId === config?.approval?.stepId
68+
if (isSameApprovalConfig) {
69+
return {
70+
isApprovalConfigValid: true,
71+
newStepPosition: prevStep.position + 1,
72+
}
73+
}
74+
logger.error(
75+
'Invalid approval config: new step approval config is not the same as previous step',
76+
)
77+
return {
78+
isApprovalConfigValid: false,
79+
}
3080
}
3181

3282
/**
33-
* If previous step has the same approval branch and step id, return true
83+
* Case 4: Previous step is an approval step
3484
*/
35-
if (
36-
prevStep.config.approval?.branch === config.approval?.branch &&
37-
prevStep.config.approval?.stepId === config.approval?.stepId
38-
) {
39-
return true
85+
if (isPreviousStepMrfApprovalStep) {
86+
// validate approval config
87+
const { success } = stepApprovalConfigSchema.safeParse(config?.approval)
88+
// validate approval config step id is the same as prev step id
89+
if (!success || config.approval.stepId !== prevStep.id) {
90+
logger.error(
91+
'Invalid approval config (after approval step): invalid approval config or new step id is not the same as approval step id',
92+
{
93+
approvalConfig: config?.approval,
94+
},
95+
)
96+
return {
97+
isApprovalConfigValid: false,
98+
}
99+
}
100+
/**
101+
* If approval branch, simply add 1 to the prev step position
102+
*/
103+
if (config.approval.branch === 'approve') {
104+
return {
105+
isApprovalConfigValid: true,
106+
newStepPosition: prevStep.position + 1,
107+
}
108+
}
109+
// If reject branch, find the end of the approval branch and add one to that step
110+
// first find the next mrf step (if any)
111+
const nextMrfStep = await Step.query()
112+
.where('flow_id', prevStep.flowId)
113+
.andWhere('type', 'action')
114+
.andWhere('key', 'mrfSubmission')
115+
.andWhere('position', '>', prevStep.position)
116+
.orderBy('position', 'asc')
117+
.first()
118+
// then find the last approval step in the curr branch (if any)
119+
const lastApprovalStepQuery = Step.query()
120+
.where('flow_id', prevStep.flowId)
121+
.andWhere('position', '>', prevStep.position)
122+
.andWhereRaw(`config->'approval'->>'branch' = ?`, ['approve'])
123+
.orderBy('position', 'desc')
124+
.first()
125+
126+
if (nextMrfStep) {
127+
lastApprovalStepQuery.andWhere('position', '<', nextMrfStep.position)
128+
}
129+
const lastApprovalStep = await lastApprovalStepQuery.first()
130+
// If last approval step exists, return the position after that
131+
if (lastApprovalStep) {
132+
return {
133+
isApprovalConfigValid: true,
134+
newStepPosition: lastApprovalStep.position + 1,
135+
}
136+
} else {
137+
// If no last approval step, return the position after the previous step
138+
return {
139+
isApprovalConfigValid: true,
140+
newStepPosition: prevStep.position + 1,
141+
}
142+
}
40143
}
41144

42-
return false
145+
/**
146+
* Catch all branch, something seems fishy
147+
*/
148+
return {
149+
isApprovalConfigValid: false,
150+
}
43151
}

0 commit comments

Comments
 (0)