Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,53 @@
import { HttpMethod, httpClient } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import { instanceLogin, recordProperty } from '../common';
import { elementTypeProperty } from '../common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const createRecord = createAction({
name: 'create_record',
auth: vtigerAuth,
displayName: 'Create Record',
description: 'Create a Record',
props: {
elementType: elementTypeProperty,
record: recordProperty(),
},
async run({ propsValue: { elementType, record }, auth }) {
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (instance !== null) {
const response = await httpClient.sendRequest<Record<string, unknown>[]>({
method: HttpMethod.POST,
url: `${auth.props.instance_url}/webservice.php`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
operation: 'create',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType,
element: JSON.stringify(record),
},
});
console.debug({
operation: 'create',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType,
element: JSON.stringify(record),
});
return response.body;
}
return null;
},
});

View File

@@ -0,0 +1,45 @@
import { HttpMethod, httpClient } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import { instanceLogin, recordIdProperty } from '../common';
import { elementTypeProperty } from '../common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const deleteRecord = createAction({
name: 'delete_record',
auth: vtigerAuth,
displayName: 'Delete Record',
description: 'Delete a Record',
props: {
elementType: elementTypeProperty,
record: recordIdProperty(),
},
async run({
propsValue: { elementType, record },
auth
}) {
const instance = await instanceLogin(auth.props.instance_url, auth.props.username, auth.props.password);
if (instance !== null) {
const response = await httpClient.sendRequest<Record<string, unknown>[]>({
method: HttpMethod.POST,
url: `${auth.props.instance_url}/webservice.php`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
operation: 'delete',
sessionName: instance.sessionId ?? instance.sessionName,
elementType,
id: record['id'],
},
});
return response.body;
}
return null;
},
});

View File

@@ -0,0 +1,42 @@
import { HttpMethod, httpClient } from '@activepieces/pieces-common';
import { createAction } from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import { instanceLogin, recordIdProperty } from '../common';
import { elementTypeProperty } from '../common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const getRecord = createAction({
name: 'get_record',
auth: vtigerAuth,
displayName: 'Get Record',
description: 'Get a Record by value',
props: {
elementType: elementTypeProperty,
record: recordIdProperty(),
},
async run({
propsValue: { elementType, record },
auth
}) {
const instance = await instanceLogin(auth.props.instance_url, auth.props.username, auth.props.password);
if (instance !== null) {
const response = await httpClient.sendRequest<Record<string, unknown>[]>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
operation: 'retrieve',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType as unknown as string,
...record,
},
});
return response.body;
}
return null;
},
});

View File

