@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.
Files changed (99) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxlintrc.json +37 -0
  3. package/.yarnrc.yml +1 -0
  4. package/README.md +127 -2
  5. package/package.json +60 -4
  6. package/public/cover.svg +15 -0
  7. package/public/logo.svg +9 -0
  8. package/src/application-config.ts +58 -0
  9. package/src/fields/company-documents.field.ts +29 -0
  10. package/src/fields/document-author.field.ts +32 -0
  11. package/src/fields/document-counterparty.field.ts +32 -0
  12. package/src/fields/document-folder-child-folders.field.ts +23 -0
  13. package/src/fields/document-folder-documents.field.ts +24 -0
  14. package/src/fields/document-folder-parent-folder.field.ts +31 -0
  15. package/src/fields/document-folder.field.ts +32 -0
  16. package/src/fields/document-responsible.field.ts +32 -0
  17. package/src/fields/member-authored-documents.field.ts +29 -0
  18. package/src/fields/member-responsible-documents.field.ts +29 -0
  19. package/src/fields/task-form-schema.field.ts +21 -0
  20. package/src/fields/task-form-values.field.ts +21 -0
  21. package/src/fields/task-instruction.field.ts +21 -0
  22. package/src/fields/task-section.field.ts +36 -0
  23. package/src/fields/task-workflow-run-id.field.ts +21 -0
  24. package/src/fields/task-workflow-step-id.field.ts +21 -0
  25. package/src/fields/task-workflow-thread-id.field.ts +21 -0
  26. package/src/fields/workflow-launch-fields.field.ts +21 -0
  27. package/src/fields/workflow-section.field.ts +36 -0
  28. package/src/front/api.ts +56 -0
  29. package/src/front/format.ts +60 -0
  30. package/src/front/hooks/use-async.ts +54 -0
  31. package/src/front/hooks/use-workspace-members.ts +22 -0
  32. package/src/front/types.ts +48 -0
  33. package/src/front/ui/components.tsx +436 -0
  34. package/src/front/ui/form-field-inputs.tsx +187 -0
  35. package/src/front/ui/form-fields-builder.tsx +183 -0
  36. package/src/front/ui/task-work-panel.tsx +218 -0
  37. package/src/front-components/docflow-documents.front-component.tsx +516 -0
  38. package/src/front-components/docflow-processes.front-component.tsx +266 -0
  39. package/src/front-components/docflow-tasks.front-component.tsx +303 -0
  40. package/src/front-components/process-steps.front-component.tsx +286 -0
  41. package/src/front-components/task-form.front-component.tsx +29 -0
  42. package/src/logic-functions/bitrix-app.logic-function.ts +143 -0
  43. package/src/logic-functions/bitrix-event.logic-function.ts +54 -0
  44. package/src/logic-functions/bitrix-install.logic-function.ts +81 -0
  45. package/src/logic-functions/capture-file.logic-function.ts +32 -0
  46. package/src/logic-functions/complete-task.logic-function.ts +46 -0
  47. package/src/logic-functions/hand-out-form-step.logic-function.ts +30 -0
  48. package/src/logic-functions/health-check.ts +31 -0
  49. package/src/logic-functions/launch-parameters-step.logic-function.ts +33 -0
  50. package/src/logic-functions/launch-process.logic-function.ts +96 -0
  51. package/src/logic-functions/notify-bitrix-assignee.logic-function.ts +85 -0
  52. package/src/logic-functions/on-attachment-created.logic-function.ts +67 -0
  53. package/src/logic-functions/on-task-updated.logic-function.ts +71 -0
  54. package/src/logic-functions/on-workflow-run-updated.logic-function.ts +70 -0
  55. package/src/logic-functions/save-launch-fields.logic-function.ts +73 -0
  56. package/src/logic-functions/save-step-settings.logic-function.ts +90 -0
  57. package/src/logic-functions/save-task-form.logic-function.ts +62 -0
  58. package/src/navigation-menu-items/docflow.navigation-menu-item.ts +15 -0
  59. package/src/objects/document-folder.object.ts +80 -0
  60. package/src/objects/document.object.ts +312 -0
  61. package/src/page-layouts/docflow.page-layout.ts +86 -0
  62. package/src/page-layouts/task-docflow.page-layout-tab.ts +36 -0
  63. package/src/page-layouts/workflow-steps.page-layout-tab.ts +37 -0
  64. package/src/roles/default-role.ts +21 -0
  65. package/src/roles/docflow-member.role.ts +23 -0
  66. package/src/server/bitrix-page.ts +52 -0
  67. package/src/server/bitrix-portal.ts +67 -0
  68. package/src/server/document-capture.ts +102 -0
  69. package/src/server/http-response.ts +18 -0
  70. package/src/server/launch-step.ts +166 -0
  71. package/src/server/process-tasks.ts +116 -0
  72. package/src/server/records.ts +132 -0
  73. package/src/server/task-completion.ts +100 -0
  74. package/src/server/workflow-engine.ts +105 -0
  75. package/src/shared/api/core-graphql.util.ts +98 -0
  76. package/src/shared/api/object-summaries.ts +79 -0
  77. package/src/shared/api/rest-filter.util.ts +37 -0
  78. package/src/shared/api/rest-response.util.ts +43 -0
  79. package/src/shared/bitrix/bitrix-client.ts +114 -0
  80. package/src/shared/bitrix/bitrix.type.ts +25 -0
  81. package/src/shared/bitrix/bitrix.util.ts +139 -0
  82. package/src/shared/docflow.constants.ts +31 -0
  83. package/src/shared/documents/attachment-target.util.ts +64 -0
  84. package/src/shared/engine.ts +11 -0
  85. package/src/shared/form/form-field-builder.util.ts +122 -0
  86. package/src/shared/form/form-field.type.ts +23 -0
  87. package/src/shared/form/form-fields.util.ts +138 -0
  88. package/src/shared/form/step-settings.util.ts +104 -0
  89. package/src/shared/workflow/launch-step.util.ts +56 -0
  90. package/src/shared/workflow/run-target-record.util.ts +70 -0
  91. package/src/shared/workflow/workflow-run.type.ts +35 -0
  92. package/src/shared/workflow/workflow-run.util.ts +174 -0
  93. package/src/universal-identifiers.ts +199 -0
  94. package/src/views/all-document-folders.view.ts +50 -0
  95. package/src/views/all-documents.view.ts +91 -0
  96. package/src/views/documents-by-status.view.ts +76 -0
  97. package/tsconfig.json +37 -0
  98. package/tsconfig.spec.json +9 -0
  99. package/vitest.unit.config.ts +14 -0
