@haiiro/docuflow 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/.oxlintrc.json +37 -0
- package/.yarnrc.yml +1 -0
- package/README.md +127 -2
- package/package.json +60 -4
- package/public/cover.svg +15 -0
- package/public/logo.svg +9 -0
- package/src/application-config.ts +58 -0
- package/src/fields/company-documents.field.ts +29 -0
- package/src/fields/document-author.field.ts +32 -0
- package/src/fields/document-counterparty.field.ts +32 -0
- package/src/fields/document-folder-child-folders.field.ts +23 -0
- package/src/fields/document-folder-documents.field.ts +24 -0
- package/src/fields/document-folder-parent-folder.field.ts +31 -0
- package/src/fields/document-folder.field.ts +32 -0
- package/src/fields/document-responsible.field.ts +32 -0
- package/src/fields/member-authored-documents.field.ts +29 -0
- package/src/fields/member-responsible-documents.field.ts +29 -0
- package/src/fields/task-form-schema.field.ts +21 -0
- package/src/fields/task-form-values.field.ts +21 -0
- package/src/fields/task-instruction.field.ts +21 -0
- package/src/fields/task-section.field.ts +36 -0
- package/src/fields/task-workflow-run-id.field.ts +21 -0
- package/src/fields/task-workflow-step-id.field.ts +21 -0
- package/src/fields/task-workflow-thread-id.field.ts +21 -0
- package/src/fields/workflow-launch-fields.field.ts +21 -0
- package/src/fields/workflow-section.field.ts +36 -0
- package/src/front/api.ts +56 -0
- package/src/front/format.ts +60 -0
- package/src/front/hooks/use-async.ts +54 -0
- package/src/front/hooks/use-workspace-members.ts +22 -0
- package/src/front/types.ts +48 -0
- package/src/front/ui/components.tsx +436 -0
- package/src/front/ui/form-field-inputs.tsx +187 -0
- package/src/front/ui/form-fields-builder.tsx +183 -0
- package/src/front/ui/task-work-panel.tsx +218 -0
- package/src/front-components/docflow-documents.front-component.tsx +516 -0
- package/src/front-components/docflow-processes.front-component.tsx +266 -0
- package/src/front-components/docflow-tasks.front-component.tsx +303 -0
- package/src/front-components/process-steps.front-component.tsx +286 -0
- package/src/front-components/task-form.front-component.tsx +29 -0
- package/src/logic-functions/bitrix-app.logic-function.ts +143 -0
- package/src/logic-functions/bitrix-event.logic-function.ts +54 -0
- package/src/logic-functions/bitrix-install.logic-function.ts +81 -0
- package/src/logic-functions/capture-file.logic-function.ts +32 -0
- package/src/logic-functions/complete-task.logic-function.ts +46 -0
- package/src/logic-functions/hand-out-form-step.logic-function.ts +30 -0
- package/src/logic-functions/health-check.ts +31 -0
- package/src/logic-functions/launch-parameters-step.logic-function.ts +33 -0
- package/src/logic-functions/launch-process.logic-function.ts +96 -0
- package/src/logic-functions/notify-bitrix-assignee.logic-function.ts +85 -0
- package/src/logic-functions/on-attachment-created.logic-function.ts +67 -0
- package/src/logic-functions/on-task-updated.logic-function.ts +71 -0
- package/src/logic-functions/on-workflow-run-updated.logic-function.ts +70 -0
- package/src/logic-functions/save-launch-fields.logic-function.ts +73 -0
- package/src/logic-functions/save-step-settings.logic-function.ts +90 -0
- package/src/logic-functions/save-task-form.logic-function.ts +62 -0
- package/src/navigation-menu-items/docflow.navigation-menu-item.ts +15 -0
- package/src/objects/document-folder.object.ts +80 -0
- package/src/objects/document.object.ts +312 -0
- package/src/page-layouts/docflow.page-layout.ts +86 -0
- package/src/page-layouts/task-docflow.page-layout-tab.ts +36 -0
- package/src/page-layouts/workflow-steps.page-layout-tab.ts +37 -0
- package/src/roles/default-role.ts +21 -0
- package/src/roles/docflow-member.role.ts +23 -0
- package/src/server/bitrix-page.ts +52 -0
- package/src/server/bitrix-portal.ts +67 -0
- package/src/server/document-capture.ts +102 -0
- package/src/server/http-response.ts +18 -0
- package/src/server/launch-step.ts +166 -0
- package/src/server/process-tasks.ts +116 -0
- package/src/server/records.ts +132 -0
- package/src/server/task-completion.ts +100 -0
- package/src/server/workflow-engine.ts +105 -0
- package/src/shared/api/core-graphql.util.ts +98 -0
- package/src/shared/api/object-summaries.ts +79 -0
- package/src/shared/api/rest-filter.util.ts +37 -0
- package/src/shared/api/rest-response.util.ts +43 -0
- package/src/shared/bitrix/bitrix-client.ts +114 -0
- package/src/shared/bitrix/bitrix.type.ts +25 -0
- package/src/shared/bitrix/bitrix.util.ts +139 -0
- package/src/shared/docflow.constants.ts +31 -0
- package/src/shared/documents/attachment-target.util.ts +64 -0
- package/src/shared/engine.ts +11 -0
- package/src/shared/form/form-field-builder.util.ts +122 -0
- package/src/shared/form/form-field.type.ts +23 -0
- package/src/shared/form/form-fields.util.ts +138 -0
- package/src/shared/form/step-settings.util.ts +104 -0
- package/src/shared/workflow/launch-step.util.ts +56 -0
- package/src/shared/workflow/run-target-record.util.ts +70 -0
- package/src/shared/workflow/workflow-run.type.ts +35 -0
- package/src/shared/workflow/workflow-run.util.ts +174 -0
- package/src/universal-identifiers.ts +199 -0
- package/src/views/all-document-folders.view.ts +50 -0
- package/src/views/all-documents.view.ts +91 -0
- package/src/views/documents-by-status.view.ts +76 -0
- package/tsconfig.json +37 -0
- package/tsconfig.spec.json +9 -0
- package/vitest.unit.config.ts +14 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { CoreGraphqlError } from 'src/shared/api/core-graphql.util';
|
|
2
|
+
import { TASK_DONE_STATUS } from 'src/shared/docflow.constants';
|
|
3
|
+
import { isAppEngine } from 'src/shared/engine';
|
|
4
|
+
import { type FormValues } from 'src/shared/form/form-field.type';
|
|
5
|
+
import {
|
|
6
|
+
findMissingRequiredFields,
|
|
7
|
+
parseFormFields,
|
|
8
|
+
parseFormValues,
|
|
9
|
+
} from 'src/shared/form/form-fields.util';
|
|
10
|
+
import { getTask, getWorkflowRun, updateTask, type DocflowTaskRecord } from 'src/server/records';
|
|
11
|
+
import { answerFormStep } from 'src/server/workflow-engine';
|
|
12
|
+
|
|
13
|
+
export type CompleteTaskResult =
|
|
14
|
+
| { status: 'DONE' }
|
|
15
|
+
| { status: 'NOT_FOUND' }
|
|
16
|
+
| { status: 'MISSING_REQUIRED'; labels: string[] }
|
|
17
|
+
| { status: 'FAILED'; message: string };
|
|
18
|
+
|
|
19
|
+
export const isProcessTask = (task: DocflowTaskRecord): boolean =>
|
|
20
|
+
typeof task.smbWorkflowThreadId === 'string' &&
|
|
21
|
+
task.smbWorkflowThreadId.length > 0 &&
|
|
22
|
+
typeof task.smbWorkflowRunId === 'string' &&
|
|
23
|
+
typeof task.smbWorkflowStepId === 'string';
|
|
24
|
+
|
|
25
|
+
// Answers the process step of a task as the person completing it. A step
|
|
26
|
+
// that is no longer waiting (answered elsewhere, run stopped) is not an error.
|
|
27
|
+
export const answerStepOfTask = async (
|
|
28
|
+
task: DocflowTaskRecord,
|
|
29
|
+
values: FormValues,
|
|
30
|
+
): Promise<CompleteTaskResult | undefined> => {
|
|
31
|
+
if (!isProcessTask(task) || !isAppEngine()) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const run = await getWorkflowRun('person', task.smbWorkflowRunId as string);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await answerFormStep({
|
|
39
|
+
state: run?.state,
|
|
40
|
+
stepId: task.smbWorkflowStepId as string,
|
|
41
|
+
threadId: task.smbWorkflowThreadId as string,
|
|
42
|
+
values,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return result.status === 'MISSING_REQUIRED'
|
|
46
|
+
? { status: 'MISSING_REQUIRED', labels: result.labels }
|
|
47
|
+
: undefined;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const refreshedRun = await getWorkflowRun('person', task.smbWorkflowRunId as string);
|
|
50
|
+
|
|
51
|
+
if (refreshedRun?.state?.stepInfos?.[task.smbWorkflowStepId as string]?.status === 'SUCCESS') {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
status: 'FAILED',
|
|
57
|
+
message:
|
|
58
|
+
error instanceof CoreGraphqlError && error.userMessage !== undefined
|
|
59
|
+
? error.userMessage
|
|
60
|
+
: 'Процесс не принял ответ. Для ответа на шаги процессов нужно право «Рабочие процессы».',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const completeTask = async ({
|
|
66
|
+
taskId,
|
|
67
|
+
values,
|
|
68
|
+
}: {
|
|
69
|
+
taskId: string;
|
|
70
|
+
values: FormValues | undefined;
|
|
71
|
+
}): Promise<CompleteTaskResult> => {
|
|
72
|
+
const task = await getTask('person', taskId);
|
|
73
|
+
|
|
74
|
+
if (task === undefined) {
|
|
75
|
+
return { status: 'NOT_FOUND' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fields = parseFormFields(task.smbFormSchema);
|
|
79
|
+
const currentValues = values ?? parseFormValues(task.smbFormValues);
|
|
80
|
+
|
|
81
|
+
if (values !== undefined) {
|
|
82
|
+
await updateTask('person', taskId, { smbFormValues: values });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const missingFields = findMissingRequiredFields({ fields, values: currentValues });
|
|
86
|
+
|
|
87
|
+
if (missingFields.length > 0) {
|
|
88
|
+
return { status: 'MISSING_REQUIRED', labels: missingFields.map(({ label }) => label) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stepResult = await answerStepOfTask(task, currentValues);
|
|
92
|
+
|
|
93
|
+
if (stepResult !== undefined) {
|
|
94
|
+
return stepResult;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await updateTask('person', taskId, { status: TASK_DONE_STATUS });
|
|
98
|
+
|
|
99
|
+
return { status: 'DONE' };
|
|
100
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANSWER_TOOL_CALL_MUTATION,
|
|
3
|
+
callCoreGraphql,
|
|
4
|
+
RUN_WORKFLOW_VERSION_MUTATION,
|
|
5
|
+
} from 'src/shared/api/core-graphql.util';
|
|
6
|
+
import { type FormValues } from 'src/shared/form/form-field.type';
|
|
7
|
+
import {
|
|
8
|
+
buildFormStepResponse,
|
|
9
|
+
findMissingRequiredFields,
|
|
10
|
+
getFormFieldRole,
|
|
11
|
+
} from 'src/shared/form/form-fields.util';
|
|
12
|
+
import { type WorkflowRunState } from 'src/shared/workflow/workflow-run.type';
|
|
13
|
+
import {
|
|
14
|
+
getFormStepAssignment,
|
|
15
|
+
getFormStepFields,
|
|
16
|
+
isFormStep,
|
|
17
|
+
resolveFormStepFields,
|
|
18
|
+
} from 'src/shared/workflow/workflow-run.util';
|
|
19
|
+
|
|
20
|
+
// The two calls the app makes to the workflow engine. Both run as the person
|
|
21
|
+
// behind the current run: Twenty lets only a person with the "Workflows"
|
|
22
|
+
// permission start a version or answer a step of a running process.
|
|
23
|
+
export const runWorkflowVersion = async ({
|
|
24
|
+
workflowVersionId,
|
|
25
|
+
workflowRunId,
|
|
26
|
+
payload,
|
|
27
|
+
}: {
|
|
28
|
+
workflowVersionId: string;
|
|
29
|
+
workflowRunId: string;
|
|
30
|
+
payload: Record<string, unknown>;
|
|
31
|
+
}): Promise<string> => {
|
|
32
|
+
const data = await callCoreGraphql<{
|
|
33
|
+
runWorkflowVersion: { workflowRunId: string };
|
|
34
|
+
}>({
|
|
35
|
+
query: RUN_WORKFLOW_VERSION_MUTATION,
|
|
36
|
+
variables: { input: { workflowVersionId, workflowRunId, payload } },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return data.runWorkflowVersion.workflowRunId;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type AnswerFormStepResult =
|
|
43
|
+
| { status: 'ANSWERED' }
|
|
44
|
+
| { status: 'NOT_WAITING' }
|
|
45
|
+
| { status: 'MISSING_REQUIRED'; labels: string[] };
|
|
46
|
+
|
|
47
|
+
export const answerFormStep = async ({
|
|
48
|
+
state,
|
|
49
|
+
stepId,
|
|
50
|
+
threadId,
|
|
51
|
+
values,
|
|
52
|
+
}: {
|
|
53
|
+
state: WorkflowRunState | null | undefined;
|
|
54
|
+
stepId: string;
|
|
55
|
+
threadId: string;
|
|
56
|
+
values: FormValues;
|
|
57
|
+
}): Promise<AnswerFormStepResult> => {
|
|
58
|
+
const step = state?.flow?.steps?.find((candidate) => candidate.id === stepId);
|
|
59
|
+
const stepInfo = state?.stepInfos?.[stepId];
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
step === undefined ||
|
|
63
|
+
!isFormStep(step) ||
|
|
64
|
+
stepInfo?.status !== 'PENDING' ||
|
|
65
|
+
stepInfo.threadId !== threadId
|
|
66
|
+
) {
|
|
67
|
+
return { status: 'NOT_WAITING' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fields = resolveFormStepFields(
|
|
71
|
+
getFormStepFields(step),
|
|
72
|
+
state?.stepInfos,
|
|
73
|
+
);
|
|
74
|
+
const missingFields = findMissingRequiredFields({ fields, values });
|
|
75
|
+
|
|
76
|
+
if (missingFields.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
status: 'MISSING_REQUIRED',
|
|
79
|
+
labels: missingFields.map(({ label }) => label),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The assignee goes out as { id }: the engine then puts the whole member
|
|
84
|
+
// into the step output, as for a record picked in the form.
|
|
85
|
+
const { assigneeWorkspaceMemberId } = getFormStepAssignment({
|
|
86
|
+
fields,
|
|
87
|
+
now: new Date(),
|
|
88
|
+
});
|
|
89
|
+
const response = buildFormStepResponse({
|
|
90
|
+
fields: fields.map((field) =>
|
|
91
|
+
getFormFieldRole(field) === 'ASSIGNEE' &&
|
|
92
|
+
assigneeWorkspaceMemberId !== undefined
|
|
93
|
+
? { ...field, value: { id: assigneeWorkspaceMemberId } }
|
|
94
|
+
: field,
|
|
95
|
+
),
|
|
96
|
+
values,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await callCoreGraphql({
|
|
100
|
+
query: ANSWER_TOOL_CALL_MUTATION,
|
|
101
|
+
variables: { input: { threadId, toolCallId: stepId, response } },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { status: 'ANSWERED' };
|
|
105
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Mutations of the workflow engine (run a version, answer a form step) live
|
|
2
|
+
// in the core GraphQL schema, outside the generated record client, so they
|
|
3
|
+
// are sent as plain requests with the token Twenty gives the app.
|
|
4
|
+
type CoreGraphqlResponse<TData> = {
|
|
5
|
+
data?: TData;
|
|
6
|
+
errors?: { message?: string; extensions?: { userFriendlyMessage?: unknown } }[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class CoreGraphqlError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
message: string,
|
|
12
|
+
readonly userMessage: string | undefined,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'CoreGraphqlError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const readUserMessage = (value: unknown): string | undefined => {
|
|
20
|
+
if (typeof value === 'string') {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value === 'object' && value !== null && 'message' in value) {
|
|
25
|
+
const message = (value as { message: unknown }).message;
|
|
26
|
+
|
|
27
|
+
return typeof message === 'string' ? message : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const callCoreGraphql = async <TData>({
|
|
34
|
+
query,
|
|
35
|
+
variables,
|
|
36
|
+
}: {
|
|
37
|
+
query: string;
|
|
38
|
+
variables: Record<string, unknown>;
|
|
39
|
+
}): Promise<TData> => {
|
|
40
|
+
const apiUrl = process.env.TWENTY_API_URL;
|
|
41
|
+
const token = process.env.TWENTY_APP_ACCESS_TOKEN;
|
|
42
|
+
|
|
43
|
+
if (apiUrl === undefined || token === undefined) {
|
|
44
|
+
throw new CoreGraphqlError(
|
|
45
|
+
'TWENTY_API_URL or TWENTY_APP_ACCESS_TOKEN is not set',
|
|
46
|
+
undefined,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await fetch(`${apiUrl.replace(/\/$/, '')}/graphql`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
authorization: `Bearer ${token}`,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ query, variables }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const body = (await response.json()) as CoreGraphqlResponse<TData>;
|
|
60
|
+
const [firstError] = body.errors ?? [];
|
|
61
|
+
|
|
62
|
+
if (firstError !== undefined || body.data === undefined) {
|
|
63
|
+
throw new CoreGraphqlError(
|
|
64
|
+
firstError?.message ?? `GraphQL request failed with ${response.status}`,
|
|
65
|
+
readUserMessage(firstError?.extensions?.userFriendlyMessage),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return body.data;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const RUN_WORKFLOW_VERSION_MUTATION = `mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) {
|
|
73
|
+
runWorkflowVersion(input: $input) { workflowRunId }
|
|
74
|
+
}`;
|
|
75
|
+
|
|
76
|
+
export const ANSWER_TOOL_CALL_MUTATION = `mutation AnswerToolCall($input: AnswerToolCallInput!) {
|
|
77
|
+
answerToolCall(input: $input) { streamId }
|
|
78
|
+
}`;
|
|
79
|
+
|
|
80
|
+
export const UPDATE_WORKFLOW_VERSION_STEP_MUTATION = `mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
|
|
81
|
+
updateWorkflowVersionStep(input: $input) { id }
|
|
82
|
+
}`;
|
|
83
|
+
|
|
84
|
+
export const CREATE_WORKFLOW_VERSION_STEP_MUTATION = `mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
|
85
|
+
createWorkflowVersionStep(input: $input) { __typename }
|
|
86
|
+
}`;
|
|
87
|
+
|
|
88
|
+
export const DELETE_WORKFLOW_VERSION_STEP_MUTATION = `mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
|
|
89
|
+
deleteWorkflowVersionStep(input: $input) { __typename }
|
|
90
|
+
}`;
|
|
91
|
+
|
|
92
|
+
export const UPDATE_WORKFLOW_VERSION_TRIGGER_MUTATION = `mutation UpdateWorkflowVersionTrigger($input: UpdateWorkflowVersionTriggerInput!) {
|
|
93
|
+
updateWorkflowVersionTrigger(input: $input) { __typename }
|
|
94
|
+
}`;
|
|
95
|
+
|
|
96
|
+
export const CREATE_DRAFT_FROM_WORKFLOW_VERSION_MUTATION = `mutation CreateDraftFromWorkflowVersion($input: CreateDraftFromWorkflowVersionInput!) {
|
|
97
|
+
createDraftFromWorkflowVersion(input: $input) { id }
|
|
98
|
+
}`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
|
2
|
+
|
|
3
|
+
export type ObjectSummary = {
|
|
4
|
+
nameSingular: string;
|
|
5
|
+
namePlural: string;
|
|
6
|
+
labelSingular: string;
|
|
7
|
+
labelFieldName?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const OBJECTS_PAGE_SIZE = 100;
|
|
11
|
+
|
|
12
|
+
// Names and label fields of the workspace objects, read once per run. The
|
|
13
|
+
// metadata API cannot filter by name, so it is paged through.
|
|
14
|
+
export const getObjectSummaries = async (): Promise<Map<string, ObjectSummary>> => {
|
|
15
|
+
const client = new MetadataApiClient();
|
|
16
|
+
const summaries = new Map<string, ObjectSummary>();
|
|
17
|
+
let after: string | undefined;
|
|
18
|
+
|
|
19
|
+
do {
|
|
20
|
+
const { objects } = await client.query({
|
|
21
|
+
objects: {
|
|
22
|
+
__args: {
|
|
23
|
+
paging: { first: OBJECTS_PAGE_SIZE, ...(after !== undefined ? { after } : {}) },
|
|
24
|
+
filter: {},
|
|
25
|
+
},
|
|
26
|
+
pageInfo: { hasNextPage: true, endCursor: true },
|
|
27
|
+
edges: {
|
|
28
|
+
node: {
|
|
29
|
+
nameSingular: true,
|
|
30
|
+
namePlural: true,
|
|
31
|
+
labelSingular: true,
|
|
32
|
+
labelIdentifierFieldMetadataId: true,
|
|
33
|
+
fieldsList: { id: true, name: true },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const { node } of objects.edges) {
|
|
40
|
+
summaries.set(node.nameSingular, {
|
|
41
|
+
nameSingular: node.nameSingular,
|
|
42
|
+
namePlural: node.namePlural,
|
|
43
|
+
labelSingular: node.labelSingular,
|
|
44
|
+
labelFieldName: node.fieldsList?.find(
|
|
45
|
+
(field) => field.id === node.labelIdentifierFieldMetadataId,
|
|
46
|
+
)?.name,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
after =
|
|
51
|
+
objects.pageInfo.hasNextPage === true
|
|
52
|
+
? (objects.pageInfo.endCursor ?? undefined)
|
|
53
|
+
: undefined;
|
|
54
|
+
} while (after !== undefined);
|
|
55
|
+
|
|
56
|
+
return summaries;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type SelectOption = { value: string; label: string };
|
|
60
|
+
|
|
61
|
+
// A SELECT field of a form takes its options from a field of an object
|
|
62
|
+
// (settings.selectedFieldId), like the standard form step does.
|
|
63
|
+
export const getFieldOptions = async (fieldId: string): Promise<SelectOption[]> => {
|
|
64
|
+
const { fields } = await new MetadataApiClient().query({
|
|
65
|
+
fields: {
|
|
66
|
+
__args: { filter: { id: { eq: fieldId } }, paging: { first: 1 } },
|
|
67
|
+
edges: { node: { options: true } },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
const options = fields.edges[0]?.node.options;
|
|
71
|
+
|
|
72
|
+
return Array.isArray(options)
|
|
73
|
+
? options.flatMap((option: unknown) => {
|
|
74
|
+
const { value, label } = (option ?? {}) as Record<string, unknown>;
|
|
75
|
+
|
|
76
|
+
return typeof value === 'string' && typeof label === 'string' ? [{ value, label }] : [];
|
|
77
|
+
})
|
|
78
|
+
: [];
|
|
79
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Filters of the Twenty REST API: `and(field[eq]:'value',or(...))`. The
|
|
2
|
+
// parser counts brackets and quotes without escaping, so text a person types
|
|
3
|
+
// loses those characters before it becomes part of a filter.
|
|
4
|
+
const UNSAFE_FILTER_CHARACTERS = /[()[\]'",]/g;
|
|
5
|
+
|
|
6
|
+
export const sanitizeFilterText = (text: string): string =>
|
|
7
|
+
text.replace(UNSAFE_FILTER_CHARACTERS, ' ').trim();
|
|
8
|
+
|
|
9
|
+
const quote = (value: string): string => `'${sanitizeFilterText(value)}'`;
|
|
10
|
+
|
|
11
|
+
export const restEq = (field: string, value: string): string =>
|
|
12
|
+
`${field}[eq]:${quote(value)}`;
|
|
13
|
+
|
|
14
|
+
export const restNeq = (field: string, value: string): string =>
|
|
15
|
+
`${field}[neq]:${quote(value)}`;
|
|
16
|
+
|
|
17
|
+
export const restIsNull = (field: string): string => `${field}[is]:NULL`;
|
|
18
|
+
|
|
19
|
+
export const restLt = (field: string, value: string): string =>
|
|
20
|
+
`${field}[lt]:${quote(value)}`;
|
|
21
|
+
|
|
22
|
+
export const restIn = (field: string, values: string[]): string =>
|
|
23
|
+
`${field}[in]:[${values.map((value) => `"${sanitizeFilterText(value)}"`).join(',')}]`;
|
|
24
|
+
|
|
25
|
+
export const restIlike = (field: string, text: string): string =>
|
|
26
|
+
`${field}[ilike]:'%${sanitizeFilterText(text)}%'`;
|
|
27
|
+
|
|
28
|
+
export const restAnd = (...conditions: (string | undefined)[]): string | undefined => {
|
|
29
|
+
const present = conditions.filter(
|
|
30
|
+
(condition): condition is string => condition !== undefined,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return present.length === 0 ? undefined : `and(${present.join(',')})`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const restOr = (...conditions: string[]): string =>
|
|
37
|
+
`or(${conditions.join(',')})`;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type RestPageInfo = {
|
|
2
|
+
hasNextPage?: boolean;
|
|
3
|
+
endCursor?: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type RestList<TRecord> = {
|
|
7
|
+
records: TRecord[];
|
|
8
|
+
pageInfo: RestPageInfo;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type RestEnvelope = {
|
|
12
|
+
data?: Record<string, unknown>;
|
|
13
|
+
pageInfo?: RestPageInfo;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Every REST answer carries its records under one key of `data`
|
|
17
|
+
// (`tasks`, `createTask`, `updateTask`...): the shape is read once here.
|
|
18
|
+
const readDataValue = (response: unknown): unknown => {
|
|
19
|
+
const data = (response as RestEnvelope | null)?.data;
|
|
20
|
+
|
|
21
|
+
if (typeof data !== 'object' || data === null) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Object.values(data)[0];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const readRestList = <TRecord>(response: unknown): RestList<TRecord> => {
|
|
29
|
+
const value = readDataValue(response);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
records: Array.isArray(value) ? (value as TRecord[]) : [],
|
|
33
|
+
pageInfo: (response as RestEnvelope | null)?.pageInfo ?? {},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const readRestRecord = <TRecord>(response: unknown): TRecord | undefined => {
|
|
38
|
+
const value = readDataValue(response);
|
|
39
|
+
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
? (value as TRecord)
|
|
42
|
+
: undefined;
|
|
43
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type BitrixGrant, type BitrixPortal } from 'src/shared/bitrix/bitrix.type';
|
|
2
|
+
import { BITRIX_OAUTH_TOKEN_URL, parseBitrixGrant } from 'src/shared/bitrix/bitrix.util';
|
|
3
|
+
|
|
4
|
+
const TOKEN_REFRESH_MARGIN_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export class BitrixError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'BitrixError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const getClientCredentials = (): { clientId: string; clientSecret: string } => {
|
|
14
|
+
const clientId = process.env.BITRIX_CLIENT_ID;
|
|
15
|
+
const clientSecret = process.env.BITRIX_CLIENT_SECRET;
|
|
16
|
+
|
|
17
|
+
if (!clientId || !clientSecret) {
|
|
18
|
+
throw new BitrixError('BITRIX_CLIENT_ID and BITRIX_CLIENT_SECRET are not set');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { clientId, clientSecret };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const isBitrixConfigured = (): boolean =>
|
|
25
|
+
Boolean(process.env.BITRIX_CLIENT_ID) && Boolean(process.env.BITRIX_CLIENT_SECRET);
|
|
26
|
+
|
|
27
|
+
// The one proof a portal request has to pass: its refresh token is exchanged
|
|
28
|
+
// with the app's client secret, and the portal id, REST address and tokens
|
|
29
|
+
// are taken from the answer of oauth.bitrix.info, not from the request.
|
|
30
|
+
export const exchangeBitrixRefreshToken = async (
|
|
31
|
+
refreshToken: string,
|
|
32
|
+
): Promise<BitrixGrant> => {
|
|
33
|
+
const { clientId, clientSecret } = getClientCredentials();
|
|
34
|
+
const url = new URL(BITRIX_OAUTH_TOKEN_URL);
|
|
35
|
+
|
|
36
|
+
url.searchParams.set('grant_type', 'refresh_token');
|
|
37
|
+
url.searchParams.set('client_id', clientId);
|
|
38
|
+
url.searchParams.set('client_secret', clientSecret);
|
|
39
|
+
url.searchParams.set('refresh_token', refreshToken);
|
|
40
|
+
|
|
41
|
+
let response: Response;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(url, { redirect: 'error' });
|
|
45
|
+
} catch {
|
|
46
|
+
// The URL carries the client secret, so the original error is not kept.
|
|
47
|
+
throw new BitrixError('oauth.bitrix.info is unreachable');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const grant = parseBitrixGrant(await response.json().catch(() => null), Date.now());
|
|
51
|
+
|
|
52
|
+
if (!response.ok || grant === undefined) {
|
|
53
|
+
throw new BitrixError(`Bitrix24 did not accept the refresh token (HTTP ${response.status})`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return grant;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const withFreshBitrixToken = async (
|
|
60
|
+
portal: BitrixPortal,
|
|
61
|
+
): Promise<{ portal: BitrixPortal; isRefreshed: boolean }> => {
|
|
62
|
+
if (portal.expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now()) {
|
|
63
|
+
return { portal, isRefreshed: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const grant = await exchangeBitrixRefreshToken(portal.refreshToken);
|
|
67
|
+
|
|
68
|
+
if (grant.memberId !== portal.memberId) {
|
|
69
|
+
throw new BitrixError('The refreshed token belongs to another portal');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
portal: {
|
|
74
|
+
...portal,
|
|
75
|
+
accessToken: grant.accessToken,
|
|
76
|
+
refreshToken: grant.refreshToken,
|
|
77
|
+
expiresAt: grant.expiresAt,
|
|
78
|
+
restEndpoint: grant.restEndpoint,
|
|
79
|
+
},
|
|
80
|
+
isRefreshed: true,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const callBitrix = async <TResult>({
|
|
85
|
+
restEndpoint,
|
|
86
|
+
accessToken,
|
|
87
|
+
method,
|
|
88
|
+
params,
|
|
89
|
+
}: {
|
|
90
|
+
restEndpoint: string;
|
|
91
|
+
accessToken: string;
|
|
92
|
+
method: string;
|
|
93
|
+
params: Record<string, unknown>;
|
|
94
|
+
}): Promise<TResult> => {
|
|
95
|
+
const response = await fetch(`${restEndpoint}${method}.json`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify({ ...params, auth: accessToken }),
|
|
99
|
+
redirect: 'error',
|
|
100
|
+
});
|
|
101
|
+
const body = (await response.json().catch(() => null)) as {
|
|
102
|
+
result?: TResult;
|
|
103
|
+
error?: string;
|
|
104
|
+
error_description?: string;
|
|
105
|
+
} | null;
|
|
106
|
+
|
|
107
|
+
if (!response.ok || body === null || body.error !== undefined) {
|
|
108
|
+
throw new BitrixError(
|
|
109
|
+
`Bitrix24 ${method} failed: ${body?.error ?? response.status} ${body?.error_description ?? ''}`.trim(),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return body.result as TResult;
|
|
114
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A Bitrix24 portal linked to this workspace. Kept in the app's key-value
|
|
2
|
+
// store, never in records: it holds tokens.
|
|
3
|
+
export type BitrixPortal = {
|
|
4
|
+
memberId: string;
|
|
5
|
+
portalDomain: string;
|
|
6
|
+
restEndpoint: string;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
refreshToken: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
// Sent by the portal with every event; proves an event comes from it.
|
|
11
|
+
applicationToken?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BitrixGrant = Omit<BitrixPortal, 'applicationToken'> & {
|
|
15
|
+
bitrixUserId?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type BitrixRequest = {
|
|
19
|
+
authId?: string;
|
|
20
|
+
refreshId?: string;
|
|
21
|
+
memberId?: string;
|
|
22
|
+
event?: string;
|
|
23
|
+
applicationToken?: string;
|
|
24
|
+
domain?: string;
|
|
25
|
+
};
|