@@ -0,0 +1,195 @@
import { Property, createAction } from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import { instanceLogin } from '../common';
import {
AuthenticationType,
HttpHeaders,
HttpMessageBody,
HttpMethod,
HttpRequest,
httpClient,
} from '@activepieces/pieces-common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const makeAPICall = createAction({
name: 'make_api_call',
auth: vtigerAuth,
displayName: 'Custom API Call (Deprecated)',
description: 'Performs an arbitrary authorized API call. (Deprecated)',
props: {
method: Property.StaticDropdown<HttpMethod>({
displayName: 'Http Method',
description: 'Select the HTTP method you want to use',
required: true,
options: {
options: [
{ label: 'GET', value: HttpMethod.GET },
{ label: 'POST', value: HttpMethod.POST },
{ label: 'PUT', value: HttpMethod.PUT },
{ label: 'PATCH', value: HttpMethod.PATCH },
{ label: 'DELETE', value: HttpMethod.DELETE },
],
},
}),
url: Property.ShortText({
displayName: 'URL',
description:
'Absolute URL or path. If a relative path is provided (e.g., /me, /listtypes, /describe), it will be called against the REST base.',
required: false,
}),
urlPath: Property.ShortText({
displayName: 'URL Path (deprecated)',
description:
"Deprecated. Use 'URL' instead. API endpoint's URL path (example: /me, /listtypes, /describe)",
required: false,
}),
headers: Property.Json({
displayName: 'Headers',
description: `Enter the desired request headers. Skip the authorization headers`,
required: false,
defaultValue: {},
}),
data: Property.Json({
displayName: 'Data',
description: `Enter the data to pass. if its POST, it will be sent as body data, and if GET, as query string`,
required: false,
defaultValue: {},
}),
},
async run({ propsValue, auth }) {
const method = propsValue.method ?? HttpMethod.GET;
const urlPath = propsValue.urlPath;
const url = propsValue.url;
if (urlPath && !urlPath.startsWith('/')) {
return {
error:
'URL path must start with a slash, example: /me, /listtypes, /describe',
};
}
let finalUrl = `${auth.props.instance_url}/webservice.php`;
let useRestAuth = false;
if (url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
finalUrl = url;
} else if (url.startsWith('/')) {
finalUrl = `${auth.props.instance_url}/restapi/v1/vtiger/default${url}`;
useRestAuth = true;
} else {
finalUrl = `${auth.props.instance_url}/restapi/v1/vtiger/default/${url}`;
useRestAuth = true;
}
} else if (urlPath) {
finalUrl = `${auth.props.instance_url}/restapi/v1/vtiger/default${urlPath}`;
useRestAuth = true;
}
const normalizeHeaders = (h: unknown): HttpHeaders => {
const out: HttpHeaders = {};
if (h && typeof h === 'object' && !Array.isArray(h)) {
for (const [k, v] of Object.entries(h as Record<string, unknown>)) {
if (v === undefined || v === null) {
out[k] = undefined;
} else if (Array.isArray(v)) {
out[k] = (v as unknown[]).map((x) => String(x));
} else if (typeof v === 'string') {
out[k] = v;
} else {
out[k] = String(v);
}
}
}
return out;
};
const headers: HttpHeaders = normalizeHeaders(propsValue.headers);
if (useRestAuth) {
// Default JSON for REST when not GET and no explicit content-type provided
if (
method !== HttpMethod.GET &&
!Object.keys(headers).some(
(k) => k.toLowerCase() === 'content-type'
)
) {
headers['Content-Type'] = 'application/json';
}
} else {
// webservice.php defaults to urlencoded for POST operations
if (
method !== HttpMethod.GET &&
!Object.keys(headers).some(
(k) => k.toLowerCase() === 'content-type'
)
) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
}
const httpRequest: HttpRequest<HttpMessageBody> = {
url: finalUrl,
method,
headers,
};
let data: Record<string, unknown> = propsValue.data ?? {};
const toQueryParams = (obj: Record<string, unknown>): Record<string, string> => {
const qp: Record<string, string> = {};
for (const [k, v] of Object.entries(obj ?? {})) {
if (v === undefined || v === null) continue;
qp[k] = typeof v === 'string' ? v : JSON.stringify(v);
}
return qp;
};
if (useRestAuth) {
httpRequest.authentication = {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
};
} else {
const vtigerInstance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (vtigerInstance === null) return;
data = {
sessionName: vtigerInstance.sessionId ?? vtigerInstance.sessionName,
...(propsValue.data ?? {}),
};
}
if (method === HttpMethod.GET) {
httpRequest['queryParams'] = toQueryParams(data);
} else {
// For REST with JSON default, send raw object; else url-encode
const contentType = Object.entries(headers).find(([k]) => k.toLowerCase() === 'content-type')?.[1];
const ct = Array.isArray(contentType) ? contentType[0] : contentType;
if (useRestAuth && ct === 'application/json') {
httpRequest['body'] = data;
} else {
httpRequest['body'] = toQueryParams(data);
}
}
const response = await httpClient.sendRequest<Record<string, unknown>[]>(
httpRequest
);
if ([200, 201].includes(response.status)) {
return response.body;
}
return {
error: 'Unexpected outcome!',
};
},
});

View File

@@ -0,0 +1,57 @@
import { Property, createAction } from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import {
Operation,
elementTypeProperty,
instanceLogin,
prepareHttpRequest,
} from '../common';
import { httpClient } from '@activepieces/pieces-common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const queryRecords = createAction({
name: 'query_records',
auth: vtigerAuth,
displayName: 'Query Records',
description: 'Query records by SQL statement.',
props: {
query: Property.LongText({
displayName: 'Query',
description:
'Enter the query statement, e.g. SELECT count(*) FROM Contacts;',
required: true,
}),
},
async run({ propsValue, auth }) {
const vtigerInstance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (vtigerInstance === null) return;
const response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, unknown>[];
}>(
prepareHttpRequest(
auth.props.instance_url,
vtigerInstance.sessionId ?? vtigerInstance.sessionName,
'query' as Operation,
{ query: propsValue.query }
)
);
if (response.body.success) {
return response.body.result;
} else {
console.debug(response);
return {
error: 'Unexpected outcome!',
};
}
},
});

View File