@@ -0,0 +1,52 @@
1
+ import { escapeHtml } from 'src/shared/bitrix/bitrix.util';
2
+
3
+ const BX24_SDK_URL = 'https://api.bitrix24.com/api/v1/';
4
+
5
+ export type BitrixPageTask = { title: string; url?: string; dueAt?: string };
6
+
7
+ // The page shown inside the portal frame. It opens the CRM in a new browser
8
+ // tab: a CRM session in a third-party frame depends on cookie rules the app
9
+ // cannot change on a plain Twenty.
10
+ export const renderBitrixPage = ({
11
+ title,
12
+ message,
13
+ crmUrl,
14
+ tasks,
15
+ isInstall,
16
+ }: {
17
+ title: string;
18
+ message: string;
19
+ crmUrl?: string;
20
+ tasks: BitrixPageTask[];
21
+ isInstall: boolean;
22
+ }): string => `<!doctype html>
23
+ <html lang="ru">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <meta name="viewport" content="width=device-width, initial-scale=1">
27
+ <title>${escapeHtml(title)}</title>
28
+ <script src="${BX24_SDK_URL}"></script>
29
+ <style>
30
+ body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 24px; color: #333; background: #fff; }
31
+ h1 { font-size: 20px; margin: 0 0 8px; }
32
+ p { color: #666; margin: 0 0 16px; }
33
+ a.button { display: inline-block; padding: 8px 16px; border-radius: 8px; background: #1961ed; color: #fff; text-decoration: none; }
34
+ ul { list-style: none; padding: 0; margin: 16px 0 0; }
35
+ li { padding: 8px 0; border-top: 1px solid #eee; display: flex; justify-content: space-between; gap: 16px; }
36
+ li a { color: #1961ed; text-decoration: none; }
37
+ .due { color: #999; white-space: nowrap; }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <h1>${escapeHtml(title)}</h1>
42
+ <p>${escapeHtml(message)}</p>
43
+ ${crmUrl !== undefined ? `<a class="button" href="${escapeHtml(crmUrl)}" target="_blank" rel="noopener">Открыть документооборот</a>` : ''}
44
+ ${tasks.length > 0 ? `<ul>${tasks
45
+ .map(
46
+ (task) =>
47
+ `<li>${task.url !== undefined ? `<a href="${escapeHtml(task.url)}" target="_blank" rel="noopener">${escapeHtml(task.title)}</a>` : escapeHtml(task.title)}<span class="due">${escapeHtml(task.dueAt ?? '')}</span></li>`,
48
+ )
49
+ .join('')}</ul>` : ''}
50
+ ${isInstall ? '<script>if (window.BX24) { BX24.init(function () { BX24.installFinish(); }); }</script>' : ''}
51
+ </body>
52
+ </html>`;
@@ -0,0 +1,67 @@
1
+ import { kv } from 'twenty-sdk/logic-function';
2
+
3
+ import { type BitrixPortal, type BitrixRequest } from 'src/shared/bitrix/bitrix.type';
4
+ import { BITRIX_PORTAL_KV_KEY } from 'src/shared/bitrix/bitrix.util';
5
+ import {
6
+ BitrixError,
7
+ exchangeBitrixRefreshToken,
8
+ withFreshBitrixToken,
9
+ } from 'src/shared/bitrix/bitrix-client';
10
+
11
+ // One Bitrix24 portal per workspace: a company works in one portal.
12
+ export const loadBitrixPortal = (): Promise<BitrixPortal | null> =>
13
+ kv.get<BitrixPortal>(BITRIX_PORTAL_KV_KEY);
14
+
15
+ export const saveBitrixPortal = (portal: BitrixPortal): Promise<void> =>
16
+ kv.set(BITRIX_PORTAL_KV_KEY, portal);
17
+
18
+ export const forgetBitrixPortal = (): Promise<boolean> =>
19
+ kv.delete(BITRIX_PORTAL_KV_KEY);
20
+
21
+ // A request from the portal proves itself by its refresh token. The first
22
+ // proven portal is linked; another portal is refused, so a stranger cannot
23
+ // take over the workspace's link.
24
+ export const proveAndLinkBitrixPortal = async (
25
+ request: BitrixRequest,
26
+ ): Promise<BitrixPortal & { bitrixUserId?: string }> => {
27
+ if (request.refreshId === undefined) {
28
+ throw new BitrixError('The request carries no refresh token');
29
+ }
30
+
31
+ const grant = await exchangeBitrixRefreshToken(request.refreshId);
32
+ const linkedPortal = await loadBitrixPortal();
33
+
34
+ if (linkedPortal !== null && linkedPortal.memberId !== grant.memberId) {
35
+ throw new BitrixError('Another Bitrix24 portal is linked to this workspace');
36
+ }
37
+
38
+ const portal: BitrixPortal = {
39
+ memberId: grant.memberId,
40
+ portalDomain: grant.portalDomain,
41
+ restEndpoint: grant.restEndpoint,
42
+ accessToken: grant.accessToken,
43
+ refreshToken: grant.refreshToken,
44
+ expiresAt: grant.expiresAt,
45
+ applicationToken: linkedPortal?.applicationToken,
46
+ };
47
+
48
+ await saveBitrixPortal(portal);
49
+
50
+ return { ...portal, bitrixUserId: grant.bitrixUserId };
51
+ };
52
+
53
+ export const getLinkedBitrixPortal = async (): Promise<BitrixPortal | null> => {
54
+ const linkedPortal = await loadBitrixPortal();
55
+
56
+ if (linkedPortal === null) {
57
+ return null;
58
+ }
59
+
60
+ const { portal, isRefreshed } = await withFreshBitrixToken(linkedPortal);
61
+
62
+ if (isRefreshed) {
63
+ await saveBitrixPortal(portal);
64
+ }
65
+
66
+ return portal;
67
+ };
@@ -0,0 +1,102 @@
1
+ import { MetadataApiClient } from 'twenty-client-sdk/metadata';
2
+
3
+ import { restEq } from 'src/shared/api/rest-filter.util';
4
+ import { readRestList, readRestRecord } from 'src/shared/api/rest-response.util';
5
+ import {
6
+ formatRecordLabel,
7
+ getDocumentNameFromFile,
8
+ readFileItems,
9
+ } from 'src/shared/documents/attachment-target.util';
10
+ import { getObjectSummaries } from 'src/shared/api/object-summaries';
11
+ import { getTask, restClient } from 'src/server/records';
12
+ import { DOCUMENT_FILE_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
13
+
14
+ export type FileCapture = {
15
+ attachmentId: string;
16
+ fileId: string;
17
+ label: string;
18
+ sourceObjectName: string;
19
+ sourceRecordId: string;
20
+ workspaceMemberId: string | null;
21
+ };
22
+
23
+ export type CaptureResult = 'CREATED' | 'ALREADY_CAPTURED' | 'FILE_GONE';
24
+
25
+ const findSourceRecordName = async (
26
+ objectName: string,
27
+ recordId: string,
28
+ ): Promise<string | null> => {
29
+ const summary = (await getObjectSummaries()).get(objectName);
30
+
31
+ if (summary === undefined || summary.labelFieldName === undefined) {
32
+ return null;
33
+ }
34
+
35
+ const record = readRestRecord<Record<string, unknown>>(
36
+ await restClient('application')
37
+ .get(`/rest/${summary.namePlural}/${recordId}`)
38
+ .catch(() => null),
39
+ );
40
+ const label = formatRecordLabel(record?.[summary.labelFieldName]);
41
+
42
+ return label === undefined ? summary.labelSingular : `${summary.labelSingular}: ${label}`;
43
+ };
44
+
45
+ // The document keeps its own copy of the file: a file belongs to one FILES
46
+ // field, and the archive must outlive the record it came from.
47
+ export const captureFile = async (capture: FileCapture): Promise<CaptureResult> => {
48
+ const existing = readRestList<{ id: string }>(
49
+ await restClient('application').get('/rest/smbDocuments', {
50
+ query: { filter: restEq('sourceFileId', capture.fileId), limit: 1 },
51
+ }),
52
+ ).records;
53
+
54
+ if (existing.length > 0) {
55
+ return 'ALREADY_CAPTURED';
56
+ }
57
+
58
+ const attachment = readRestRecord<Record<string, unknown>>(
59
+ await restClient('application').get(`/rest/attachments/${capture.attachmentId}`),
60
+ );
61
+ const file = readFileItems(attachment?.file).find(
62
+ (item) => item.fileId === capture.fileId,
63
+ );
64
+
65
+ if (file?.url === undefined) {
66
+ return 'FILE_GONE';
67
+ }
68
+
69
+ const download = await fetch(file.url);
70
+
71
+ if (!download.ok) {
72
+ throw new Error(`The file of attachment ${capture.attachmentId} could not be read (${download.status})`);
73
+ }
74
+
75
+ const uploaded = await new MetadataApiClient().uploadFile({
76
+ fileBuffer: Buffer.from(await download.arrayBuffer()),
77
+ filename: capture.label,
78
+ fieldMetadataUniversalIdentifier: DOCUMENT_FILE_UNIVERSAL_IDENTIFIER,
79
+ });
80
+
81
+ const workflowRunId =
82
+ capture.sourceObjectName === 'task'
83
+ ? ((await getTask('application', capture.sourceRecordId))?.smbWorkflowRunId ?? null)
84
+ : null;
85
+
86
+ await restClient('application').post('/rest/smbDocuments', {
87
+ name: getDocumentNameFromFile(capture.label),
88
+ file: [{ fileId: uploaded.id, label: capture.label }],
89
+ origin: workflowRunId !== null ? 'PROCESS' : 'RECORD',
90
+ sourceObjectName: capture.sourceObjectName,
91
+ sourceRecordId: capture.sourceRecordId,
92
+ sourceRecordName: await findSourceRecordName(
93
+ capture.sourceObjectName,
94
+ capture.sourceRecordId,
95
+ ),
96
+ sourceFileId: capture.fileId,
97
+ workflowRunId,
98
+ authorId: capture.workspaceMemberId,
99
+ });
100
+
101
+ return 'CREATED';
102
+ };
@@ -0,0 +1,18 @@
1
+ import { Response } from 'twenty-sdk/logic-function';
2
+
3
+ export const jsonResponse = (status: number, body: Record<string, unknown>) =>
4
+ new Response(JSON.stringify(body), {
5
+ status,
6
+ headers: { 'content-type': 'application/json; charset=utf-8' },
7
+ });
8
+
9
+ export const htmlResponse = (status: number, html: string) =>
10
+ new Response(html, {
11
+ status,
12
+ headers: { 'content-type': 'text/html; charset=utf-8' },
13
+ });
14
+
15
+ export const readJsonBody = (body: unknown): Record<string, unknown> =>
16
+ typeof body === 'object' && body !== null && !Array.isArray(body)
17
+ ? (body as Record<string, unknown>)
18
+ : {};
@@ -0,0 +1,166 @@
1
+ import { MetadataApiClient } from 'twenty-client-sdk/metadata';
2
+
3
+ import {
4
+ callCoreGraphql,
5
+ CREATE_DRAFT_FROM_WORKFLOW_VERSION_MUTATION,
6
+ CREATE_WORKFLOW_VERSION_STEP_MUTATION,
7
+ DELETE_WORKFLOW_VERSION_STEP_MUTATION,
8
+ UPDATE_WORKFLOW_VERSION_STEP_MUTATION,
9
+ UPDATE_WORKFLOW_VERSION_TRIGGER_MUTATION,
10
+ } from 'src/shared/api/core-graphql.util';
11
+ import {
12
+ LAUNCH_PARAMETERS_STEP_INPUT,
13
+ LAUNCH_PARAMETERS_STEP_NAME,
14
+ TRIGGER_STEP_ID,
15
+ } from 'src/shared/docflow.constants';
16
+ import { type FormField } from 'src/shared/form/form-field.type';
17
+ import { buildFieldsOutputSchema } from 'src/shared/form/form-field-builder.util';
18
+ import { findLaunchParametersStep } from 'src/shared/workflow/launch-step.util';
19
+ import { listWorkflowVersions, type WorkflowVersionRecord } from 'src/server/records';
20
+ import { LAUNCH_PARAMETERS_STEP_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
21
+
22
+ export type LaunchStepResult =
23
+ | { status: 'SAVED'; workflowVersionId: string; stepId: string | null }
24
+ | { status: 'NOT_MANUAL' };
25
+
26
+ const MANUAL_TRIGGER = {
27
+ name: 'Запуск вручную',
28
+ type: 'MANUAL',
29
+ position: { x: 0, y: 0 },
30
+ settings: {
31
+ outputSchema: {},
32
+ availability: { type: 'GLOBAL' },
33
+ icon: 'IconPlayerPlay',
34
+ isPinned: false,
35
+ },
36
+ nextStepIds: [],
37
+ };
38
+
39
+ // The step of the workflow editor is created with the function's id in this
40
+ // workspace; the app knows the function by its universal identifier.
41
+ const findLaunchStepLogicFunctionId = async (): Promise<string> => {
42
+ const { findManyLogicFunctions } = await new MetadataApiClient().query({
43
+ findManyLogicFunctions: { id: true, universalIdentifier: true },
44
+ });
45
+ const logicFunction = findManyLogicFunctions.find(
46
+ ({ universalIdentifier }) =>
47
+ universalIdentifier === LAUNCH_PARAMETERS_STEP_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
48
+ );
49
+
50
+ if (logicFunction === undefined) {
51
+ throw new Error('The "Launch with parameters" function is not installed');
52
+ }
53
+
54
+ return logicFunction.id;
55
+ };
56
+
57
+ // Only a draft can change: an existing one, or a new one copied from the
58
+ // latest version, as the editor does when a published process is edited.
59
+ const findOrCreateDraft = async (workflowId: string): Promise<WorkflowVersionRecord | undefined> => {
60
+ const versions = await listWorkflowVersions('person', workflowId);
61
+ const draft = versions.find(({ status }) => status === 'DRAFT');
62
+
63
+ if (draft !== undefined || versions.length === 0) {
64
+ return draft;
65
+ }
66
+
67
+ await callCoreGraphql({
68
+ query: CREATE_DRAFT_FROM_WORKFLOW_VERSION_MUTATION,
69
+ variables: { input: { workflowId, workflowVersionIdToCopy: versions[0].id } },
70
+ });
71
+
72
+ return (await listWorkflowVersions('person', workflowId)).find(({ status }) => status === 'DRAFT');
73
+ };
74
+
75
+ // Puts the "Launch with parameters" step right after the manual trigger of the
76
+ // draft and describes its output with the parameter fields, so the editor
77
+ // offers them as variables of later steps. No fields, no step.
78
+ export const syncLaunchStep = async ({
79
+ workflowId,
80
+ fields,
81
+ }: {
82
+ workflowId: string;
83
+ fields: FormField[];
84
+ }): Promise<LaunchStepResult | undefined> => {
85
+ let draft = await findOrCreateDraft(workflowId);
86
+
87
+ if (draft === undefined) {
88
+ return undefined;
89
+ }
90
+
91
+ if (draft.trigger === null || draft.trigger === undefined) {
92
+ await callCoreGraphql({
93
+ query: UPDATE_WORKFLOW_VERSION_TRIGGER_MUTATION,
94
+ variables: { input: { workflowVersionId: draft.id, trigger: MANUAL_TRIGGER } },
95
+ });
96
+ draft = { ...draft, trigger: MANUAL_TRIGGER };
97
+ }
98
+
99
+ if (draft.trigger?.type !== 'MANUAL') {
100
+ return { status: 'NOT_MANUAL' };
101
+ }
102
+
103
+ const existingStep = findLaunchParametersStep(draft.steps);
104
+
105
+ if (fields.length === 0) {
106
+ if (existingStep !== undefined) {
107
+ await callCoreGraphql({
108
+ query: DELETE_WORKFLOW_VERSION_STEP_MUTATION,
109
+ variables: { input: { workflowVersionId: draft.id, stepId: existingStep.id } },
110
+ });
111
+ }
112
+
113
+ return { status: 'SAVED', workflowVersionId: draft.id, stepId: null };
114
+ }
115
+
116
+ let step = existingStep;
117
+
118
+ if (step === undefined) {
119
+ const stepId = crypto.randomUUID();
120
+
121
+ await callCoreGraphql({
122
+ query: CREATE_WORKFLOW_VERSION_STEP_MUTATION,
123
+ variables: {
124
+ input: {
125
+ workflowVersionId: draft.id,
126
+ id: stepId,
127
+ stepType: 'LOGIC_FUNCTION',
128
+ parentStepId: TRIGGER_STEP_ID,
129
+ nextStepId: draft.trigger.nextStepIds?.[0],
130
+ defaultSettings: {
131
+ input: {
132
+ logicFunctionId: await findLaunchStepLogicFunctionId(),
133
+ logicFunctionInput: { parameters: LAUNCH_PARAMETERS_STEP_INPUT },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ });
139
+
140
+ const versions = await listWorkflowVersions('person', workflowId);
141
+
142
+ step = versions
143
+ .find(({ id }) => id === draft?.id)
144
+ ?.steps?.find(({ id }) => id === stepId);
145
+
146
+ if (step === undefined) {
147
+ throw new Error(`The launch step ${stepId} was not created`);
148
+ }
149
+ }
150
+
151
+ await callCoreGraphql({
152
+ query: UPDATE_WORKFLOW_VERSION_STEP_MUTATION,
153
+ variables: {
154
+ input: {
155
+ workflowVersionId: draft.id,
156
+ step: {
157
+ ...step,
158
+ name: LAUNCH_PARAMETERS_STEP_NAME,
159
+ settings: { ...step.settings, outputSchema: buildFieldsOutputSchema(fields) },
160
+ },
161
+ },
162
+ },
163
+ });
164
+
165
+ return { status: 'SAVED', workflowVersionId: draft.id, stepId: step.id };
166
+ };
@@ -0,0 +1,116 @@
1
+ import { DOCFLOW_SECTION, CRM_SECTION, TASK_DONE_STATUS, TASK_TODO_STATUS } from 'src/shared/docflow.constants';
2
+ import { parseFormFields } from 'src/shared/form/form-fields.util';
3
+ import { getRunTargetRecord } from 'src/shared/workflow/run-target-record.util';
4
+ import { getFormStepTaskRequests } from 'src/shared/workflow/workflow-run.util';
5
+ import {
6
+ createTask,
7
+ createTaskTarget,
8
+ findOpenTasksOfRun,
9
+ findTaskByThreadId,
10
+ getWorkflow,
11
+ getWorkflowRun,
12
+ updateTask,
13
+ type WorkflowRunRecord,
14
+ } from 'src/server/records';
15
+
16
+ export type HandOutResult = 'CREATED' | 'ALREADY_HANDED_OUT' | 'NOT_WAITING';
17
+
18
+ // Creates the CRM task of one waiting FORM step. The run is read again: the
19
+ // job may run after the step was answered or the task created.
20
+ export const handOutFormStep = async ({
21
+ workflowRunId,
22
+ stepId,
23
+ threadId,
24
+ }: {
25
+ workflowRunId: string;
26
+ stepId: string;
27
+ threadId: string;
28
+ }): Promise<HandOutResult> => {
29
+ if ((await findTaskByThreadId(threadId)) !== undefined) {
30
+ return 'ALREADY_HANDED_OUT';
31
+ }
32
+
33
+ const run = await getWorkflowRun('application', workflowRunId);
34
+ const request = getFormStepTaskRequests({
35
+ state: run?.state,
36
+ runStarterWorkspaceMemberId: run?.createdBy?.workspaceMemberId,
37
+ now: new Date(),
38
+ }).find(
39
+ (candidate) => candidate.stepId === stepId && candidate.threadId === threadId,
40
+ );
41
+
42
+ if (run === undefined || request === undefined) {
43
+ return 'NOT_WAITING';
44
+ }
45
+
46
+ const workflow =
47
+ typeof run.workflowId === 'string'
48
+ ? await getWorkflow('application', run.workflowId)
49
+ : undefined;
50
+
51
+ const task = await createTask('application', {
52
+ title: request.title,
53
+ assigneeId: request.assigneeWorkspaceMemberId,
54
+ dueAt: request.dueAt ?? null,
55
+ status: TASK_TODO_STATUS,
56
+ smbSection:
57
+ workflow?.smbSection === DOCFLOW_SECTION ? DOCFLOW_SECTION : CRM_SECTION,
58
+ smbFormSchema: request.fields,
59
+ smbFormValues: {},
60
+ smbWorkflowRunId: run.id,
61
+ smbWorkflowStepId: request.stepId,
62
+ smbWorkflowThreadId: request.threadId,
63
+ });
64
+
65
+ const target = getRunTargetRecord(run.state);
66
+
67
+ // The task stands without the link: a record deleted meanwhile must not
68
+ // cancel handing it out.
69
+ if (task !== undefined && target !== undefined) {
70
+ await createTaskTarget({
71
+ taskId: task.id,
72
+ [target.targetFieldName]: target.recordId,
73
+ }).catch(() => undefined);
74
+ }
75
+
76
+ return 'CREATED';
77
+ };
78
+
79
+ // A step answered on the run page or by a launch closes its open task with
80
+ // the answer, so the task does not stay open.
81
+ export const closeTasksOfAnsweredSteps = async (
82
+ run: WorkflowRunRecord,
83
+ ): Promise<number> => {
84
+ const tasks = await findOpenTasksOfRun(run.id);
85
+ let closedCount = 0;
86
+
87
+ for (const task of tasks) {
88
+ const stepInfo =
89
+ typeof task.smbWorkflowStepId === 'string'
90
+ ? run.state?.stepInfos?.[task.smbWorkflowStepId]
91
+ : undefined;
92
+
93
+ if (
94
+ stepInfo?.status !== 'SUCCESS' ||
95
+ stepInfo.threadId !== task.smbWorkflowThreadId
96
+ ) {
97
+ continue;
98
+ }
99
+
100
+ const answer =
101
+ typeof stepInfo.result === 'object' && stepInfo.result !== null
102
+ ? (stepInfo.result as Record<string, unknown>)
103
+ : {};
104
+ const fieldNames = parseFormFields(task.smbFormSchema).map(({ name }) => name);
105
+
106
+ await updateTask('application', task.id, {
107
+ smbFormValues: Object.fromEntries(
108
+ fieldNames.filter((name) => name in answer).map((name) => [name, answer[name]]),
109
+ ),
110
+ status: TASK_DONE_STATUS,
111
+ });
112
+ closedCount++;
113
+ }
114
+
115
+ return closedCount;
116
+ };
@@ -0,0 +1,132 @@
1
+ import { RestApiClient } from 'twenty-client-sdk/rest';
2
+
3
+ import { restAnd, restEq, restNeq, restIsNull, restOr } from 'src/shared/api/rest-filter.util';
4
+ import { readRestList, readRestRecord } from 'src/shared/api/rest-response.util';
5
+ import { TASK_DONE_STATUS } from 'src/shared/docflow.constants';
6
+ import {
7
+ type WorkflowRunState,
8
+ type WorkflowStep,
9
+ type WorkflowTrigger,
10
+ } from 'src/shared/workflow/workflow-run.type';
11
+
12
+ // Every record the server functions read or write goes through here, over
13
+ // the REST API: it is the most stable public surface of Twenty, so a new core
14
+ // version changes at most this file.
15
+ export type RunAs = 'person' | 'application';
16
+
17
+ const clients: Record<RunAs, RestApiClient> = {
18
+ person: new RestApiClient(),
19
+ application: new RestApiClient({ runAs: 'application' }),
20
+ };
21
+
22
+ export const restClient = (runAs: RunAs): RestApiClient => clients[runAs];
23
+
24
+ export type DocflowTaskRecord = {
25
+ id: string;
26
+ title?: string | null;
27
+ status?: string | null;
28
+ assigneeId?: string | null;
29
+ smbSection?: string | null;
30
+ smbFormSchema?: unknown;
31
+ smbFormValues?: unknown;
32
+ smbWorkflowRunId?: string | null;
33
+ smbWorkflowStepId?: string | null;
34
+ smbWorkflowThreadId?: string | null;
35
+ };
36
+
37
+ export type WorkflowRunRecord = {
38
+ id: string;
39
+ name?: string | null;
40
+ status?: string | null;
41
+ workflowId?: string | null;
42
+ state?: WorkflowRunState | null;
43
+ createdBy?: { workspaceMemberId?: string | null } | null;
44
+ };
45
+
46
+ export type WorkflowRecord = {
47
+ id: string;
48
+ smbSection?: string | null;
49
+ smbLaunchFields?: unknown;
50
+ };
51
+
52
+ export type WorkflowVersionRecord = {
53
+ id: string;
54
+ status?: string | null;
55
+ workflowId?: string | null;
56
+ trigger?: WorkflowTrigger | null;
57
+ steps?: WorkflowStep[] | null;
58
+ };
59
+
60
+ export const getTask = async (runAs: RunAs, taskId: string) =>
61
+ readRestRecord<DocflowTaskRecord>(
62
+ await restClient(runAs).get(`/rest/tasks/${taskId}`),
63
+ );
64
+
65
+ export const updateTask = async (
66
+ runAs: RunAs,
67
+ taskId: string,
68
+ data: Partial<Omit<DocflowTaskRecord, 'id'>> & Record<string, unknown>,
69
+ ) =>
70
+ readRestRecord<DocflowTaskRecord>(
71
+ await restClient(runAs).patch(`/rest/tasks/${taskId}`, data),
72
+ );
73
+
74
+ export const createTask = async (
75
+ runAs: RunAs,
76
+ data: Record<string, unknown>,
77
+ ) =>
78
+ readRestRecord<DocflowTaskRecord>(
79
+ await restClient(runAs).post('/rest/tasks', data),
80
+ );
81
+
82
+ export const findTaskByThreadId = async (threadId: string) =>
83
+ readRestList<DocflowTaskRecord>(
84
+ await restClient('application').get('/rest/tasks', {
85
+ query: { filter: restEq('smbWorkflowThreadId', threadId), limit: 1 },
86
+ }),
87
+ ).records[0];
88
+
89
+ export const findOpenTasksOfRun = async (workflowRunId: string) =>
90
+ readRestList<DocflowTaskRecord>(
91
+ await restClient('application').get('/rest/tasks', {
92
+ query: {
93
+ filter: restAnd(
94
+ restEq('smbWorkflowRunId', workflowRunId),
95
+ restOr(restNeq('status', TASK_DONE_STATUS), restIsNull('status')),
96
+ ),
97
+ limit: 200,
98
+ },
99
+ }),
100
+ ).records;
101
+
102
+ export const getWorkflowRun = async (runAs: RunAs, workflowRunId: string) =>
103
+ readRestRecord<WorkflowRunRecord>(
104
+ await restClient(runAs).get(`/rest/workflowRuns/${workflowRunId}`),
105
+ );
106
+
107
+ export const getWorkflow = async (runAs: RunAs, workflowId: string) =>
108
+ readRestRecord<WorkflowRecord>(
109
+ await restClient(runAs).get(`/rest/workflows/${workflowId}`),
110
+ );
111
+
112
+ export const getWorkflowVersion = async (
113
+ runAs: RunAs,
114
+ workflowVersionId: string,
115
+ ) =>
116
+ readRestRecord<WorkflowVersionRecord>(
117
+ await restClient(runAs).get(`/rest/workflowVersions/${workflowVersionId}`),
118
+ );
119
+
120
+ export const listWorkflowVersions = async (runAs: RunAs, workflowId: string) =>
121
+ readRestList<WorkflowVersionRecord>(
122
+ await restClient(runAs).get('/rest/workflowVersions', {
123
+ query: {
124
+ filter: restEq('workflowId', workflowId),
125
+ order_by: 'createdAt[DescNullsLast]',
126
+ limit: 50,
127
+ },
128
+ }),
129
+ ).records;
130
+
131
+ export const createTaskTarget = async (data: Record<string, unknown>) =>
132
+ restClient('application').post('/rest/taskTargets', data);