- 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>
636 lines
17 KiB
TypeScript
636 lines
17 KiB
TypeScript
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;
|
|
};
|