@@ -0,0 +1,94 @@
import {
PiecePropValueSchema,
Property,
createAction,
} from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import {
VTigerAuthValue,
queryRecords,
countRecords,
elementTypeProperty,
generateElementFields,
instanceLogin,
} from '../common';
//Docs: https://code.vtiger.com/vtiger/vtigercrm-manual/-/wikis/Webservice-Docs
//Extra: https://help.vtiger.com/article/147111249-Rest-API-Manual
export const searchRecords = createAction({
name: 'search_records',
auth: vtigerAuth,
displayName: 'Search Records',
description: 'Search for a record.',
props: {
elementType: elementTypeProperty,
fields: Property.DynamicProperties({
auth: vtigerAuth,
displayName: 'Search Fields',
description: 'Enter your filter criteria',
required: true,
refreshers: ['elementType'],
props: async ({ auth, elementType }) => {
if (!auth || !elementType) {
return {};
}
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (instance === null) {
return {};
}
return generateElementFields(
auth,
elementType as unknown as string,
{},
true
);
},
}),
limit: Property.Number({
displayName: 'Limit',
description: 'Enter the maximum number of records to return.',
required: false,
}),
},
async run({ propsValue, auth }) {
const vtigerInstance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (vtigerInstance === null) return;
const count = await countRecords(auth, propsValue.elementType as string);
if (count > 0) {
const records: Record<string, unknown>[] = await queryRecords(
auth,
propsValue.elementType as string,
0,
count
);
const filtered = records.filter((record) => {
return Object.entries(propsValue['fields']).every(([key, value]) => {
const recordValue = record[key];
if (typeof value === 'string') {
const rv = typeof recordValue === 'string' ? recordValue : String(recordValue ?? '');
return rv.toLowerCase().includes(value.toLowerCase());
}
return recordValue === value;
});
});
return propsValue.limit ? filtered.slice(0, propsValue.limit) : filtered;
} else {
return [];
}
},
});

View File

@@ -0,0 +1,316 @@
import { HttpMethod, httpClient } from '@activepieces/pieces-common';
import {
DropdownState,
DynamicPropsValue,
PiecePropValueSchema,
Property,
createAction,
} from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import {
instanceLogin,
VTigerAuthValue,
Modules,
Field,
getRecordReference,
} from '../common';
import { elementTypeProperty } from '../common';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const updateRecord = createAction({
name: 'update_record',
auth: vtigerAuth,
displayName: 'Update Record',
description: 'Update a Record',
props: {
elementType: elementTypeProperty,
id: Property.Dropdown({
auth: vtigerAuth,
displayName: 'Id',
description: "The record's id",
required: true,
refreshers: ['elementType'],
options: async ({ auth, elementType }) => {
if (!auth || !elementType) {
return {
disabled: true,
options: [],
placeholder:
'Please select the element type and setup authentication to continue.',
};
}
let c = 0;
let instance = null;
while (!instance && c < 3) {
instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
await sleep(1500);
c++;
}
if (!instance) {
return {
disabled: true,
options: [],
placeholder: 'Authentication failed.',
};
}
const response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, string>[];
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
sessionName: instance.sessionId ?? instance.sessionName,
operation: 'query',
elementType: elementType as unknown as string,
query: `SELECT * FROM ${elementType} LIMIT 100;`,
},
});
if (!response.body.success)
return {
disabled: true,
options: [],
placeholder: 'Request unsuccessful.',
};
const element: string = elementType as unknown as string;
return {
options: await Promise.all(response.body.result.map(async (record) => {
return {
label: await Modules[element]?.(record) || record['id'],
value: record['id'] as string,
};
})),
disabled: false,
};
},
}),
record: Property.DynamicProperties({
auth: vtigerAuth,
displayName: 'Record Fields',
description: 'Add new fields to be created in the new record',
required: true,
refreshers: ['id', 'elementType'],
props: async ({ auth, id, elementType }) => {
if (!auth || !elementType) {
return {};
}
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (!instance) return {};
let defaultValue: Record<string, unknown>;
if (id && 'id') {
const retrieve_response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, unknown>;
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
operation: 'retrieve',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType as unknown as string,
id: id as unknown as string,
},
});
if (retrieve_response.body.result) {
defaultValue = retrieve_response.body.result;
} else {
defaultValue = {};
}
} else {
defaultValue = {};
}
const describe_response = await httpClient.sendRequest<{
success: boolean;
result: { fields: Field[] };
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
sessionName: instance.sessionId ?? instance.sessionName,
operation: 'describe',
elementType: elementType as unknown as string,
},
});
const fields: DynamicPropsValue = {};
if (describe_response.body.success) {
let limit = 30; // Limit to show 30 input property, more than this will cause frontend unresponsive
const generateField = async (field: Field) => {
const params = {
displayName: field.label,
description: `Field ${field.name} of object type ${elementType}`,
required: field.mandatory,
};
if (
[
'string',
'text',
'mediumtext',
'phone',
'url',
'email',
].includes(field.type.name)
) {
if (['mediumtext', 'url'].includes(field.type.name)) {
fields[field.name] = Property.LongText({
...params,
defaultValue: defaultValue?.[field.name] as string,
});
} else {
fields[field.name] = Property.ShortText({
...params,
defaultValue: defaultValue?.[field.name] as string,
});
}
} else if (
['picklist', 'reference', 'owner'].includes(field.type.name)
) {
let options: DropdownState<string>;
if (field.type.name === 'picklist') {
options = {
disabled: false,
options: field.type.picklistValues ?? [],
};
} else if (field.type.name === 'owner') {
options = await getRecordReference(
auth,
['Users']
);
} else if (field.type.refersTo) {
options = await getRecordReference(
auth,
field.type.refersTo ?? []
);
} else {
options = { disabled: false, options: [] };
}
fields[field.name] = Property.StaticDropdown({
...params,
defaultValue: defaultValue?.[field.name] as string,
options,
});
} else if (
['double', 'integer', 'currency'].includes(field.type.name)
) {
fields[field.name] = Property.Number({
...params,
defaultValue: defaultValue?.[field.name] as number,
});
} else if (['boolean'].includes(field.type.name)) {
fields[field.name] = Property.Checkbox({
displayName: field.label,
description: `The fields to fill in the object type ${elementType}`,
required: field.mandatory,
defaultValue: defaultValue?.[field.name] ? true : false,
});
} else if (['date', 'datetime', 'time'].includes(field.type.name)) {
fields[field.name] = Property.DateTime({
displayName: field.label,
description: `The fields to fill in the object type ${elementType}`,
defaultValue: defaultValue?.[field.name] as string,
required: field.mandatory,
});
}
};
const skipFields = [
'id',
];
// Prioritize mandatory fields
for (const field of describe_response.body.result.fields) {
if (skipFields.includes(field.name)) {
continue;
}
if (field.mandatory) {
await generateField(field);
limit--;
}
}
// Let's add the rest...
for (const field of describe_response.body.result.fields) {
if (skipFields.includes(field.name)) {
continue;
}
// Skip the rest of field to avoid unresponsive frontend
if (limit < 0) break;
if (!field.mandatory) {
await generateField(field);
limit--;
}
}
}
return fields;
},
}),
},
async run({ propsValue: { elementType, id, record }, auth }) {
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (instance !== null) {
const response = await httpClient.sendRequest<Record<string, unknown>[]>({
method: HttpMethod.POST,
url: `${auth.props.instance_url}/webservice.php`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
operation: 'update',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType,
element: JSON.stringify({
id: id,
...record,
}),
},
});
console.debug({
operation: 'update',
sessionName: instance.sessionId ?? instance.sessionName,
elementType: elementType,
element: JSON.stringify({
id: id,
...record,
}),
});
return response.body;
}
return null;
},
});

View File

@@ -0,0 +1,635 @@
import {
AuthenticationType,
HttpHeaders,
HttpMessageBody,
HttpMethod,
HttpRequest,
httpClient,
} from '@activepieces/pieces-common';
import {
AppConnectionValueForAuthProperty,
DropdownState,
DynamicPropsValue,
PiecePropValueSchema,
Property,
} from '@activepieces/pieces-framework';
import * as crypto from 'crypto-js';
import { Challenge, Instance } from './models';
import { vtigerAuth } from '..';
export const isBaseUrl = (urlString: string): boolean => {
try {
const url = new URL(urlString);
return !url.pathname || url.pathname === '/';
} catch (error) {
// Handle invalid URLs here, e.g., return false or throw an error
return false;
}
};
export const md5 = (contents: string) => crypto.MD5(contents).toString();
export const calculateAuthKey = (
challengeToken: string,
accessKey: string
): string => crypto.MD5(challengeToken + accessKey).toString(crypto.enc.Hex);
export const instanceLogin = async (
instance_url: string,
username: string,
password: string,
debug = false
) => {
const endpoint = `${instance_url}/webservice.php`;
const challenge = await httpClient.sendRequest<{
success: boolean;
result: Challenge;
}>({
method: HttpMethod.GET,
url: `${endpoint}?operation=getchallenge&username=${username}`,
});
const accessKey = calculateAuthKey(challenge.body.result.token, password);
const response = await httpClient.sendRequest<{
success: boolean;
result: Instance;
}>({
method: HttpMethod.POST,
url: `${endpoint}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
operation: 'login',
username,
accessKey,
},
});
if (debug) {
console.debug('>>>>>>>>>>>> LOGIN', response.body, {
method: HttpMethod.POST,
url: `${endpoint}`,
headers: {
'Content-Type': 'multipart/form-data',
},
body: {
operation: 'login',
username,
accessKey,
},
});
}
if (response.body.success) {
return response.body.result;
}
return null;
};
export type Operation =
| 'create'
| 'retrieve'
| 'delete'
| 'update'
| 'query'
| 'listtypes';
export const Operations: Record<Operation, BodyParams> = {
listtypes: {
method: HttpMethod.GET,
},
create: {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
retrieve: {
method: HttpMethod.GET,
},
delete: {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
update: {
method: HttpMethod.POST,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
query: {
method: HttpMethod.GET,
},
};
export const prepareHttpRequest = (
instanceUrl: string,
sessionName: string,
operation: Operation,
record: Record<string, string>
) => {
const data: Record<string, string> = {
operation,
sessionName,
...record,
};
if ('element' in record) data['element'] = JSON.stringify(record['element']);
const httpRequest: HttpRequest<HttpMessageBody> = {
url: `${instanceUrl}/webservice.php`,
method: Operations[operation].method,
headers: Operations[operation].headers,
};
if (Operations[operation].method === HttpMethod.GET) {
httpRequest['queryParams'] = data;
} else if (Operations[operation].method === HttpMethod.POST) {
httpRequest['body'] = data;
}
return httpRequest;
};
interface BodyParams {
method: HttpMethod;
headers?: HttpHeaders;
}
export const Modules: Record<string, (record: Record<string, string>) => Promise<string>> = {
Contacts: async (record) => `${record['email']}`, // firstname,lastname
Documents: async (record) => `${record['notes_title']}`, // title
Faq: async (record) => `${record['faq_no']}`, // question
// HelpDesk: async (record) => `${record['ticket_no']}`, // this module not exist
Invoice: async (record) => `${record['invoice_no']}`, // subject
Leads: async (record) => `${record['lead_no']}: ${record['firstname']} ${record['lastname']}`, // firstname,lastname
LineItem: async (record) => `${record['productid']}`, // no label field
ProductTaxes: async (record) => `#${record['taxid']} pid: ${record['productid']}`, // no label field
// ProjectTask: async (record) => `${record['projecttaskname']}`, // this module not exist
SalesOrder: async (record) => `${record['salesorder_no']}`, // subject
Tax: async (record) => `${record['taxname']}`, // taxlabel
Users: async (record) => `${record['user_name']}`, // first_name,last_name
};
export async function refreshModules(auth: VTigerAuthValue){
const response = await httpClient.sendRequest({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/restapi/v1/vtiger/default/listtypes?fieldTypeList=null`,
authentication: {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
},
});
if(response.body.success !== true){
throw new Error('Failed to retrieve module types');
}
const types = response.body.result.types;
for (let i = 0; i < types.length; i++) {
const element = types[i];
let labelFields = '';
let isModuleLabelUnknown = false;
Modules[element] ??= async (record) => {
if(labelFields !== '') return labelFields;
if(isModuleLabelUnknown) return '';
const response = await httpClient.sendRequest({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/restapi/v1/vtiger/default/describe?elementType=${element}`,
authentication: {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
},
});
if (!response.body.success) return '';
const result = response.body.result;
const lf = result.labelFields;
if (Array.isArray(lf)) {
labelFields = (lf[0] ?? '') as string;
} else if (typeof lf === 'string') {
labelFields = lf.includes(',') ? lf.split(',')[0] : lf;
} else {
labelFields = '';
}
if(labelFields === '') {
if(!result.fields?.length){
isModuleLabelUnknown = true;
return '';
}
labelFields = result.fields[0].name;
}
return record[labelFields];
};
}
}
export const elementTypeProperty = Property.Dropdown({
auth: vtigerAuth,
displayName: 'Module Type',
description: 'The module / element type',
required: true,
refreshers: [],
options: async (props) => {
const { auth } = props;
if (!auth) {
return {
disabled: true,
options: [],
placeholder: 'Please setup authentication to continue',
};
}
await refreshModules(auth);
const modules = Object.keys(Modules).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
return {
disabled: false,
options: modules.map((module) => ({
label: module,
value: module,
}))
};
}
});
export interface Field {
name: string;
dblabel: string;
label: string;
default: string;
mandatory: boolean;
type: {
name: string;
length?: string;
refersTo?: string[];
picklistValues?: {
label: string;
value: string;
}[];
};
}
export type VTigerAuthValue = AppConnectionValueForAuthProperty<typeof vtigerAuth>;
export const recordIdProperty = () =>
Property.DynamicProperties({
auth: vtigerAuth,
displayName: 'Record Fields',
description: 'Add new fields to be created in the new record',
required: true,
refreshers: ['elementType'],
props: async ({ auth, elementType }) => {
if (!auth || !elementType) {
return {};
}
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (!instance) return {};
const response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, string>[];
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
sessionName: instance.sessionId ?? instance.sessionName,
operation: 'query',
elementType: elementType as unknown as string,
query: `SELECT * FROM ${elementType} LIMIT 100;`,
},
});
if (!response.body.success) return {};
const fields: DynamicPropsValue = {};
const _type: string = elementType as unknown as string;
const _module: CallableFunction = Modules[_type];
fields['id'] = Property.StaticDropdown<string>({
displayName: 'Id',
description: "The record's id",
required: true,
options: {
options: response.body.result.map((r) => ({
label: _module?.(r) || r['id'],
value: r['id'],
})),
},
});
return fields;
},
});
export const FieldMapping = {
autogenerated: Property.ShortText,
string: Property.ShortText,
text: Property.ShortText,
double: Property.Number,
integer: Property.Number,
mediumtext: Property.LongText,
phone: Property.LongText,
url: Property.LongText,
email: Property.LongText,
picklist: Property.StaticDropdown,
reference: Property.StaticDropdown,
currency: Property.Number,
boolean: Property.Checkbox,
owner: Property.StaticDropdown,
date: Property.DateTime,
datetime: Property.DateTime,
file: Property.File,
time: Property.DateTime,
};
export async function getRecordReference(
auth: AppConnectionValueForAuthProperty<typeof vtigerAuth>,
modules: string[]
): Promise<DropdownState<string>> {
const module = modules[0]; //Limit to the first reference for now
const vtigerInstance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (vtigerInstance === null)
return {
disabled: true,
options: [],
};
const httpRequest = prepareHttpRequest(
auth.props.instance_url,
vtigerInstance.sessionId ?? vtigerInstance.sessionName,
'query' as Operation,
{ query: `SELECT * FROM ${module};` }
);
const response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, any>[];
}>(httpRequest);
if (response.body.success) {
return {
disabled: false,
options: await Promise.all(response.body.result.map(async (record) => {
return {
label: await Modules[module]?.(record) || record['id'],
value: record['id'] as string,
};
})),
};
}
return {
disabled: true,
options: [],
};
}
export const recordProperty = () =>
Property.DynamicProperties({
auth: vtigerAuth,
displayName: 'Record Fields',
description: 'Add new fields to be created in the new record',
required: true,
refreshers: ['elementType'],
props: async ({ auth, id, elementType }) => {
if (!auth || !elementType) {
return {};
}
return generateElementFields(
auth,
elementType as unknown as string,
{}
);
},
});
export const queryRecords = async (
auth: AppConnectionValueForAuthProperty<typeof vtigerAuth>,
elementType: string,
page = 0,
limit = 100
) => {
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (!instance) return [];
const response = await httpClient.sendRequest<{
success: boolean;
result: Record<string, unknown>[];
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
sessionName: instance.sessionId ?? instance.sessionName,
operation: 'query',
elementType: elementType as unknown as string,
query: `SELECT * FROM ${elementType} LIMIT ${page}, ${limit};`,
},
});
if (response.body.success) {
return response.body.result;
}
return [];
};
export const countRecords = async (
auth: VTigerAuthValue,
elementType: string
) => {
const instance = await instanceLogin(
auth.props.instance_url,
auth.props.username,
auth.props.password
);
if (!instance) return 0;
const response = await httpClient.sendRequest<{
success: boolean;
result: { count: string }[];
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/webservice.php`,
queryParams: {
sessionName: instance.sessionId ?? instance.sessionName,
operation: 'query',
elementType: elementType as unknown as string,
query: `SELECT count(*) FROM ${elementType};`,
},
});
if (response.body.success) {
return Number.parseInt(response.body.result[0].count);
}
return 0;
};
export const generateElementFields = async (
auth: VTigerAuthValue,
elementType: string,
defaultValue: Record<string, unknown>,
skipMandatory = false
): Promise<DynamicPropsValue> => {
const describe_response = await httpClient.sendRequest<{
success: boolean;
result: { fields: Field[] };
}>({
method: HttpMethod.GET,
url: `${auth.props.instance_url}/restapi/v1/vtiger/default/describe`,
authentication: {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
},
queryParams: {
elementType: elementType,
},
});
const fields: DynamicPropsValue = {};
if (describe_response.body.success) {
let limit = 30; // Limit to show 30 input property, more than this will cause frontend unresponsive
const generateField = async (field: Field) => {
const params = {
displayName: field.label,
description: `Field ${field.name} of object type ${elementType}`,
required: !skipMandatory ? field.mandatory : false,
};
if (
['string', 'text', 'mediumtext', 'phone', 'url', 'email'].includes(
field.type.name
)
) {
if (['mediumtext', 'url'].includes(field.type.name)) {
fields[field.name] = Property.LongText({
...params,
defaultValue: defaultValue?.[field.name] as string,
});
} else {
fields[field.name] = Property.ShortText({
...params,
defaultValue: defaultValue?.[field.name] as string,
});
}
} else if (['picklist', 'reference', 'owner'].includes(field.type.name)) {
let options: DropdownState<string>;
if (field.type.name === 'picklist') {
options = {
disabled: false,
options: field.type.picklistValues ?? [],
};
} else if (field.type.name === 'owner') {
options = await getRecordReference(
auth,
['Users']
);
} else if (field.type.refersTo) {
options = await getRecordReference(
auth,
field.type.refersTo ?? []
);
} else {
options = { disabled: false, options: [] };
}
fields[field.name] = Property.StaticDropdown({
...params,
defaultValue: defaultValue?.[field.name] as string,
options,
});
} else if (['double', 'integer', 'currency'].includes(field.type.name)) {
fields[field.name] = Property.Number({
...params,
defaultValue: defaultValue?.[field.name] as number,
});
} else if (['boolean'].includes(field.type.name)) {
fields[field.name] = Property.Checkbox({
displayName: field.label,
description: `The fields to fill in the object type ${elementType}`,
required: !skipMandatory ? field.mandatory : false,
defaultValue: defaultValue?.[field.name] ? true : false,
});
} else if (['date', 'datetime', 'time'].includes(field.type.name)) {
fields[field.name] = Property.DateTime({
displayName: field.label,
description: `The fields to fill in the object type ${elementType}`,
defaultValue: defaultValue?.[field.name] as string,
required: !skipMandatory ? field.mandatory : false,
});
} else if(params.required) {
// Add the mandatory field for unknown input type, but with text input
fields[field.name] = Property.ShortText({
...params,
defaultValue: defaultValue?.[field.name] as string,
});
}
};
const skipFields = [
'id',
'modifiedtime',
'createdtime',
'modifiedby',
'created_user_id',
];
// Prioritize mandatory fields
for (const field of describe_response.body.result.fields) {
if (skipFields.includes(field.name)) {
continue;
}
if (field.mandatory) {
await generateField(field);
limit--;
}
}
// Let's add the rest...
for (const field of describe_response.body.result.fields) {
if (skipFields.includes(field.name)) {
continue;
}
// Skip the rest of field to avoid unresponsive frontend
if (limit < 0) break;
if (!field.mandatory) {
await generateField(field);
limit--;
}
}
}
else throw new Error("Failed to get module description");
return fields;
};

View File

@@ -0,0 +1,23 @@
export interface Challenge {
token: string; // Challenge token to be used for login.
serverTime: string; // Current Server time
expireTime: string;
}
export interface Instance {
sessionId: string;
sessionName: string;
userId: string;
version: string;
vtigerVersion: string;
username: string;
first_name: string;
last_name: string;
email: string;
time_zone: string;
hour_format: string;
date_format: string;
is_admin: string;
call_duration: string;
other_event_duration: string;
}

View File

@@ -0,0 +1,191 @@
import {
AuthenticationType,
DedupeStrategy,
HttpMethod,
Polling,
httpClient,
pollingHelper,
} from '@activepieces/pieces-common';
import {
AppConnectionValueForAuthProperty,
createTrigger,
PiecePropValueSchema,
Property,
TriggerStrategy,
} from '@activepieces/pieces-framework';
import { vtigerAuth } from '../..';
import {
elementTypeProperty,
instanceLogin,
prepareHttpRequest,
} from '../common';
import dayjs from 'dayjs';
export const newOrUpdatedRecord = createTrigger({
auth: vtigerAuth,
name: 'new_or_updated_record',
displayName: 'New or Updated Record',
description:
'Triggers when a new record is introduced or a record is updated.',
props: {
elementType: elementTypeProperty,
watchBy: Property.StaticDropdown({
displayName: 'Watch By',
description: 'Column to watch for trigger',
required: true,
options: {
options: [
{ value: 'createdtime', label: 'Created Time' },
{ value: 'modifiedtime', label: 'Modified Time' },
],
},
}),
syncType: Property.StaticDropdown({
displayName: 'Sync Scope',
description: 'Records visibility scope for sync',
required: false,
options: {
options: [
{ value: 'application', label: 'Application (all records)' },
{ value: 'userandgroup', label: "User's groups" },
{ value: 'user', label: 'User only' },
],
},
defaultValue: 'application',
}),
limit: Property.Number({
displayName: 'Limit',
description: 'Enter the maximum number of records to return.',
defaultValue: 100,
required: true,
}),
},
sampleData: {
success: true,
result: [
{
id: '3x291',
createdtime: '2020-07-22 12:46:55',
modifiedtime: '2020-07-22 12:46:55',
},
],
},
type: TriggerStrategy.POLLING,
async test(ctx) {
return await pollingHelper.test(polling, { ...ctx });
},
async onEnable(ctx) {
await pollingHelper.onEnable(polling, { ...ctx });
},
async onDisable(ctx) {
await pollingHelper.onDisable(polling, { ...ctx });
},
async run(ctx) {
return await pollingHelper.poll(polling, { ...ctx });
},
});
const polling: Polling<
AppConnectionValueForAuthProperty<typeof vtigerAuth>,
{ elementType?: string; watchBy?: string; limit?: number; syncType?: string }
> = {
strategy: DedupeStrategy.TIMEBASED,
items: async ({ auth, propsValue, lastFetchEpochMS }) => {
const items = await fetchRecords({ auth, propsValue, lastFetchEpochMS });
return (items ?? []).map((item) => {
return {
epochMilliSeconds: dayjs(
propsValue.watchBy === 'createdtime'
? item['createdtime']
: item['modifiedtime']
).valueOf(),
data: item,
};
});
},
};
const fetchRecords = async ({
auth,
propsValue,
lastFetchEpochMS,
}: {
auth: AppConnectionValueForAuthProperty<typeof vtigerAuth>;
propsValue: Record<string, unknown>;
lastFetchEpochMS: number;
}) => {
const elementType = propsValue['elementType'] as string;
const limit = (propsValue['limit'] as number) ?? 100;
const syncType = (propsValue['syncType'] as string) ?? 'application';
const baseUrl = `${auth.props.instance_url}/restapi/v1/vtiger/default`;
// Vtiger expects UNIX timestamp (seconds)
let modifiedTimeSec = Math.floor((lastFetchEpochMS || 0) / 1000);
const updatedIds: string[] = [];
let more = true;
let safety = 0;
while (more && updatedIds.length < limit && safety < 10) {
const syncResp = await httpClient.sendRequest<{
success: boolean;
result: { updated: string[]; deleted: string[]; more: boolean; lastModifiedTime: number };
}>({
method: HttpMethod.GET,
url: `${baseUrl}/sync`,
authentication: {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
},
queryParams: {
modifiedTime: String(modifiedTimeSec),
elementType,
syncType,
},
});
if (!syncResp.body?.success) break;
const res = syncResp.body.result;
more = res.more === true;
modifiedTimeSec = res.lastModifiedTime || modifiedTimeSec;
for (const id of res.updated ?? []) {
if (updatedIds.length < limit) updatedIds.push(id);
}
safety++;
}
if (updatedIds.length === 0) return [];
const idsToFetch = updatedIds.slice(0, limit);
const results: Record<string, any>[] = [];
for (const id of idsToFetch) {
const retrieveResp = await httpClient.sendRequest<{
success: boolean;
result: Record<string, any>;
}>({
method: HttpMethod.GET,
url: `${baseUrl}/retrieve`,
authentication: {
type: AuthenticationType.BASIC,
username: auth.props.username,
password: auth.props.password,
},
queryParams: {
id,
},
});
if (retrieveResp.body?.success && retrieveResp.body.result) {
results.push(retrieveResp.body.result);
}
}
return results;
};