diff --git a/bun.lock b/bun.lock index 86323ec4477..7b604b1b8e6 100644 --- a/bun.lock +++ b/bun.lock @@ -5570,6 +5570,16 @@ "tslib": "2.6.2", }, }, + "packages/pieces/community/qawafel": { + "name": "@activepieces/piece-qawafel", + "version": "0.0.1", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + "@activepieces/shared": "workspace:*", + "tslib": "2.6.2", + }, + }, "packages/pieces/community/qdrant": { "name": "@activepieces/piece-qdrant", "version": "0.3.3", @@ -9348,6 +9358,8 @@ "@activepieces/piece-pylon": ["@activepieces/piece-pylon@workspace:packages/pieces/community/pylon"], + "@activepieces/piece-qawafel": ["@activepieces/piece-qawafel@workspace:packages/pieces/community/qawafel"], + "@activepieces/piece-qdrant": ["@activepieces/piece-qdrant@workspace:packages/pieces/community/qdrant"], "@activepieces/piece-qrcode": ["@activepieces/piece-qrcode@workspace:packages/pieces/core/qrcode"], diff --git a/packages/pieces/community/microsoft-outlook/package.json b/packages/pieces/community/microsoft-outlook/package.json index 3e3c8258a45..e02e78e2d18 100644 --- a/packages/pieces/community/microsoft-outlook/package.json +++ b/packages/pieces/community/microsoft-outlook/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-outlook", - "version": "0.3.2", + "version": "0.3.3", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/microsoft-outlook/src/index.ts b/packages/pieces/community/microsoft-outlook/src/index.ts index dffe158df42..bc458e0fcb4 100644 --- a/packages/pieces/community/microsoft-outlook/src/index.ts +++ b/packages/pieces/community/microsoft-outlook/src/index.ts @@ -22,9 +22,9 @@ export const microsoftOutlook = createPiece({ displayName: 'Microsoft Outlook', auth: microsoftOutlookAuth, minimumSupportedRelease: '0.82.0', - logoUrl: 'https://cdn.activepieces.com/pieces/microsoft-outlook.jpg', + logoUrl: 'https://cdn.activepieces.com/pieces/microsoft-outlook.png', categories: [PieceCategory.PRODUCTIVITY], - authors: ['lucaslimasouza', 'kishanprmr','sanket-a11y'], + authors: ['lucaslimasouza', 'kishanprmr', 'sanket-a11y'], actions: [ sendEmailAction, downloadAttachmentAction, diff --git a/packages/pieces/community/mistral-ai/package.json b/packages/pieces/community/mistral-ai/package.json index da40f07c737..50c10f47a38 100644 --- a/packages/pieces/community/mistral-ai/package.json +++ b/packages/pieces/community/mistral-ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-mistral-ai", - "version": "0.1.4", + "version": "0.2.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/mistral-ai/src/i18n/translation.json b/packages/pieces/community/mistral-ai/src/i18n/translation.json index ba1692fb899..064d5b0f7c3 100644 --- a/packages/pieces/community/mistral-ai/src/i18n/translation.json +++ b/packages/pieces/community/mistral-ai/src/i18n/translation.json @@ -1,9 +1,21 @@ { "Mistral AI provides state-of-the-art open-weight and hosted language models for text generation, embeddings, and reasoning tasks.": "Mistral AI provides state-of-the-art open-weight and hosted language models for text generation, embeddings, and reasoning tasks.", "You can obtain your API key from the Mistral AI dashboard. Go to https://console.mistral.ai, generate an API key, and paste it here.": "You can obtain your API key from the Mistral AI dashboard. Go to https://console.mistral.ai, generate an API key, and paste it here.", + "Cloudflare AI Gateway": "Cloudflare AI Gateway", + "Route Mistral calls through your Cloudflare AI Gateway. Provide a Mistral API key (key-in-request mode) or leave it blank if your gateway has Mistral configured as a stored key (BYOK).": "Route Mistral calls through your Cloudflare AI Gateway. Provide a Mistral API key (key-in-request mode) or leave it blank if your gateway has Mistral configured as a stored key (BYOK).", + "Cloudflare Account ID": "Cloudflare Account ID", + "Your Cloudflare account ID (visible on the AI Gateway dashboard).": "Your Cloudflare account ID (visible on the AI Gateway dashboard).", + "Gateway ID": "Gateway ID", + "The slug of your AI Gateway (visible on the gateway settings page).": "The slug of your AI Gateway (visible on the gateway settings page).", + "Gateway Auth Token": "Gateway Auth Token", + "Optional. Required only if your gateway has authentication enabled. Sent as cf-aig-authorization.": "Optional. Required only if your gateway has authentication enabled. Sent as cf-aig-authorization.", + "Mistral API Key": "Mistral API Key", + "Optional. Provide your Mistral key for key-in-request mode. Leave blank if Cloudflare injects the key via stored credentials (BYOK).": "Optional. Provide your Mistral key for key-in-request mode. Leave blank if Cloudflare injects the key via stored credentials (BYOK).", "Ask Mistral": "Ask Mistral", "Create Embeddings": "Create Embeddings", "Upload File": "Upload File", + "Run OCR": "Run OCR", + "Extract text from PDFs and images using mistral-ocr-latest. To OCR a file from a previous step, run Upload File first (with purpose=ocr) and pass the returned id here.": "Extract text from PDFs and images using mistral-ocr-latest. To OCR a file from a previous step, run Upload File first (with purpose=ocr) and pass the returned id here.", "List Models": "List Models", "Custom API Call": "Custom API Call", "Ask Mistral anything you want!": "Ask Mistral anything you want!", @@ -36,6 +48,24 @@ "The input text for which to create an embedding.": "The input text for which to create an embedding.", "The file to upload (max 512MB).For fine tuning purspose provide .jsonl file.": "The file to upload (max 512MB).For fine tuning purspose provide .jsonl file.", "Purpose of the file.": "Purpose of the file.", + "Document Source": "Document Source", + "Where the document or image to OCR comes from.": "Where the document or image to OCR comes from.", + "URL": "URL", + "Mistral file ID (uploaded earlier with purpose=ocr)": "Mistral file ID (uploaded earlier with purpose=ocr)", + "Document": "Document", + "File ID": "File ID", + "A Mistral file ID returned from a prior Upload File step (purpose=ocr).": "A Mistral file ID returned from a prior Upload File step (purpose=ocr).", + "Document Type": "Document Type", + "Pick \"Image\" for PNG/JPG/WEBP, \"Document\" for PDFs.": "Pick \"Image\" for PNG/JPG/WEBP, \"Document\" for PDFs.", + "Document (PDF)": "Document (PDF)", + "Image": "Image", + "Public URL of the PDF or image to OCR.": "Public URL of the PDF or image to OCR.", + "Pages": "Pages", + "Optional zero-based page indices to process. Leave empty for all pages.": "Optional zero-based page indices to process. Leave empty for all pages.", + "Include Image Base64": "Include Image Base64", + "Return embedded images encoded as base64 alongside the Markdown.": "Return embedded images encoded as base64 alongside the Markdown.", + "mistral-ocr-latest (Recommended)": "mistral-ocr-latest (Recommended)", + "mistral-ocr-2505": "mistral-ocr-2505", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", "fine-tune": "fine-tune", @@ -51,4 +81,4 @@ "JSON": "JSON", "Form Data": "Form Data", "Raw": "Raw" -} \ No newline at end of file +} diff --git a/packages/pieces/community/mistral-ai/src/index.ts b/packages/pieces/community/mistral-ai/src/index.ts index 0d7223dec2e..9095e075fce 100644 --- a/packages/pieces/community/mistral-ai/src/index.ts +++ b/packages/pieces/community/mistral-ai/src/index.ts @@ -3,8 +3,10 @@ import { PieceCategory } from "@activepieces/shared"; import { createChatCompletion } from "./lib/actions/create-chat-completion"; import { createEmbeddings } from "./lib/actions/create-embeddings"; import { uploadFile } from "./lib/actions/upload-file"; +import { runOcr } from "./lib/actions/run-ocr"; import { listModels } from "./lib/actions/list-models"; import { mistralAuth } from "./lib/common/auth"; +import { mistralRequest } from "./lib/common/request"; import { createCustomApiCallAction } from "@activepieces/pieces-common"; export const mistralAi = createPiece({ @@ -19,17 +21,13 @@ export const mistralAi = createPiece({ createChatCompletion, createEmbeddings, uploadFile, + runOcr, listModels, createCustomApiCallAction({ - auth:mistralAuth, - baseUrl:()=>'https://api.mistral.ai/v1', - authMapping:async (auth)=>{ - return{ - Authorization:`Bearer ${auth.secret_text}` - } - } - }) + auth: mistralAuth, + baseUrl: (auth) => (auth ? mistralRequest.getConfig(auth).baseUrl : 'https://api.mistral.ai/v1'), + authMapping: async (auth) => mistralRequest.getConfig(auth).headers, + }), ], triggers: [], }); - \ No newline at end of file diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts index 0726e908cc2..6f27332409a 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-chat-completion.ts @@ -1,7 +1,8 @@ import { createAction, Property } from '@activepieces/pieces-framework'; -import { HttpMethod, httpClient, AuthenticationType } from '@activepieces/pieces-common'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import { modelDropdown, parseMistralError } from '../common/props'; +import { mistralRequest } from '../common/request'; export const createChatCompletion = createAction({ auth: mistralAuth, @@ -34,6 +35,7 @@ export const createChatCompletion = createAction({ }, async run(context) { const { model, temperature, top_p, max_tokens, random_seed, timeout, prompt } = context.propsValue; + const { baseUrl, headers } = mistralRequest.getConfig(context.auth); const body: Record = { model, @@ -46,18 +48,15 @@ export const createChatCompletion = createAction({ temperature, top_p, max_tokens, - random_seed + random_seed, }; let lastErr; for (let attempt = 0; attempt <= 3; ++attempt) { try { - const response = await httpClient.sendRequest<{choices:{message:{content:string}}[]}>({ + const response = await httpClient.sendRequest<{ choices: { message: { content: string } }[] }>({ method: HttpMethod.POST, - url: 'https://api.mistral.ai/v1/chat/completions', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: context.auth.secret_text, - }, + url: `${baseUrl}/chat/completions`, + headers, body, timeout: timeout ?? 30000, }); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts b/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts index c4a2a6ce8f1..0798e5cc765 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/create-embeddings.ts @@ -1,7 +1,8 @@ import { createAction, Property } from '@activepieces/pieces-framework'; -import { HttpMethod, httpClient, AuthenticationType } from '@activepieces/pieces-common'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import { parseMistralError } from '../common/props'; +import { mistralRequest } from '../common/request'; export const createEmbeddings = createAction({ auth: mistralAuth, @@ -18,6 +19,7 @@ export const createEmbeddings = createAction({ }, async run(context) { const { input, timeout } = context.propsValue; + const { baseUrl, headers } = mistralRequest.getConfig(context.auth); let inputArr: string[] = []; try { if (typeof input === 'string') { @@ -40,11 +42,8 @@ export const createEmbeddings = createAction({ try { const response = await httpClient.sendRequest({ method: HttpMethod.POST, - url: 'https://api.mistral.ai/v1/embeddings', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: context.auth.secret_text, - }, + url: `${baseUrl}/embeddings`, + headers, body, timeout: timeout ?? 30000, }); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts b/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts index 36604556073..0a152861c83 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/list-models.ts @@ -1,28 +1,27 @@ import { createAction } from '@activepieces/pieces-framework'; -import { HttpMethod, httpClient, AuthenticationType } from '@activepieces/pieces-common'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import { parseMistralError } from '../common/props'; +import { mistralRequest } from '../common/request'; export const listModels = createAction({ - auth: mistralAuth, - name: 'list_models', + auth: mistralAuth, + name: 'list_models', displayName: 'List Models', description: 'Retrieves a list of available Mistral AI models.', - props: {}, - async run({ auth }) { - try { - const response = await httpClient.sendRequest({ - method: HttpMethod.GET, - url: 'https://api.mistral.ai/v1/models', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: auth.secret_text, - }, - }); - - return response.body; - } catch (e: any) { - throw new Error(parseMistralError(e)); - } - }, + props: {}, + async run({ auth }) { + try { + const { baseUrl, headers } = mistralRequest.getConfig(auth); + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${baseUrl}/models`, + headers, + }); + + return response.body; + } catch (e: any) { + throw new Error(parseMistralError(e)); + } + }, }); diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/run-ocr.ts b/packages/pieces/community/mistral-ai/src/lib/actions/run-ocr.ts new file mode 100644 index 00000000000..c5fc984fbf1 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/actions/run-ocr.ts @@ -0,0 +1,236 @@ +import { createAction, InputPropertyMap, PieceAuth, Property } from '@activepieces/pieces-framework'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { mistralAuth } from '../common/auth'; +import { parseMistralError } from '../common/props'; +import { mistralRequest, MistralRequestConfig } from '../common/request'; + +const OCR_MODEL_OPTIONS = [ + { label: 'mistral-ocr-latest (Recommended)', value: 'mistral-ocr-latest' }, + { label: 'mistral-ocr-2505', value: 'mistral-ocr-2505' }, +]; + +export const runOcr = createAction({ + auth: mistralAuth, + name: 'run_ocr', + displayName: 'Run OCR', + description: 'Extract text from PDFs and images using mistral-ocr-latest. To OCR a file from a previous step, run Upload File first (with purpose=ocr) and pass the returned id here.', + props: { + model: Property.StaticDropdown({ + displayName: 'Model', + required: true, + defaultValue: 'mistral-ocr-latest', + options: { options: OCR_MODEL_OPTIONS }, + }), + documentSource: Property.StaticDropdown({ + displayName: 'Document Source', + description: 'Where the document or image to OCR comes from.', + required: true, + defaultValue: 'url', + options: { + options: [ + { label: 'URL', value: 'url' }, + { label: 'Mistral file ID (uploaded earlier with purpose=ocr)', value: 'fileId' }, + ], + }, + }), + sourceFields: Property.DynamicProperties({ + displayName: 'Document', + required: true, + refreshers: ['documentSource'], + auth: PieceAuth.None(), + props: async (propsValue) => { + const source = propsValue['documentSource'] as unknown as DocumentSource; + if (source === 'fileId') { + const fields: InputPropertyMap = { + fileId: Property.ShortText({ + displayName: 'File ID', + description: 'A Mistral file ID returned from a prior Upload File step (purpose=ocr).', + required: true, + }), + documentType: documentTypeDropdown(), + }; + return fields; + } + const fields: InputPropertyMap = { + documentUrl: Property.ShortText({ + displayName: 'URL', + description: 'Public URL of the PDF or image to OCR.', + required: true, + }), + documentType: documentTypeDropdown(), + }; + return fields; + }, + }), + pages: Property.Array({ + displayName: 'Pages', + description: 'Optional zero-based page indices to process. Leave empty for all pages.', + required: false, + }), + includeImageBase64: Property.Checkbox({ + displayName: 'Include Image Base64', + description: 'Return embedded images encoded as base64 alongside the Markdown.', + required: false, + defaultValue: false, + }), + timeout: Property.Number({ + displayName: 'Timeout (ms)', + required: false, + defaultValue: 120000, + }), + }, + async run(context) { + const { model, documentSource, sourceFields, pages, includeImageBase64, timeout } = context.propsValue; + const config = mistralRequest.getConfig(context.auth); + + const document = await resolveDocument({ + source: documentSource as DocumentSource, + fields: sourceFields as DynamicSourceFields, + config, + timeout: timeout ?? 120000, + }); + + const body: Record = { + model, + document, + }; + const pageIndices = parsePageIndices(pages as unknown); + if (pageIndices) { + body['pages'] = pageIndices; + } + if (includeImageBase64) { + body['include_image_base64'] = true; + } + + let lastErr; + for (let attempt = 0; attempt <= 3; ++attempt) { + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${config.baseUrl}/ocr`, + headers: { + ...config.headers, + 'Content-Type': 'application/json', + }, + body, + timeout: timeout ?? 120000, + }); + const markdown = (response.body.pages ?? []).map((p) => p.markdown ?? '').join('\n\n'); + return { + ...response.body, + markdown, + }; + } catch (e: any) { + lastErr = e; + const status = e.response?.status; + if (status === 429 || (status && status >= 500 && status < 600)) { + if (attempt < 3) { + await new Promise((r) => setTimeout(r, 1000 * (attempt + 1))); + continue; + } + } + throw new Error(parseMistralError(e)); + } + } + throw new Error(parseMistralError(lastErr)); + }, +}); + +function documentTypeDropdown() { + return Property.StaticDropdown({ + displayName: 'Document Type', + description: 'Pick "Image" for PNG/JPG/WEBP, "Document" for PDFs.', + required: true, + defaultValue: 'document_url', + options: { + options: [ + { label: 'Document (PDF)', value: 'document_url' }, + { label: 'Image', value: 'image_url' }, + ], + }, + }); +} + +async function resolveDocument({ + source, + fields, + config, + timeout, +}: { + source: DocumentSource; + fields: DynamicSourceFields; + config: MistralRequestConfig; + timeout: number; +}): Promise { + const documentType = (fields['documentType'] as DocumentType | undefined) ?? 'document_url'; + if (source === 'fileId') { + const fileId = (fields['fileId'] as string | undefined)?.trim(); + if (!fileId) { + throw new Error('A Mistral file ID is required.'); + } + const signedUrl = await fetchSignedUrl({ fileId, config, timeout }); + return buildUrlDocument({ documentType, url: signedUrl }); + } + const documentUrl = (fields['documentUrl'] as string | undefined)?.trim(); + if (!documentUrl) { + throw new Error('A document URL is required.'); + } + return buildUrlDocument({ documentType, url: documentUrl }); +} + +function buildUrlDocument({ documentType, url }: { documentType: DocumentType; url: string }): OcrDocument { + if (documentType === 'image_url') { + return { type: 'image_url', image_url: url }; + } + return { type: 'document_url', document_url: url }; +} + +async function fetchSignedUrl({ fileId, config, timeout }: { fileId: string; config: MistralRequestConfig; timeout: number }): Promise { + const response = await httpClient.sendRequest<{ url: string }>({ + method: HttpMethod.GET, + url: `${config.baseUrl}/files/${encodeURIComponent(fileId)}/url?expiry=24`, + headers: config.headers, + timeout, + }); + if (!response.body.url) { + throw new Error(`Could not fetch a signed URL for file ${fileId}.`); + } + return response.body.url; +} + +function parsePageIndices(raw: unknown): number[] | null { + if (!Array.isArray(raw) || raw.length === 0) { + return null; + } + const indices = raw + .map((entry) => { + if (typeof entry === 'number') return entry; + const parsed = Number(String(entry).trim()); + return Number.isFinite(parsed) ? parsed : null; + }) + .filter((entry): entry is number => entry !== null && Number.isInteger(entry) && entry >= 0); + return indices.length > 0 ? indices : null; +} + +type DocumentSource = 'url' | 'fileId'; + +type DocumentType = 'document_url' | 'image_url'; + +type DynamicSourceFields = Record; + +type OcrDocument = + | { type: 'document_url'; document_url: string } + | { type: 'image_url'; image_url: string }; + +type OcrPage = { + index?: number; + markdown?: string; + images?: unknown[]; + dimensions?: Record; +}; + +type OcrResponse = { + pages?: OcrPage[]; + model?: string; + usage_info?: Record; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts b/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts index 3cbd79ac477..6052fec39ce 100644 --- a/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/mistral-ai/src/lib/actions/upload-file.ts @@ -1,8 +1,9 @@ import { createAction, Property } from '@activepieces/pieces-framework'; -import { HttpMethod, httpClient, AuthenticationType } from '@activepieces/pieces-common'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from '../common/auth'; import FormData from 'form-data'; import { parseMistralError } from '../common/props'; +import { mistralRequest } from '../common/request'; const SUPPORTED_PURPOSES = ['fine-tune', 'batch', 'ocr']; const MAX_FILE_SIZE_BYTES = 512 * 1024 * 1024; @@ -57,6 +58,7 @@ export const uploadFile = createAction({ if (!ALLOWED_EXTENSIONS.includes(ext)) throw new Error(`File extension .${ext} is not allowed`); + const { baseUrl, headers } = mistralRequest.getConfig(context.auth); const form = new FormData(); form.append('file', Buffer.from(file.data), file.filename); form.append('purpose', purpose); @@ -64,13 +66,10 @@ export const uploadFile = createAction({ try { const response = await httpClient.sendRequest({ method: HttpMethod.POST, - url: 'https://api.mistral.ai/v1/files', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: context.auth.secret_text, - }, - headers:{ - ...form.getHeaders() + url: `${baseUrl}/files`, + headers: { + ...headers, + ...form.getHeaders(), }, body: form, }); diff --git a/packages/pieces/community/mistral-ai/src/lib/common/auth.ts b/packages/pieces/community/mistral-ai/src/lib/common/auth.ts index 5fb6f560b4c..4170c60dbb1 100644 --- a/packages/pieces/community/mistral-ai/src/lib/common/auth.ts +++ b/packages/pieces/community/mistral-ai/src/lib/common/auth.ts @@ -1,7 +1,7 @@ -import { PieceAuth } from '@activepieces/pieces-framework'; -import { httpClient, HttpMethod, AuthenticationType } from '@activepieces/pieces-common'; +import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; -export const mistralAuth = PieceAuth.SecretText({ +const mistralDirectAuth = PieceAuth.SecretText({ displayName: 'API Key', description: `You can obtain your API key from the Mistral AI dashboard. Go to https://console.mistral.ai, generate an API key, and paste it here.`, required: true, @@ -10,9 +10,8 @@ export const mistralAuth = PieceAuth.SecretText({ await httpClient.sendRequest({ method: HttpMethod.GET, url: 'https://api.mistral.ai/v1/models', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: auth + headers: { + Authorization: `Bearer ${auth}`, }, }); return { valid: true }; @@ -30,3 +29,77 @@ export const mistralAuth = PieceAuth.SecretText({ } }, }); + +const mistralCloudflareGatewayAuth = PieceAuth.CustomAuth({ + displayName: 'Cloudflare AI Gateway', + description: + 'Route Mistral calls through your Cloudflare AI Gateway. Provide a Mistral API key (key-in-request mode) or leave it blank if your gateway has Mistral configured as a stored key (BYOK).', + required: true, + props: { + accountId: Property.ShortText({ + displayName: 'Cloudflare Account ID', + description: 'Your Cloudflare account ID (visible on the AI Gateway dashboard).', + required: true, + }), + gatewayId: Property.ShortText({ + displayName: 'Gateway ID', + description: 'The slug of your AI Gateway (visible on the gateway settings page).', + required: true, + }), + gatewayAuthToken: PieceAuth.SecretText({ + displayName: 'Gateway Auth Token', + description: 'Optional. Required only if your gateway has authentication enabled. Sent as cf-aig-authorization.', + required: false, + }), + mistralApiKey: PieceAuth.SecretText({ + displayName: 'Mistral API Key', + description: + 'Optional. Provide your Mistral key for key-in-request mode. Leave blank if Cloudflare injects the key via stored credentials (BYOK).', + required: false, + }), + }, + validate: async ({ auth }) => { + const { accountId, gatewayId, gatewayAuthToken, mistralApiKey } = auth as GatewayAuthProps; + + try { + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(accountId)}/${encodeURIComponent(gatewayId)}/mistral/v1/models`, + headers: buildGatewayHeaders({ gatewayAuthToken, mistralApiKey }), + }); + return { valid: true }; + } catch (e: any) { + const status = e.response?.status; + if (status === 401 || status === 403) { + return { valid: false, error: 'Authentication rejected by the gateway. Check your Mistral key and gateway auth token.' }; + } + if (status === 404) { + return { valid: false, error: 'Gateway not found. Check your account ID and gateway ID.' }; + } + if (status === 429) { + return { valid: false, error: 'Rate limit exceeded at the gateway. Please wait and try again.' }; + } + return { valid: false, error: 'Gateway validation failed: ' + (e.message || 'Unknown error') }; + } + }, +}); + +export const mistralAuth = [mistralDirectAuth, mistralCloudflareGatewayAuth]; + +function buildGatewayHeaders({ gatewayAuthToken, mistralApiKey }: { gatewayAuthToken?: string; mistralApiKey?: string }): Record { + const headers: Record = {}; + if (gatewayAuthToken) { + headers['cf-aig-authorization'] = `Bearer ${gatewayAuthToken}`; + } + if (mistralApiKey) { + headers['Authorization'] = `Bearer ${mistralApiKey}`; + } + return headers; +} + +type GatewayAuthProps = { + accountId: string; + gatewayId: string; + gatewayAuthToken?: string; + mistralApiKey?: string; +}; diff --git a/packages/pieces/community/mistral-ai/src/lib/common/props.ts b/packages/pieces/community/mistral-ai/src/lib/common/props.ts index a86186ee1ac..e49aa228a9d 100644 --- a/packages/pieces/community/mistral-ai/src/lib/common/props.ts +++ b/packages/pieces/community/mistral-ai/src/lib/common/props.ts @@ -1,6 +1,7 @@ import { Property, DropdownOption } from '@activepieces/pieces-framework'; -import { HttpMethod, httpClient, AuthenticationType } from '@activepieces/pieces-common'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { mistralAuth } from './auth'; +import { mistralRequest } from './request'; export const modelDropdown = Property.Dropdown({ displayName: 'Model', @@ -17,13 +18,11 @@ export const modelDropdown = Property.Dropdown({ }; } try { - const response = await httpClient.sendRequest<{data:{id:string,name:string}[]}>({ + const { baseUrl, headers } = mistralRequest.getConfig(auth); + const response = await httpClient.sendRequest<{ data: { id: string; name: string }[] }>({ method: HttpMethod.GET, - url: 'https://api.mistral.ai/v1/models', - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: auth.secret_text - }, + url: `${baseUrl}/models`, + headers, }); const models = response.body.data || []; const options: DropdownOption[] = models.map((model) => ({ @@ -56,4 +55,4 @@ export function parseMistralError(e: any): string { if (e.response?.data?.message) return e.response.data.message; if (e.message) return e.message; return 'Unknown error'; -} \ No newline at end of file +} diff --git a/packages/pieces/community/mistral-ai/src/lib/common/request.ts b/packages/pieces/community/mistral-ai/src/lib/common/request.ts new file mode 100644 index 00000000000..fb08660cce7 --- /dev/null +++ b/packages/pieces/community/mistral-ai/src/lib/common/request.ts @@ -0,0 +1,55 @@ +import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { AppConnectionType } from '@activepieces/shared'; +import type { mistralAuth } from './auth'; + +export type MistralAuthValue = AppConnectionValueForAuthProperty; + +function isGatewayAuth(auth: MistralAuth): auth is MistralGatewayAuth { + return auth.type === AppConnectionType.CUSTOM_AUTH; +} + +function getConfig(auth: MistralAuthValue): MistralRequestConfig { + const a = auth as MistralAuth; + if (isGatewayAuth(a)) { + const { accountId, gatewayId, gatewayAuthToken, mistralApiKey } = a.props; + const headers: Record = {}; + if (gatewayAuthToken) { + headers['cf-aig-authorization'] = `Bearer ${gatewayAuthToken}`; + } + if (mistralApiKey) { + headers['Authorization'] = `Bearer ${mistralApiKey}`; + } + return { + baseUrl: `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(accountId)}/${encodeURIComponent(gatewayId)}/mistral/v1`, + headers, + }; + } + return { + baseUrl: 'https://api.mistral.ai/v1', + headers: { Authorization: `Bearer ${a.secret_text}` }, + }; +} + +export const mistralRequest = { getConfig }; + +type MistralDirectAuth = { + type: AppConnectionType.SECRET_TEXT; + secret_text: string; +}; + +type MistralGatewayAuth = { + type: AppConnectionType.CUSTOM_AUTH; + props: { + accountId: string; + gatewayId: string; + gatewayAuthToken?: string; + mistralApiKey?: string; + }; +}; + +type MistralAuth = MistralDirectAuth | MistralGatewayAuth; + +export type MistralRequestConfig = { + baseUrl: string; + headers: Record; +}; diff --git a/packages/pieces/community/qawafel/.eslintrc.json b/packages/pieces/community/qawafel/.eslintrc.json new file mode 100644 index 00000000000..359ff63d51d --- /dev/null +++ b/packages/pieces/community/qawafel/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/qawafel/package.json b/packages/pieces/community/qawafel/package.json new file mode 100644 index 00000000000..09ec546a148 --- /dev/null +++ b/packages/pieces/community/qawafel/package.json @@ -0,0 +1,17 @@ +{ + "name": "@activepieces/piece-qawafel", + "version": "0.0.2", + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + "@activepieces/shared": "workspace:*", + "tslib": "2.6.2" + }, + "scripts": { + "build": "tsc -p tsconfig.lib.json && cp package.json dist/", + "lint": "eslint 'src/**/*.ts'" + } +} \ No newline at end of file diff --git a/packages/pieces/community/qawafel/src/index.ts b/packages/pieces/community/qawafel/src/index.ts new file mode 100644 index 00000000000..65163aaec56 --- /dev/null +++ b/packages/pieces/community/qawafel/src/index.ts @@ -0,0 +1,79 @@ +import { createPiece } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { PieceCategory } from '@activepieces/shared'; +import { + getQawafelBaseUrl, + PRODUCTION_API_BASE_URL, + qawafelAuth, +} from './lib/common/auth'; + +import { createProduct } from './lib/actions/create-product'; +import { updateProduct } from './lib/actions/update-product'; +import { getProduct } from './lib/actions/get-product'; +import { listProducts } from './lib/actions/list-products'; +import { createOrder } from './lib/actions/create-order'; +import { updateOrderStatus } from './lib/actions/update-order-status'; +import { cancelOrder } from './lib/actions/cancel-order'; +import { getOrder } from './lib/actions/get-order'; +import { listOrders } from './lib/actions/list-orders'; +import { createMerchant } from './lib/actions/create-merchant'; +import { updateMerchant } from './lib/actions/update-merchant'; +import { createInvoice } from './lib/actions/create-invoice'; +import { getInvoice } from './lib/actions/get-invoice'; +import { listInvoices } from './lib/actions/list-invoices'; + +import { newOrder } from './lib/triggers/new-order'; +import { orderStatusChanged } from './lib/triggers/order-status-changed'; +import { invoicePaid } from './lib/triggers/invoice-paid'; +import { newInvoice } from './lib/triggers/new-invoice'; +import { newProduct } from './lib/triggers/new-product'; +import { productUpdated } from './lib/triggers/product-updated'; +import { newMerchant } from './lib/triggers/new-merchant'; +import { refundRequested } from './lib/triggers/refund-requested'; + +export { qawafelAuth } from './lib/common/auth'; + +export const qawafel = createPiece({ + displayName: 'Qawafel', + description: + "B2B marketplace and ZATCA-compliant document platform — sync products, orders, merchants and invoices with Saudi Arabia's leading wholesale network.", + auth: qawafelAuth, + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/qawafel.jpg', + categories: [PieceCategory.COMMERCE], + authors: ['sanket-a11y'], + actions: [ + createProduct, + updateProduct, + getProduct, + listProducts, + createOrder, + updateOrderStatus, + cancelOrder, + getOrder, + listOrders, + createMerchant, + updateMerchant, + createInvoice, + getInvoice, + listInvoices, + createCustomApiCallAction({ + auth: qawafelAuth, + baseUrl: (auth) => + auth ? getQawafelBaseUrl(auth) : PRODUCTION_API_BASE_URL, + authMapping: async (auth) => ({ + 'x-qawafel-api-key': auth.props.apiKey, + }), + }), + ], + triggers: [ + newOrder, + orderStatusChanged, + invoicePaid, + newInvoice, + newProduct, + productUpdated, + newMerchant, + refundRequested, + ], +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/cancel-order.ts b/packages/pieces/community/qawafel/src/lib/actions/cancel-order.ts new file mode 100644 index 00000000000..31befd3f67f --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/cancel-order.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { qawafelProps } from '../common/props'; + +export const cancelOrder = createAction({ + auth: qawafelAuth, + name: 'cancel_order', + displayName: 'Cancel Order', + description: + 'Cancel an open order. Once cancelled, the order cannot be reopened — the only path forward is to create a new order.', + props: { + order_id: Property.ShortText({ + displayName: 'Order ID', + description: + 'The Qawafel order ID (starts with `ord_`) you want to cancel.', + required: true, + }), + reason: Property.LongText({ + displayName: 'Cancellation Reason', + description: + 'Optional but recommended. A short note explaining why the order is being cancelled (max 500 characters).', + required: false, + }), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const body: Record = {}; + if (propsValue.reason) { + body['reason'] = propsValue.reason; + } + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: `/orders/${propsValue.order_id}/cancel`, + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/create-invoice.ts b/packages/pieces/community/qawafel/src/lib/actions/create-invoice.ts new file mode 100644 index 00000000000..da4b7590632 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/create-invoice.ts @@ -0,0 +1,105 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { InvoiceLineItemInput, qawafelProps } from '../common/props'; + +export const createInvoice = createAction({ + auth: qawafelAuth, + name: 'create_invoice', + displayName: 'Create Invoice', + description: + 'Create an invoice in Qawafel for a customer. The invoice starts in `draft` state — use the **Generate Invoice** action (or call it from your dashboard) to finalize and issue a ZATCA-compliant copy.', + props: { + merchant_id: qawafelProps.merchantDropdown({ + displayName: 'Customer', + description: 'The customer being invoiced.', + required: true, + type: 'customer', + }), + issue_date: Property.DateTime({ + displayName: 'Issue Date', + description: + 'The date the invoice is issued. Use today for most cases. (YYY-MM-DD format)', + required: true, + }), + due_date: Property.DateTime({ + displayName: 'Due Date', + description: 'When payment is due. (YYY-MM-DD format)', + required: true, + }), + line_items: qawafelProps.invoiceLineItemsArray, + order_id: Property.ShortText({ + displayName: 'Linked Order ID', + description: + 'Optional. The Qawafel order ID (`ord_…`) this invoice is for. Linking helps track fulfilment-to-billing.', + required: false, + }), + shipping_fees: Property.ShortText({ + displayName: 'Shipping Fees (SAR)', + description: + 'Optional. Delivery / shipping fees as a decimal string excluding VAT, e.g. `25.00`. Defaults to `0.00`.', + required: false, + }), + payment_start_date: Property.DateTime({ + displayName: 'Payment Window Start', + description: 'Optional. When the payment terms begin. (YYY-MM-DD format)', + required: false, + }), + payment_due_date: Property.DateTime({ + displayName: 'Final Payment Due Date', + description: + 'Optional. The absolute final date payment is due (used for late-payment workflows). (YYY-MM-DD format)', + required: false, + }), + notes: Property.LongText({ + displayName: 'Notes', + description: 'Optional. Notes printed on the invoice or kept internal.', + required: false, + }), + external_ref: qawafelProps.externalRef('Your Reference ID'), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const lineItems = (propsValue.line_items ?? []) as InvoiceLineItemInput[]; + + const body: Record = { + merchant_id: propsValue.merchant_id, + issue_date: toDate(propsValue.issue_date), + due_date: toDate(propsValue.due_date), + line_items: lineItems, + }; + if (propsValue.order_id) { + body['order_id'] = propsValue.order_id; + } + if (propsValue.shipping_fees) { + body['shipping_fees'] = propsValue.shipping_fees; + } + if (propsValue.payment_start_date) { + body['payment_start_date'] = toDate(propsValue.payment_start_date); + } + if (propsValue.payment_due_date) { + body['payment_due_date'] = toDate(propsValue.payment_due_date); + } + if (propsValue.notes) { + body['notes'] = propsValue.notes; + } + if (propsValue.external_ref) { + body['external_ref'] = propsValue.external_ref; + } + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: '/invoices', + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); + +function toDate(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.slice(0, 10); +} diff --git a/packages/pieces/community/qawafel/src/lib/actions/create-merchant.ts b/packages/pieces/community/qawafel/src/lib/actions/create-merchant.ts new file mode 100644 index 00000000000..217a66d5fa4 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/create-merchant.ts @@ -0,0 +1,164 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { qawafelProps } from '../common/props'; + +export const createMerchant = createAction({ + auth: qawafelAuth, + name: 'create_merchant', + displayName: 'Create Merchant (Customer or Supplier)', + description: + 'Add a new customer or supplier to your Qawafel tenant. Use this when onboarding a new B2B partner from your CRM, signup form, or another system.', + props: { + type: Property.StaticDropdown<'customer' | 'supplier'>({ + displayName: 'Merchant Type', + description: + 'Pick **Customer** if this party buys from you, or **Supplier** if you buy from them.', + required: true, + defaultValue: 'customer', + options: { + disabled: false, + options: [ + { label: 'Customer (buys from you)', value: 'customer' }, + { label: 'Supplier (you buy from them)', value: 'supplier' }, + ], + }, + }), + legal_name: Property.ShortText({ + displayName: 'Legal Name', + description: + 'The official registered company name as it appears on the commercial registration.', + required: true, + }), + name_en: Property.ShortText({ + displayName: 'Trade Name (English)', + description: + 'The trade or store name in English. Shown on documents and dashboards.', + required: true, + }), + name_ar: Property.ShortText({ + displayName: 'Trade Name (Arabic)', + description: + 'The trade or store name in Arabic. Required by Qawafel for ZATCA-compliant invoices.', + required: true, + }), + email: Property.ShortText({ + displayName: 'Email', + description: 'Primary contact email for this merchant.', + required: true, + }), + phone: Property.ShortText({ + displayName: 'Phone', + description: + 'Primary phone number, including country code (e.g. `+966500000000`).', + required: true, + }), + cr_number: Property.ShortText({ + displayName: 'Commercial Registration (CR) Number', + description: '10-digit Saudi Commercial Registration number.', + required: true, + }), + vat_number: Property.ShortText({ + displayName: 'VAT Number', + description: + 'Optional. 15-digit VAT registration number, starts with `3`. Required if the merchant is VAT-registered.', + required: true, + }), + unified_national_number: Property.ShortText({ + displayName: 'Unified National Number', + description: + 'Optional. Saudi Unified National Number (الرقم الوطني الموحد), 10 digits starting with `7`.', + required: false, + }), + is_taxable: Property.Checkbox({ + displayName: 'VAT Registered', + description: + 'Tick if this merchant is registered for VAT. Affects how invoices to/from them are calculated.', + required: false, + defaultValue: true, + }), + address_line: Property.ShortText({ + displayName: 'Address — Street', + required: true, + }), + city: Property.ShortText({ + displayName: 'Address — City', + required: true, + }), + postal_code: Property.ShortText({ + displayName: 'Address — Postal Code', + description: 'Postal code (5 digits in Saudi Arabia).', + required: true, + }), + country: Property.ShortText({ + displayName: 'Address — Country', + description: + 'ISO 3166-1 alpha-2 country code, e.g. `SA` for Saudi Arabia.', + required: true, + defaultValue: 'SA', + }), + district: Property.ShortText({ + displayName: 'Address — District', + description: 'Optional.', + required: false, + }), + region: Property.ShortText({ + displayName: 'Address — Region', + description: 'Optional.', + required: false, + }), + short_address: Property.ShortText({ + displayName: 'Saudi Short Address', + description: + 'Optional. National Address short code (4 letters + 4 digits, e.g. `RHRA1234`).', + required: false, + }), + external_ref: qawafelProps.externalRef('Your Reference ID'), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const address: Record = { + address_line: propsValue.address_line, + city: propsValue.city, + postal_code: propsValue.postal_code, + country: propsValue.country, + }; + if (propsValue.district) { + address['district'] = propsValue.district; + } + if (propsValue.region) { + address['region'] = propsValue.region; + } + if (propsValue.short_address) { + address['short_address'] = propsValue.short_address; + } + + const body: Record = { + type: propsValue.type, + legal_name: propsValue.legal_name, + name_en: propsValue.name_en, + name_ar: propsValue.name_ar, + email: propsValue.email, + phone: propsValue.phone, + cr_number: propsValue.cr_number, + vat_number: propsValue.vat_number, + address, + }; + if (propsValue.unified_national_number) { + body['unified_national_number'] = propsValue.unified_national_number; + } + if (propsValue.is_taxable === false) { + body['is_taxable'] = false; + } + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: '/merchants', + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/create-order.ts b/packages/pieces/community/qawafel/src/lib/actions/create-order.ts new file mode 100644 index 00000000000..5eca70d34da --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/create-order.ts @@ -0,0 +1,153 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { OrderLineItemInput, qawafelProps } from '../common/props'; + +export const createOrder = createAction({ + auth: qawafelAuth, + name: 'create_order', + displayName: 'Create Completed Order', + description: + 'Sync a completed (fulfilled) order into Qawafel. Use this to import historical or externally-completed orders from another storefront, ERP, or B2B portal — the order is created directly in **Fulfilled** state and skips the normal fulfilment workflow. Do **not** use this for new orders that still need to be confirmed, picked, and delivered.', + props: { + merchant_id: qawafelProps.merchantDropdown({ + displayName: 'Customer', + description: + 'The customer placing the order. Pick from your existing customer merchants — use "Create Merchant" first if they are new.', + required: true, + type: 'customer', + }), + line_items: qawafelProps.orderLineItemsArray, + address_line: Property.ShortText({ + displayName: 'Delivery Address — Street', + description: 'Street address line for delivery.', + required: true, + }), + city: Property.ShortText({ + displayName: 'Delivery Address — City', + required: true, + }), + postal_code: Property.ShortText({ + displayName: 'Delivery Address — Postal Code', + description: 'Postal code (5 digits in Saudi Arabia).', + required: true, + }), + country: Property.ShortText({ + displayName: 'Delivery Address — Country', + description: + 'ISO 3166-1 alpha-2 country code, e.g. `SA` for Saudi Arabia, `AE` for the UAE.', + required: true, + defaultValue: 'SA', + }), + district: Property.ShortText({ + displayName: 'Delivery Address — District', + description: 'Optional. Neighborhood or district name.', + required: false, + }), + region: Property.ShortText({ + displayName: 'Delivery Address — Region', + description: 'Optional. Province or region name.', + required: false, + }), + short_address: Property.ShortText({ + displayName: 'Saudi Short Address', + description: + 'Optional. The Saudi National Address short code (4 uppercase letters + 4 digits, e.g. `RHRA1234`).', + required: false, + }), + delivery_option: Property.StaticDropdown<'vendor' | 'courier'>({ + displayName: 'Delivery Method', + description: + 'Pick **Vendor** if you (the seller) deliver yourself, or **Courier** to use a delivery service.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Vendor (you deliver)', value: 'vendor' }, + { label: 'Courier (third-party)', value: 'courier' }, + ], + }, + }), + delivery_fees: Property.ShortText({ + displayName: 'Delivery Fees (SAR)', + description: + 'Optional. Delivery fees as a decimal string, e.g. `15.00`. Required if you set a delivery method.', + required: false, + }), + delivery_fees_payer: Property.StaticDropdown<'customer' | 'vendor'>({ + displayName: 'Who Pays Delivery', + description: + 'Required if you set a delivery method. Pick **Customer** to bill the buyer, or **Vendor** to absorb the fee.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Customer pays', value: 'customer' }, + { label: 'Vendor pays', value: 'vendor' }, + ], + }, + }), + notes: Property.LongText({ + displayName: 'Notes', + description: 'Optional. Internal notes attached to the order.', + required: false, + }), + external_ref: qawafelProps.externalRef('Your Reference ID'), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const lineItems = (propsValue.line_items ?? []) as OrderLineItemInput[]; + + const address: Record = { + address_line: propsValue.address_line, + city: propsValue.city, + postal_code: propsValue.postal_code, + country: propsValue.country, + }; + if (propsValue.district) { + address['district'] = propsValue.district; + } + if (propsValue.region) { + address['region'] = propsValue.region; + } + if (propsValue.short_address) { + address['short_address'] = propsValue.short_address; + } + + const delivery: Record = {}; + if (propsValue.delivery_option) { + delivery['delivery_option'] = propsValue.delivery_option; + } + if (propsValue.delivery_fees) { + delivery['delivery_fees'] = propsValue.delivery_fees; + } + if (propsValue.delivery_fees_payer) { + delivery['delivery_fees_payer'] = propsValue.delivery_fees_payer; + } + + const body: Record = { + merchant_id: propsValue.merchant_id, + line_items: lineItems, + address, + }; + if (Object.keys(delivery).length > 0) { + body['delivery'] = delivery; + } + if (propsValue.notes) { + body['notes'] = propsValue.notes; + } + if (propsValue.external_ref) { + body['external_ref'] = propsValue.external_ref; + } + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: '/orders', + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/create-product.ts b/packages/pieces/community/qawafel/src/lib/actions/create-product.ts new file mode 100644 index 00000000000..aa7f947fecf --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/create-product.ts @@ -0,0 +1,136 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { qawafelProps } from '../common/props'; + +export const createProduct = createAction({ + auth: qawafelAuth, + name: 'create_product', + displayName: 'Create Product', + description: + 'Add a new product to your Qawafel catalog. The workhorse for catalog sync from your ERP or storefront into Qawafel.', + props: { + type: Property.StaticDropdown<'sale' | 'purchase'>({ + displayName: 'Product Type', + description: + 'Pick **Sale** if this is a product you sell. Pick **Purchase** if it is a product you buy from a supplier (you must also pick the supplier below).', + required: true, + defaultValue: 'sale', + options: { + disabled: false, + options: [ + { label: 'Sale (something you sell)', value: 'sale' }, + { + label: 'Purchase (something you buy from a supplier)', + value: 'purchase', + }, + ], + }, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: + 'Stock Keeping Unit — your unique product code. Must be unique within your Qawafel tenant.', + required: true, + }), + name_en: Property.ShortText({ + displayName: 'Name (English)', + description: 'Product name in English. Shown on invoices and quotations.', + required: true, + }), + name_ar: Property.ShortText({ + displayName: 'Name (Arabic)', + description: + 'Product name in Arabic. Required by Qawafel for ZATCA-compliant invoices.', + required: true, + }), + unit_price: Property.ShortText({ + displayName: 'Unit Price (SAR)', + description: + 'Default price per unit in Saudi Riyals as a decimal string with two places, e.g. `99.00` or `1234.56`.', + required: true, + }), + supplier_id: qawafelProps.merchantDropdown({ + displayName: 'Supplier', + description: + 'Required when **Product Type** is `Purchase`. Pick the supplier merchant this product is bought from. Leave blank for `Sale` products.', + required: false, + type: 'supplier', + }), + is_taxable: Property.Checkbox({ + displayName: 'VAT Applies', + description: + 'Tick this if 15% VAT applies to this product. Untick for VAT-exempt items.', + required: false, + defaultValue: true, + }), + description_en: Property.LongText({ + displayName: 'Description (English)', + description: 'Optional. Longer description shown on documents.', + required: false, + }), + description_ar: Property.LongText({ + displayName: 'Description (Arabic)', + description: 'Optional. Longer description in Arabic.', + required: false, + }), + barcode: Property.ShortText({ + displayName: 'Barcode', + description: 'Optional. UPC, EAN or GTIN barcode.', + required: false, + }), + + is_active: Property.Checkbox({ + displayName: 'Active', + description: + 'Active products show up in lookups and can be sold. Untick to soft-disable.', + required: false, + defaultValue: true, + }), + external_ref: qawafelProps.externalRef('Your Reference ID'), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + if (propsValue.type === 'purchase' && !propsValue.supplier_id) { + throw new Error('Supplier is required when Product Type is Purchase'); + } + + const body: Record = { + type: propsValue.type, + sku: propsValue.sku, + name_en: propsValue.name_en, + name_ar: propsValue.name_ar, + unit_price: propsValue.unit_price, + }; + + if (propsValue.supplier_id && propsValue.type === 'purchase') { + body['supplier_id'] = propsValue.supplier_id; + } + + if (propsValue.is_taxable === false) { + body['is_taxable'] = false; + } + + if (propsValue.description_en) { + body['description_en'] = propsValue.description_en; + } + if (propsValue.description_ar) { + body['description_ar'] = propsValue.description_ar; + } + if (propsValue.barcode) { + body['barcode'] = propsValue.barcode; + } + if (propsValue.is_active === false) { + body['is_active'] = false; + } + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: '/products', + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/get-invoice.ts b/packages/pieces/community/qawafel/src/lib/actions/get-invoice.ts new file mode 100644 index 00000000000..ef1e58888b5 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/get-invoice.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; + +export const getInvoice = createAction({ + auth: qawafelAuth, + name: 'get_invoice', + displayName: 'Get Invoice', + description: + 'Fetch a single invoice by its Qawafel ID. Returns the full invoice including line items, totals, ZATCA PDF URL, and current state.', + props: { + invoice_id: Property.ShortText({ + displayName: 'Invoice ID', + description: + 'The Qawafel invoice ID (starts with `inv_`). Find this in a webhook payload, the "List Invoices" action, or the dashboard.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await qawafelApiCall({ + auth, + method: HttpMethod.GET, + path: `/invoices/${propsValue.invoice_id}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/get-order.ts b/packages/pieces/community/qawafel/src/lib/actions/get-order.ts new file mode 100644 index 00000000000..877d4d8c618 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/get-order.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; + +export const getOrder = createAction({ + auth: qawafelAuth, + name: 'get_order', + displayName: 'Get Order', + description: + 'Fetch a single order by its Qawafel ID. Returns the full order including line items, totals, delivery details, and current state.', + props: { + order_id: Property.ShortText({ + displayName: 'Order ID', + description: + 'The Qawafel order ID (starts with `ord_`). You can get this from a trigger, the "List Orders" action, or your dashboard.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await qawafelApiCall({ + auth, + method: HttpMethod.GET, + path: `/orders/${propsValue.order_id}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/get-product.ts b/packages/pieces/community/qawafel/src/lib/actions/get-product.ts new file mode 100644 index 00000000000..405ac6cfbfe --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/get-product.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; + +export const getProduct = createAction({ + auth: qawafelAuth, + name: 'get_product', + displayName: 'Get Product', + description: + 'Fetch a single product by its Qawafel ID. Returns the full product including price, descriptions, and active status.', + props: { + product_id: Property.ShortText({ + displayName: 'Product ID', + description: + 'The Qawafel product ID (starts with `prod_`). You can find this on a product page in Qawafel, in the output of "List Products", or from a webhook trigger.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const response = await qawafelApiCall({ + auth, + method: HttpMethod.GET, + path: `/products/${propsValue.product_id}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/list-invoices.ts b/packages/pieces/community/qawafel/src/lib/actions/list-invoices.ts new file mode 100644 index 00000000000..c0e402f7862 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/list-invoices.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { qawafelAuth } from '../common/auth'; +import { qawafelPaginatedList } from '../common/client'; +import { qawafelProps } from '../common/props'; +import { URLSearchParams } from 'url'; + +const INVOICE_STATES: { label: string; value: string }[] = [ + { label: 'Draft', value: 'draft' }, + { label: 'Pushed (submitted to ZATCA)', value: 'pushed' }, + { label: 'Sent', value: 'sent' }, + { label: 'Paid', value: 'paid' }, + { label: 'Rejected', value: 'rejected' }, + { label: 'Void', value: 'void' }, +]; + +export const listInvoices = createAction({ + auth: qawafelAuth, + name: 'list_invoices', + displayName: 'List Invoices', + description: + 'Get invoices, optionally filtered by status, customer, or creation date. Returns up to 500 invoices (5 pages of 100).', + props: { + state: Property.StaticDropdown({ + displayName: 'Status (filter)', + description: + 'Optional. Return only invoices in this state. Leave blank for all statuses.', + required: false, + options: { + disabled: false, + options: INVOICE_STATES, + }, + }), + merchant_id: qawafelProps.merchantDropdown({ + displayName: 'Customer (filter)', + description: + 'Optional. Return only invoices for this customer. Leave blank for all customers.', + required: false, + type: 'customer', + }), + created_after: Property.DateTime({ + displayName: 'Created After', + description: + 'Optional. Return only invoices created after this date and time.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const queryParams = new URLSearchParams(); + if (propsValue.state) { + queryParams.append('state', propsValue.state); + } + if (propsValue.merchant_id) { + queryParams.append('merchant_id', propsValue.merchant_id); + } + if (propsValue.created_after) { + queryParams.append('created_after', propsValue.created_after); + } + + const data = await qawafelPaginatedList({ + auth, + path: `/invoices?${queryParams.toString()}`, + }); + return { count: data.length, data }; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/list-orders.ts b/packages/pieces/community/qawafel/src/lib/actions/list-orders.ts new file mode 100644 index 00000000000..2ea7cfeb4f9 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/list-orders.ts @@ -0,0 +1,74 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { qawafelAuth } from '../common/auth'; +import { qawafelPaginatedList } from '../common/client'; +import { qawafelProps } from '../common/props'; + +const ORDER_STATES: { label: string; value: string }[] = [ + { label: 'Pending vendor confirmation', value: 'pending_vendor_confirmation' }, + { label: 'Pending customer confirmation', value: 'pending_customer_confirmation' }, + { label: 'Confirmed', value: 'confirmed' }, + { label: 'Ready for pickup', value: 'ready_for_pickup' }, + { label: 'Picked up by courier', value: 'picked_up_by_courier' }, + { label: 'Received in warehouse', value: 'received_in_warehouse' }, + { label: 'Under fulfillment', value: 'under_fulfillment' }, + { label: 'Ready to dispatch', value: 'ready_to_dispatch' }, + { label: 'Out for delivery', value: 'out_for_delivery' }, + { label: 'Delivered', value: 'delivered' }, + { label: 'Not delivered', value: 'not_delivered' }, + { label: 'Fulfilled', value: 'fulfilled' }, + { label: 'Cancelled by vendor', value: 'cancelled_by_vendor' }, + { label: 'Cancelled by admin', value: 'cancelled_by_admin' }, + { label: 'Cancelled by customer', value: 'cancelled_by_customer' }, +]; + +export const listOrders = createAction({ + auth: qawafelAuth, + name: 'list_orders', + displayName: 'List Orders', + description: + 'Get orders, optionally filtered by status, customer, or creation date. Returns up to 500 orders (5 pages of 100).', + props: { + state: Property.StaticDropdown({ + displayName: 'Status (filter)', + description: + 'Optional. Return only orders in this state. Leave blank for all statuses.', + required: false, + options: { + disabled: false, + options: ORDER_STATES, + }, + }), + merchant_id: qawafelProps.merchantDropdown({ + displayName: 'Customer (filter)', + description: + 'Optional. Return only orders from this customer. Leave blank for all customers.', + required: false, + type: 'customer', + }), + created_after: Property.DateTime({ + displayName: 'Created After', + description: + 'Optional. Return only orders created after this date and time.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const queryParams=new URLSearchParams(); + + if (propsValue.state) { + queryParams.append('state', propsValue.state); + } + if (propsValue.merchant_id) { + queryParams.append('merchant_id', propsValue.merchant_id); + } + if (propsValue.created_after) { + queryParams.append('created_after', propsValue.created_after); + } + + const data = await qawafelPaginatedList({ + auth, + path: `/orders?${queryParams.toString()}`, + }); + return { count: data.length, data }; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/list-products.ts b/packages/pieces/community/qawafel/src/lib/actions/list-products.ts new file mode 100644 index 00000000000..aacc3d694c5 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/list-products.ts @@ -0,0 +1,75 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { qawafelAuth } from '../common/auth'; +import { qawafelPaginatedList } from '../common/client'; + +export const listProducts = createAction({ + auth: qawafelAuth, + name: 'list_products', + displayName: 'List Products', + description: + 'Get products from your catalog, with optional filters by type, supplier, SKU, and active status. Returns up to 500 products (5 pages of 100).', + props: { + type: Property.StaticDropdown<'sale' | 'purchase'>({ + displayName: 'Product Type (filter)', + description: + 'Optional. `Sale` returns products you sell. `Purchase` returns products you buy from suppliers. Leave blank to return both.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Sale', value: 'sale' }, + { label: 'Purchase', value: 'purchase' }, + ], + }, + }), + sku: Property.ShortText({ + displayName: 'SKU (filter)', + description: + 'Optional. Return only the product with this exact SKU (case-sensitive).', + required: false, + }), + supplier_id: Property.ShortText({ + displayName: 'Supplier ID (filter)', + description: + 'Optional. Only applies when filtering Purchase products. Pass a merchant ID (starts with `mer_`).', + required: false, + }), + is_active: Property.Checkbox({ + displayName: 'Active only', + description: + 'Tick to return only active products, untick for inactive only. Leave blank to return both.', + required: false, + }), + created_after: Property.DateTime({ + displayName: 'Created after', + description: + 'Optional. Return only products created after this date and time.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const queryParams=new URLSearchParams(); + + if (propsValue.type) { + queryParams.append('type', propsValue.type); + } + if (propsValue.sku) { + queryParams.append('sku', propsValue.sku); + } + if (propsValue.supplier_id) { + queryParams.append('supplier_id', propsValue.supplier_id); + } + if (propsValue.is_active !== undefined) { + queryParams.append('is_active', propsValue.is_active.toString()); + } + if (propsValue.created_after) { + queryParams.append('created_after', propsValue.created_after); + } + + const data = await qawafelPaginatedList({ + auth, + path: `/products?${queryParams.toString()}`, + }); + return { count: data.length, data }; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/update-merchant.ts b/packages/pieces/community/qawafel/src/lib/actions/update-merchant.ts new file mode 100644 index 00000000000..32b3c369064 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/update-merchant.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { qawafelProps } from '../common/props'; + +export const updateMerchant = createAction({ + auth: qawafelAuth, + name: 'update_merchant', + displayName: 'Update Merchant', + description: + 'Edit a merchant\'s trade name or active state. Only the fields you fill in are updated. (Legal name, CR, VAT and other identity fields cannot be changed via the API.)', + props: { + merchant_id: qawafelProps.merchantDropdown({ + displayName: 'Merchant to update', + description: + 'Pick the merchant to update. Use "Create Merchant" first if they are not yet in Qawafel.', + required: true, + }), + name_en: Property.ShortText({ + displayName: 'New Trade Name (English)', + description: 'Optional. Leave blank to keep the current name.', + required: false, + }), + name_ar: Property.ShortText({ + displayName: 'New Trade Name (Arabic)', + description: 'Optional. Leave blank to keep the current name.', + required: false, + }), + is_active: Property.Checkbox({ + displayName: 'Active', + description: + 'Optional. Tick to keep the merchant active, untick to disable. Leave blank to keep the current value.', + required: false, + }), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const body: Record = {}; + if (propsValue.name_en !== undefined) { + body['name_en'] = propsValue.name_en; + } + if (propsValue.name_ar !== undefined) { + body['name_ar'] = propsValue.name_ar; + } + if (propsValue.is_active !== undefined) { + body['is_active'] = propsValue.is_active; + } + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.PATCH, + path: `/merchants/${propsValue.merchant_id}`, + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/update-order-status.ts b/packages/pieces/community/qawafel/src/lib/actions/update-order-status.ts new file mode 100644 index 00000000000..50fed865874 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/update-order-status.ts @@ -0,0 +1,101 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth, QawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { + ALL_TRANSITIONS_FALLBACK, + qawafelProps, + TRANSITIONS_BY_STATE, +} from '../common/props'; + +export const updateOrderStatus = createAction({ + auth: qawafelAuth, + name: 'update_order_status', + displayName: 'Update Order Status', + description: + 'Move an order forward in the Qawafel fulfilment workflow. Qawafel enforces a strict state machine — for example, an order must be **Out for Delivery** before it can be marked **Delivered**. To cancel an order, use the **Cancel Order** action instead.', + props: { + order_id: Property.ShortText({ + displayName: 'Order ID', + description: + 'The Qawafel order ID (starts with `ord_`). Get it from a webhook trigger, "List Orders", or your dashboard. Once you fill this in, the dropdown below only shows transitions valid for this order\'s current state.', + required: true, + }), + transition: Property.Dropdown({ + auth: qawafelAuth, + displayName: 'New Status', + description: + "Pick the next status. Only states reachable from the order's current state appear here. Workflow: Pending → Confirmed → Ready for Pickup → Out for Delivery → Delivered → Fulfilled.", + required: true, + refreshers: ['order_id'], + options: async ({ auth, order_id }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Connect your Qawafel account first', + }; + } + const orderIdValue = order_id || ''; + if (!orderIdValue || orderIdValue) { + return { + disabled: false, + options: ALL_TRANSITIONS_FALLBACK, + placeholder: + "Order ID is dynamic — pick the transition manually. Qawafel will reject moves that are not valid from the order's current state.", + }; + } + try { + const response = await qawafelApiCall<{ state: string }>({ + auth: auth as QawafelAuth, + method: HttpMethod.GET, + path: `/orders/${orderIdValue}`, + }); + const currentState = response.body.state; + const validTransitions = TRANSITIONS_BY_STATE[currentState] ?? []; + if (validTransitions.length === 0) { + return { + disabled: true, + options: [], + placeholder: `Order is in state "${currentState}" — no forward transitions available. Use Cancel Order if needed.`, + }; + } + return { + disabled: false, + options: validTransitions, + }; + } catch { + return { + disabled: false, + options: ALL_TRANSITIONS_FALLBACK, + placeholder: + 'Could not load the order. Pick a transition manually — Qawafel will reject invalid moves.', + }; + } + }, + }), + reason: Property.LongText({ + displayName: 'Reason (only used when marking Not Delivered)', + description: + 'Required when **New Status** is "Mark Not Delivered" — explains why delivery failed (max 500 characters). Ignored for all other transitions.', + required: false, + }), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const transition = propsValue.transition; + const body = + transition === 'not-delivered' && propsValue.reason + ? { reason: propsValue.reason } + : undefined; + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.POST, + path: `/orders/${propsValue.order_id}/${transition}`, + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/actions/update-product.ts b/packages/pieces/community/qawafel/src/lib/actions/update-product.ts new file mode 100644 index 00000000000..10b2d38abda --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/actions/update-product.ts @@ -0,0 +1,70 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; +import { qawafelProps } from '../common/props'; + +export const updateProduct = createAction({ + auth: qawafelAuth, + name: 'update_product', + displayName: 'Update Product', + description: + "Edit a product's price, description, or active state. Only the fields you fill in are updated — leave the rest blank to keep them unchanged.", + props: { + product_id: qawafelProps.productDropdown({ + displayName: 'Product to update', + description: + 'Pick the product you want to change. Only the 100 most recent products are listed — use "List Products" if you need to find an older one.', + required: true, + }), + unit_price: Property.ShortText({ + displayName: 'New Unit Price (SAR)', + description: + 'Optional. New price per unit as a decimal string, e.g. `129.00`. Leave blank to keep the current price.', + required: false, + }), + description_en: Property.LongText({ + displayName: 'New Description (English)', + description: + 'Optional. New English description. Leave blank to keep the current one.', + required: false, + }), + description_ar: Property.LongText({ + displayName: 'New Description (Arabic)', + description: + 'Optional. New Arabic description. Leave blank to keep the current one.', + required: false, + }), + is_active: Property.Checkbox({ + displayName: 'Active', + description: + 'Optional. Tick to keep the product active, untick to soft-disable. Leave blank to keep the current value.', + required: false, + }), + idempotency_key: qawafelProps.idempotencyKey, + }, + async run({ auth, propsValue }) { + const body: Record = {}; + if (propsValue.unit_price !== undefined) { + body['unit_price'] = propsValue.unit_price; + } + if (propsValue.description_en !== undefined) { + body['description_en'] = propsValue.description_en; + } + if (propsValue.description_ar !== undefined) { + body['description_ar'] = propsValue.description_ar; + } + if (propsValue.is_active !== undefined) { + body['is_active'] = propsValue.is_active; + } + + const response = await qawafelApiCall({ + auth, + method: HttpMethod.PATCH, + path: `/products/${propsValue.product_id}`, + body, + idempotencyKey: propsValue.idempotency_key, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/common/auth.ts b/packages/pieces/community/qawafel/src/lib/common/auth.ts new file mode 100644 index 00000000000..65bdba382d4 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/common/auth.ts @@ -0,0 +1,80 @@ +import { + AppConnectionValueForAuthProperty, + PieceAuth, + Property, +} from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; + +const markdownDescription = ` +**How to get your Qawafel API Key:** + +1. Sign in to your [Qawafel dashboard](https://qawafel.sa). +2. Open **Settings → Developers → API Keys**. +3. Click **Create API Key**, give it a name (e.g. "Activepieces") and copy the key. +4. Paste the key below. Keep it secret — anyone with the key can read and write your Qawafel data. + +**Environment:** + +- **Production** — live Qawafel tenant at \`core.qawafel.sa\`. +- **Development** — sandbox tenant at \`core.development.qawafel.dev\` for testing without touching live data. + +API keys are scoped to a single environment. Pick the same one the key was created in. +`; + +export const qawafelAuth = PieceAuth.CustomAuth({ + description: markdownDescription, + required: true, + props: { + environment: Property.StaticDropdown({ + displayName: 'Environment', + description: 'Pick the Qawafel environment your API key belongs to.', + required: true, + defaultValue: 'production', + options: { + disabled: false, + options: [ + { label: 'Production', value: 'production' }, + { label: 'Development', value: 'development' }, + ], + }, + }), + apiKey: PieceAuth.SecretText({ + displayName: 'API Key', + description: 'Your Qawafel API key.', + required: true, + }), + }, + validate: async ({ auth }) => { + try { + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${resolveQawafelBaseUrl(auth.environment)}/tenant`, + headers: { + 'x-qawafel-api-key': auth.apiKey, + }, + }); + return { valid: true }; + } catch { + return { + valid: false, + error: + 'Could not authenticate. Double-check the API Key, and make sure you picked the same environment (Production or Development) the key was created in.', + }; + } + }, +}); + +export function getQawafelBaseUrl(auth: QawafelAuth): string { + return resolveQawafelBaseUrl(auth.props.environment); +} + +function resolveQawafelBaseUrl(environment: string | undefined): string { + return environment === 'development' + ? DEVELOPMENT_API_BASE_URL + : PRODUCTION_API_BASE_URL; +} + +export const PRODUCTION_API_BASE_URL = 'https://core.qawafel.sa/api/v1'; +export const DEVELOPMENT_API_BASE_URL = + 'https://core.development.qawafel.dev/api/v1'; +export type QawafelAuth = AppConnectionValueForAuthProperty; diff --git a/packages/pieces/community/qawafel/src/lib/common/client.ts b/packages/pieces/community/qawafel/src/lib/common/client.ts new file mode 100644 index 00000000000..6c5757a7e9a --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/common/client.ts @@ -0,0 +1,83 @@ +import { + httpClient, + HttpMethod, + HttpMessageBody, + HttpResponse, + QueryParams, +} from '@activepieces/pieces-common'; +import { getQawafelBaseUrl, QawafelAuth } from './auth'; + +export async function qawafelApiCall({ + auth, + method, + path, + body, + queryParams, + idempotencyKey, +}: { + auth: QawafelAuth; + method: HttpMethod; + path: string; + body?: unknown; + queryParams?: QueryParams; + idempotencyKey?: string; +}): Promise> { + const headers: Record = { + 'x-qawafel-api-key': auth.props.apiKey, + }; + if (idempotencyKey) { + headers['Idempotency-Key'] = idempotencyKey; + } + return await httpClient.sendRequest({ + method, + url: `${getQawafelBaseUrl(auth)}${path}`, + headers, + queryParams, + body, + }); +} + +export async function qawafelPaginatedList({ + auth, + path, + queryParams, + pageSize = 100, + maxPages = 5, +}: { + auth: QawafelAuth; + path: string; + queryParams?: QueryParams; + pageSize?: number; + maxPages?: number; +}): Promise { + const results: T[] = []; + let cursor: string | null = null; + let pages = 0; + while (pages < maxPages) { + const params: QueryParams = { + ...(queryParams ?? {}), + limit: String(pageSize), + }; + if (cursor) { + params['after'] = cursor; + } + const response = await qawafelApiCall>({ + auth, + method: HttpMethod.GET, + path, + queryParams: params, + }); + results.push(...response.body.data); + cursor = response.body.pagination?.next_cursor ?? null; + if (!cursor) break; + pages += 1; + } + return results; +} + +export type QawafelPaginatedResponse = { + data: T[]; + pagination: { + next_cursor: string | null; + }; +}; diff --git a/packages/pieces/community/qawafel/src/lib/common/props.ts b/packages/pieces/community/qawafel/src/lib/common/props.ts new file mode 100644 index 00000000000..d97995d2a68 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/common/props.ts @@ -0,0 +1,341 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import { qawafelAuth } from './auth'; +import { qawafelApiCall, QawafelPaginatedResponse } from './client'; + +export const qawafelProps = { + merchantDropdown: (params?: { + displayName?: string; + description?: string; + required?: boolean; + type?: 'customer' | 'supplier'; + }) => + Property.Dropdown({ + auth: qawafelAuth, + displayName: params?.displayName ?? 'Merchant', + description: + params?.description ?? + 'Pick a merchant. If you don\'t see who you need, use the "Create Merchant" action first.', + required: params?.required ?? true, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Connect your Qawafel account first', + }; + } + try { + const queryParams: Record = { limit: '100' }; + if (params?.type) queryParams['type'] = params.type; + const response = await qawafelApiCall< + QawafelPaginatedResponse + >({ + auth, + method: HttpMethod.GET, + path: '/merchants', + queryParams, + }); + if (response.body.data.length === 0) { + return { + disabled: false, + options: [], + placeholder: 'No merchants found yet — create one first.', + }; + } + return { + disabled: false, + options: response.body.data.map((m) => ({ + label: formatMerchantLabel(m), + value: m.id, + })), + }; + } catch { + return { + disabled: true, + options: [], + placeholder: 'Could not load merchants. Check your API key.', + }; + } + }, + }), + + productDropdown: (params?: { + displayName?: string; + description?: string; + required?: boolean; + type?: 'sale' | 'purchase'; + }) => + Property.Dropdown({ + auth: qawafelAuth, + displayName: params?.displayName ?? 'Product', + description: + params?.description ?? + 'Pick a product from your Qawafel catalog. Only the 100 most recent products are shown — use "Create Product" first if needed.', + required: params?.required ?? true, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Connect your Qawafel account first', + }; + } + try { + const queryParams: Record = { limit: '100' }; + if (params?.type) queryParams['type'] = params.type; + const response = await qawafelApiCall< + QawafelPaginatedResponse + >({ + auth, + method: HttpMethod.GET, + path: '/products', + queryParams, + }); + if (response.body.data.length === 0) { + return { + disabled: false, + options: [], + placeholder: 'No products found yet — create one first.', + }; + } + return { + disabled: false, + options: response.body.data.map((p) => ({ + label: formatProductLabel(p), + value: p.id, + })), + }; + } catch { + return { + disabled: true, + options: [], + placeholder: 'Could not load products. Check your API key.', + }; + } + }, + }), + + orderLineItemsArray: Property.Array({ + displayName: 'Line Items', + description: + 'One row per product on this order. Each row needs a product, quantity, and unit price.', + required: true, + properties: { + product_id: Property.ShortText({ + displayName: 'Product ID', + description: + 'Qawafel product ID (starts with `prod_`). Must be a `sale` type product. Use the "List Products" action to find IDs.', + required: true, + }), + quantity: Property.Number({ + displayName: 'Quantity', + description: 'How many units of this product. Whole numbers only.', + required: true, + defaultValue: 1, + }), + unit_price: Property.ShortText({ + displayName: 'Unit Price (SAR)', + description: + 'Price per single unit in Saudi Riyals. Use a decimal string with two places, e.g. `99.00` or `1234.56`.', + required: true, + }), + discount_percentage: Property.ShortText({ + displayName: 'Discount %', + description: + 'Optional. Per-line discount as a percentage, e.g. `10.00` for 10% off. Leave blank for no discount.', + required: false, + }), + external_ref: Property.ShortText({ + displayName: 'Your Reference (optional)', + description: + 'Optional. Your own ID for this line item (e.g. an order-line ID from your ERP).', + required: false, + }), + }, + }), + + invoiceLineItemsArray: Property.Array({ + displayName: 'Line Items', + description: + 'One row per product on this invoice. Each row needs a product, quantity, unit price, and VAT %.', + required: true, + properties: { + product_id: Property.ShortText({ + displayName: 'Product ID', + description: + 'Qawafel product ID (starts with `prod_`). Must be a `sale` type product.', + required: true, + }), + quantity: Property.Number({ + displayName: 'Quantity', + description: 'How many units. Whole numbers only.', + required: true, + defaultValue: 1, + }), + unit_price: Property.ShortText({ + displayName: 'Unit Price (SAR)', + description: + 'Price per unit in Saudi Riyals as a decimal string, e.g. `99.00`.', + required: true, + }), + vat_percentage: Property.ShortText({ + displayName: 'VAT %', + description: + 'VAT rate to apply, e.g. `15.00` for the standard Saudi VAT, or `0.00` for VAT-exempt items.', + required: true, + defaultValue: '15.00', + }), + discount_percentage: Property.ShortText({ + displayName: 'Discount %', + description: + 'Optional. Per-line discount as a percentage, e.g. `5.00` for 5% off.', + required: false, + }), + external_ref: Property.ShortText({ + displayName: 'Your Reference (optional)', + description: 'Optional. Your own ID for this line item.', + required: false, + }), + }, + }), + + addressGroup: Property.Object({ + displayName: 'Address', + description: + 'Postal address. The minimum required fields are street, city, and postal code (5 digits in Saudi Arabia). Use ISO codes for country (e.g. `SA` for Saudi Arabia).', + required: true, + defaultValue: {}, + }), + + idempotencyKey: Property.ShortText({ + displayName: 'Duplicate Protection Key', + description: + 'Advanced. A UUID v4 — if the flow retries this step, sending the same key prevents creating a duplicate. Most users can leave this blank.', + required: false, + }), + + externalRef: (displayName = 'Your Reference ID') => + Property.ShortText({ + displayName, + description: + 'Optional. Store an ID from another system here (e.g. your ERP record ID) to link records across systems. Must be unique within Qawafel.', + required: false, + }), +}; + +export const TRANSITIONS_BY_STATE: Record = { + pending_vendor_confirmation: [ + { + label: 'Confirm — vendor accepts the order (→ Confirmed)', + value: 'confirm', + }, + ], + confirmed: [ + { + label: 'Mark Ready for Pickup (→ Ready for Pickup)', + value: 'ready-for-pickup', + }, + ], + ready_for_pickup: [ + { + label: 'Mark Out for Delivery (→ Out for Delivery)', + value: 'out-for-delivery', + }, + ], + out_for_delivery: [ + { label: 'Mark Delivered (→ Delivered)', value: 'deliver' }, + { + label: 'Mark Not Delivered — failed delivery (→ Not Delivered)', + value: 'not-delivered', + }, + ], + delivered: [ + { + label: 'Mark Fulfilled — releases the payout (→ Fulfilled)', + value: 'fulfill', + }, + ], +}; + +export const ALL_TRANSITIONS_FALLBACK: OrderTransitionOption[] = [ + { + label: 'Confirm — only valid from Pending Vendor Confirmation', + value: 'confirm', + }, + { + label: 'Mark Ready for Pickup — only valid from Confirmed', + value: 'ready-for-pickup', + }, + { + label: 'Mark Out for Delivery — only valid from Ready for Pickup', + value: 'out-for-delivery', + }, + { + label: 'Mark Delivered — only valid from Out for Delivery', + value: 'deliver', + }, + { + label: 'Mark Not Delivered — only valid from Out for Delivery', + value: 'not-delivered', + }, + { + label: 'Mark Fulfilled — only valid from Delivered', + value: 'fulfill', + }, +]; + +function formatMerchantLabel(m: MerchantListItem): string { + const name = m.name_en || m.name_ar || m.legal_name || m.id; + const suffix = m.type ? ` (${m.type})` : ''; + return `${name}${suffix}`; +} + +function formatProductLabel(p: ProductListItem): string { + const name = p.name_en || p.name_ar || p.id; + return p.sku ? `${name} — ${p.sku}` : name; +} + +export type OrderLineItemInput = { + product_id: string; + quantity: number; + unit_price: string; + discount_percentage?: string; + external_ref?: string; +}; + +export type InvoiceLineItemInput = { + product_id: string; + quantity: number; + unit_price: string; + vat_percentage: string; + discount_percentage?: string; + external_ref?: string; +}; + +export type MerchantListItem = { + id: string; + legal_name?: string | null; + name_en?: string | null; + name_ar?: string | null; + type?: 'customer' | 'supplier'; +}; + +export type ProductListItem = { + id: string; + sku?: string | null; + name_en?: string | null; + name_ar?: string | null; +}; + +type OrderTransition = + | 'confirm' + | 'ready-for-pickup' + | 'out-for-delivery' + | 'deliver' + | 'not-delivered' + | 'fulfill'; + +type OrderTransitionOption = { label: string; value: OrderTransition }; diff --git a/packages/pieces/community/qawafel/src/lib/common/webhooks.ts b/packages/pieces/community/qawafel/src/lib/common/webhooks.ts new file mode 100644 index 00000000000..5e581934de4 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/common/webhooks.ts @@ -0,0 +1,94 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; +import { qawafelAuth } from './auth'; +import { qawafelApiCall } from './client'; + +const WEBHOOK_STORE_KEY_PREFIX = 'qawafel_webhook_'; + +export function createQawafelEventTrigger({ + name, + displayName, + description, + event, + sampleData, +}: { + name: string; + displayName: string; + description: string; + event: QawafelEventType; + sampleData: Record; +}) { + const webhookKey = `${WEBHOOK_STORE_KEY_PREFIX}${event}`; + return createTrigger({ + auth: qawafelAuth, + name, + displayName, + description, + type: TriggerStrategy.WEBHOOK, + props: {}, + sampleData: { + id: 'whd_01jk5jtv3x7f6ijkgdxawvcejr', + api_version: 'v1', + timestamp: 1705312200, + event, + data: sampleData, + }, + async onEnable(context) { + const response = await qawafelApiCall({ + auth: context.auth, + method: HttpMethod.POST, + path: '/webhooks', + body: { + url: context.webhookUrl, + event, + description: 'Activepieces webhook subscription', + }, + }); + await context.store.put(webhookKey, { + webhookId: response.body.id, + }); + }, + async onDisable(context) { + const stored = await context.store.get( + webhookKey + ); + if (!stored?.webhookId) return; + try { + await qawafelApiCall({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/webhooks/${stored.webhookId}`, + }); + } catch { + // Qawafel may have already disabled the webhook after delivery failures — ignore. + } + }, + async run(context) { + const payload = context.payload.body as any; + if (payload.event !== event) { + return []; + } + return [payload.data]; + }, + }); +} + +export type QawafelEventType = + | 'invoice.paid' + | 'invoice.generated' + | 'merchant.created' + | 'order.created' + | 'product.created' + | 'product.updated' + | 'credit_note.created'; + +type QawafelWebhookCreated = { + id: string; + url: string; + event: QawafelEventType; + secret: string; +}; + +type QawafelWebhookStoredHandle = { + webhookId: string; +}; diff --git a/packages/pieces/community/qawafel/src/lib/triggers/invoice-paid.ts b/packages/pieces/community/qawafel/src/lib/triggers/invoice-paid.ts new file mode 100644 index 00000000000..35ee0a7a682 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/invoice-paid.ts @@ -0,0 +1,38 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const invoicePaid = createQawafelEventTrigger({ + name: 'invoice_paid', + displayName: 'Invoice Paid', + description: + 'Fires when an invoice is marked as paid in Qawafel. Useful for posting payment receipts to Slack, releasing fulfilment, or syncing revenue to your books.', + event: 'invoice.paid', + sampleData: { + id: 'inv_01jk5jtv3x6e5hjkfcwzvubejq', + invoice_number: 'INV-000123', + merchant_id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + order_id: 'ord_01jk5jtv3x6e5hjkfcwzvubejq', + state: 'paid', + issue_date: '2026-04-25', + due_date: '2026-05-25', + payment_due_date: '2026-05-25', + line_items: [ + { + id: 'ili_01jk5jtv3x6e5hjkfcwzvubejq', + product_id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + quantity: 2, + unit_price: '99.00', + vat_percentage: '15.00', + total: '227.70', + }, + ], + totals: { + subtotal: '198.00', + vat_amount: '29.70', + total: '227.70', + }, + zatca_pdf_url: 'https://core.qawafel.sa/invoices/inv_xxx.pdf', + external_ref: null, + created_at: '2026-04-25T10:15:00Z', + updated_at: '2026-04-27T11:30:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/new-invoice.ts b/packages/pieces/community/qawafel/src/lib/triggers/new-invoice.ts new file mode 100644 index 00000000000..20417812dd7 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/new-invoice.ts @@ -0,0 +1,37 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const newInvoice = createQawafelEventTrigger({ + name: 'new_invoice', + displayName: 'New Invoice Generated', + description: + 'Fires when a ZATCA-compliant invoice is finalized in Qawafel (the `invoice.generated` event). Use it to email PDFs to customers, archive invoices to Drive, or push to your accounting system.', + event: 'invoice.generated', + sampleData: { + id: 'inv_01jk5jtv3x6e5hjkfcwzvubejq', + invoice_number: 'INV-000123', + merchant_id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + order_id: 'ord_01jk5jtv3x6e5hjkfcwzvubejq', + state: 'pushed', + issue_date: '2026-04-25', + due_date: '2026-05-25', + line_items: [ + { + id: 'ili_01jk5jtv3x6e5hjkfcwzvubejq', + product_id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + quantity: 2, + unit_price: '99.00', + vat_percentage: '15.00', + total: '227.70', + }, + ], + totals: { + subtotal: '198.00', + vat_amount: '29.70', + total: '227.70', + }, + zatca_pdf_url: 'https://core.qawafel.sa/invoices/inv_xxx.pdf', + external_ref: null, + created_at: '2026-04-25T10:15:00Z', + updated_at: '2026-04-25T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/new-merchant.ts b/packages/pieces/community/qawafel/src/lib/triggers/new-merchant.ts new file mode 100644 index 00000000000..af872b9ff59 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/new-merchant.ts @@ -0,0 +1,32 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const newMerchant = createQawafelEventTrigger({ + name: 'new_merchant', + displayName: 'New Merchant Registered', + description: + 'Fires when a new merchant (customer or supplier) is created in Qawafel. Use it to onboard customers in your CRM or add suppliers to your procurement system.', + event: 'merchant.created', + sampleData: { + id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + legal_name: 'Acme Trading Co.', + name_en: 'Acme', + name_ar: 'أكمي', + type: 'customer', + email: 'orders@acme.example', + phone: '+966500000000', + cr_number: '1010000001', + vat_number: '300000000000003', + unified_national_number: '7000000001', + is_taxable: true, + is_active: true, + address: { + address_line: 'King Fahd Road', + city: 'Riyadh', + country: 'SA', + postal_code: '12345', + }, + external_ref: null, + created_at: '2026-04-27T10:15:00Z', + updated_at: '2026-04-27T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/new-order.ts b/packages/pieces/community/qawafel/src/lib/triggers/new-order.ts new file mode 100644 index 00000000000..e43a173e006 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/new-order.ts @@ -0,0 +1,44 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const newOrder = createQawafelEventTrigger({ + name: 'new_order', + displayName: 'New Order', + description: + 'Fires the moment a new sales order is created in Qawafel. Use it to fan out to fulfillment, accounting, Slack alerts, or your CRM.', + event: 'order.created', + sampleData: { + id: 'ord_01jk5jtv3x6e5hjkfcwzvubejq', + order_number: 'ORD-000123', + merchant_id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + state: 'pending_vendor_confirmation', + line_items: [ + { + id: 'oli_01jk5jtv3x6e5hjkfcwzvubejq', + product_id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + quantity: 2, + unit_price: '99.00', + discount_amount: '0.00', + vat_amount: '29.70', + total: '227.70', + }, + ], + totals: { + subtotal: '198.00', + discount_amount: '0.00', + amount_excluding_vat: '198.00', + vat_amount: '29.70', + total: '227.70', + }, + delivery: { + delivery_option: 'courier', + delivery_fees: '15.00', + delivery_fees_payer: 'customer', + net_payable_amount: '242.70', + }, + notes: null, + external_ref: null, + quotation_id: null, + created_at: '2026-04-27T10:15:00Z', + updated_at: '2026-04-27T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/new-product.ts b/packages/pieces/community/qawafel/src/lib/triggers/new-product.ts new file mode 100644 index 00000000000..452a9cabc49 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/new-product.ts @@ -0,0 +1,26 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const newProduct = createQawafelEventTrigger({ + name: 'new_product', + displayName: 'New Product Published', + description: + 'Fires when a new product is created in Qawafel. Use it to mirror your catalog into Shopify, Salla, Zid, or your data warehouse.', + event: 'product.created', + sampleData: { + id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + sku: 'SKU-001', + name_en: 'Office Chair', + name_ar: 'كرسي مكتبي', + description_en: 'Ergonomic mesh chair', + description_ar: 'كرسي شبكي مريح', + type: 'sale', + unit_price: '450.00', + is_taxable: true, + is_active: true, + barcode: '5901234123457', + supplier_id: null, + external_ref: null, + created_at: '2026-04-27T10:15:00Z', + updated_at: '2026-04-27T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/order-status-changed.ts b/packages/pieces/community/qawafel/src/lib/triggers/order-status-changed.ts new file mode 100644 index 00000000000..2951c3089e0 --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/order-status-changed.ts @@ -0,0 +1,105 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { + Property, + TriggerStrategy, + createTrigger, +} from '@activepieces/pieces-framework'; +import { qawafelAuth } from '../common/auth'; +import { qawafelApiCall } from '../common/client'; + +const ORDER_STATUS_EVENTS: { label: string; value: OrderStatusEvent }[] = [ + { label: 'Confirmed (vendor accepted the order)', value: 'order.confirmed' }, + { label: 'Ready for Pickup', value: 'order.ready_for_pickup' }, + { label: 'Out for Delivery', value: 'order.out_for_delivery' }, + { label: 'Delivered', value: 'order.delivered' }, + { label: 'Not Delivered (failed delivery)', value: 'order.not_delivered' }, + { + label: 'Fulfilled (final state, payout released)', + value: 'order.fulfilled', + }, + { label: 'Cancelled', value: 'order.cancelled' }, +]; + +export const orderStatusChanged = createTrigger({ + auth: qawafelAuth, + name: 'order_status_changed', + displayName: 'Order Status Changed', + description: + 'Fires when an order moves to a specific status (confirmed, out for delivery, delivered, fulfilled, cancelled, etc.). Pick one status per trigger — add the trigger again for additional statuses.', + type: TriggerStrategy.WEBHOOK, + props: { + status: Property.StaticDropdown({ + displayName: 'Status to listen for', + description: + 'The flow runs every time an order transitions into the status you pick here.', + required: true, + options: { + disabled: false, + options: ORDER_STATUS_EVENTS, + }, + }), + }, + sampleData: { + id: 'whd_01jk5jtv3x7f6ijkgdxawvcejr', + api_version: 'v1', + timestamp: 1705312200, + event: 'order.delivered', + data: { + id: 'ord_01jk5jtv3x6e5hjkfcwzvubejq', + order_number: 'ORD-000123', + merchant_id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + state: 'delivered', + external_ref: null, + created_at: '2026-04-27T10:15:00Z', + updated_at: '2026-04-27T11:30:00Z', + }, + }, + async onEnable(context) { + const event = context.propsValue.status; + const response = await qawafelApiCall<{ id: string }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/webhooks', + body: { + url: context.webhookUrl, + event, + description: `Activepieces — order status ${event}`, + }, + }); + await context.store.put( + `qawafel_order_status_webhook_id_${context.propsValue.status}`, + response.body.id + ); + }, + async onDisable(context) { + const webhookId = await context.store.get( + `qawafel_order_status_webhook_id_${context.propsValue.status}` + ); + if (!webhookId) return; + try { + await qawafelApiCall({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/webhooks/${webhookId}`, + }); + } catch { + // Webhook may already be disabled — ignore. + } + }, + async run(context) { + const payloadEvent = context.payload.body as { id: string; event: string }; + if (payloadEvent.event !== context.propsValue.status) { + return []; + } + return [payloadEvent]; + }, +}); + +type OrderStatusEvent = + | 'order.confirmed' + | 'order.ready_for_pickup' + | 'order.out_for_delivery' + | 'order.delivered' + | 'order.not_delivered' + | 'order.fulfilled' + | 'order.cancelled'; diff --git a/packages/pieces/community/qawafel/src/lib/triggers/product-updated.ts b/packages/pieces/community/qawafel/src/lib/triggers/product-updated.ts new file mode 100644 index 00000000000..4d330a34f8b --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/product-updated.ts @@ -0,0 +1,24 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const productUpdated = createQawafelEventTrigger({ + name: 'product_updated', + displayName: 'Product Updated', + description: + 'Fires when a product is updated (price change, description edit, activated/deactivated). Use it to keep external storefronts in sync.', + event: 'product.updated', + sampleData: { + id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + sku: 'SKU-001', + name_en: 'Office Chair', + name_ar: 'كرسي مكتبي', + type: 'sale', + unit_price: '475.00', + is_taxable: true, + is_active: true, + barcode: '5901234123457', + supplier_id: null, + external_ref: null, + created_at: '2026-04-20T10:15:00Z', + updated_at: '2026-04-27T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/src/lib/triggers/refund-requested.ts b/packages/pieces/community/qawafel/src/lib/triggers/refund-requested.ts new file mode 100644 index 00000000000..08e684acd2d --- /dev/null +++ b/packages/pieces/community/qawafel/src/lib/triggers/refund-requested.ts @@ -0,0 +1,36 @@ +import { createQawafelEventTrigger } from '../common/webhooks'; + +export const refundRequested = createQawafelEventTrigger({ + name: 'refund_requested', + displayName: 'Refund / Credit Note Created', + description: + 'Fires when a credit note is created against an invoice (i.e. a customer return or refund is initiated). Use it to alert your finance team or trigger a return workflow.', + event: 'credit_note.created', + sampleData: { + id: 'cn_01jk5jtv3x6e5hjkfcwzvubejq', + credit_note_number: 'CN-000123', + invoice_id: 'inv_01jk5jtv3x6e5hjkfcwzvubejq', + merchant_id: 'mer_01jk5jtv3x2zbdroz75n3eczi4', + state: 'draft', + issue_date: '2026-04-27', + line_items: [ + { + id: 'cnli_01jk5jtv3x6e5hjkfcwzvubejq', + product_id: 'prod_01jk5jtv3x6e5hjkfcwzvubejq', + quantity: 1, + unit_price: '99.00', + vat_percentage: '15.00', + total: '113.85', + }, + ], + totals: { + subtotal: '99.00', + vat_amount: '14.85', + total: '113.85', + }, + reason: 'Customer return', + external_ref: null, + created_at: '2026-04-27T10:15:00Z', + updated_at: '2026-04-27T10:15:00Z', + }, +}); diff --git a/packages/pieces/community/qawafel/tsconfig.json b/packages/pieces/community/qawafel/tsconfig.json new file mode 100644 index 00000000000..b512ca3708c --- /dev/null +++ b/packages/pieces/community/qawafel/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/qawafel/tsconfig.lib.json b/packages/pieces/community/qawafel/tsconfig.lib.json new file mode 100644 index 00000000000..8b4345e5114 --- /dev/null +++ b/packages/pieces/community/qawafel/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts" + ] +} \ No newline at end of file diff --git a/packages/pieces/community/service-now/package.json b/packages/pieces/community/service-now/package.json index 08d983f4537..d872a5ac9d4 100644 --- a/packages/pieces/community/service-now/package.json +++ b/packages/pieces/community/service-now/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-service-now", - "version": "0.1.3", + "version": "0.2.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/service-now/src/i18n/translation.json b/packages/pieces/community/service-now/src/i18n/translation.json index 81966b7f07e..1380d2f7b32 100644 --- a/packages/pieces/community/service-now/src/i18n/translation.json +++ b/packages/pieces/community/service-now/src/i18n/translation.json @@ -105,5 +105,91 @@ "Triggers when a new record is created in a table": "Triggers when a new record is created in a table", "Triggers when a record is updated in a table": "Triggers when a record is updated in a table", "Filter Query": "Filter Query", - "Encoded query to filter records (e.g., priority=1^state=1)": "Encoded query to filter records (e.g., priority=1^state=1)" + "Encoded query to filter records (e.g., priority=1^state=1)": "Encoded query to filter records (e.g., priority=1^state=1)", + "Delete Record": "Delete Record", + "Delete a record from a specified table": "Delete a record from a specified table", + "Add Comment or Work Note": "Add Comment or Work Note", + "Append a customer-visible comment or an internal work note to a record (incident, request, problem, change, etc.)": "Append a customer-visible comment or an internal work note to a record (incident, request, problem, change, etc.)", + "Type": "Type", + "Customer-visible comment or internal work note": "Customer-visible comment or internal work note", + "Customer-visible Comment": "Customer-visible Comment", + "Internal Work Note": "Internal Work Note", + "Comment": "Comment", + "The text to append to the record": "The text to append to the record", + "Delete Attachment": "Delete Attachment", + "Delete an attachment by its sys_id": "Delete an attachment by its sys_id", + "The sys_id of the attachment to delete (use Find File to look it up)": "The sys_id of the attachment to delete (use Find File to look it up)", + "Submit Catalog Request": "Submit Catalog Request", + "Order a catalog item. Submits a service catalog request and returns the resulting request number.": "Order a catalog item. Submits a service catalog request and returns the resulting request number.", + "Catalog Item": "Catalog Item", + "Select a catalog item to order": "Select a catalog item to order", + "Or Enter Catalog Item Sys ID Manually": "Or Enter Catalog Item Sys ID Manually", + "Provide the catalog item sys_id directly if it is not in the dropdown": "Provide the catalog item sys_id directly if it is not in the dropdown", + "Quantity": "Quantity", + "How many of this item to order": "How many of this item to order", + "Requested For (User Sys ID)": "Requested For (User Sys ID)", + "sys_id of the user the request is for. Defaults to the authenticated user.": "sys_id of the user the request is for. Defaults to the authenticated user.", + "Variables": "Variables", + "Catalog item variables as a JSON object (variable_name → value)": "Catalog item variables as a JSON object (variable_name → value)", + "Count Records": "Count Records", + "Return the number of records in a table that match an optional encoded query (uses the Aggregate API)": "Return the number of records in a table that match an optional encoded query (uses the Aggregate API)", + "Optional encoded query to filter records (e.g., active=true^state=1)": "Optional encoded query to filter records (e.g., active=true^state=1)", + "New Comment or Work Note": "New Comment or Work Note", + "Triggers when a new comment or work note is added to a record in the selected table": "Triggers when a new comment or work note is added to a record in the selected table", + "Entry Type": "Entry Type", + "Which journal entries to watch": "Which journal entries to watch", + "Comments and Work Notes": "Comments and Work Notes", + "Customer-visible Comments only": "Customer-visible Comments only", + "Internal Work Notes only": "Internal Work Notes only", + "Record Sys ID Filter (optional)": "Record Sys ID Filter (optional)", + "If set, only fire for comments on this specific record sys_id": "If set, only fire for comments on this specific record sys_id", + "Search Knowledge Articles": "Search Knowledge Articles", + "Search published knowledge base articles using free text. Requires the Knowledge API plugin to be active.": "Search published knowledge base articles using free text. Requires the Knowledge API plugin to be active.", + "Search Text": "Search Text", + "Free-text search query": "Free-text search query", + "Knowledge Base sys_id": "Knowledge Base sys_id", + "Optional. Limit search to a specific knowledge base.": "Optional. Limit search to a specific knowledge base.", + "Language": "Language", + "Optional ISO language code (e.g., en, de, fr)": "Optional ISO language code (e.g., en, de, fr)", + "Maximum number of articles to return (1-100)": "Maximum number of articles to return (1-100)", + "Offset": "Offset", + "Number of articles to skip for pagination": "Number of articles to skip for pagination", + "Fields": "Fields", + "Optional list of article field names to include in the response": "Optional list of article field names to include in the response", + "Get Knowledge Article": "Get Knowledge Article", + "Retrieve the full content of a knowledge article by sys_id (or article number)": "Retrieve the full content of a knowledge article by sys_id (or article number)", + "Article Sys ID": "Article Sys ID", + "sys_id of the article. Use \"Search Knowledge Articles\" first if you only have a KB number.": "sys_id of the article. Use \"Search Knowledge Articles\" first if you only have a KB number.", + "Increment View Count": "Increment View Count", + "Whether to increment the article view counter when fetching": "Whether to increment the article view counter when fetching", + "Send Email": "Send Email", + "Send an email through your ServiceNow instance using the Email API. Requires the email outbound capability to be configured.": "Send an email through your ServiceNow instance using the Email API. Requires the email outbound capability to be configured.", + "To": "To", + "One or more recipient email addresses": "One or more recipient email addresses", + "Subject": "Subject", + "Email subject line": "Email subject line", + "Body": "Body", + "Email body. HTML is supported by ServiceNow.": "Email body. HTML is supported by ServiceNow.", + "From": "From", + "Optional sender address. Defaults to the instance email-from setting.": "Optional sender address. Defaults to the instance email-from setting.", + "CC": "CC", + "Optional CC recipients": "Optional CC recipients", + "BCC": "BCC", + "Optional BCC recipients": "Optional BCC recipients", + "Resolve or Close Incident": "Resolve or Close Incident", + "Move an incident to Resolved or Closed with a close code and resolution notes": "Move an incident to Resolved or Closed with a close code and resolution notes", + "Incident sys_id": "Incident sys_id", + "sys_id of the incident to resolve or close": "sys_id of the incident to resolve or close", + "Resolution": "Resolution", + "Resolved keeps the record open for closure later; Closed finalizes it.": "Resolved keeps the record open for closure later; Closed finalizes it.", + "Resolved": "Resolved", + "Closed": "Closed", + "Close Code": "Close Code", + "Close code (e.g., \"Solved (Permanently)\", \"Solved Remotely\", \"Not Solved (Not Reproducible)\")": "Close code (e.g., \"Solved (Permanently)\", \"Solved Remotely\", \"Not Solved (Not Reproducible)\")", + "Resolution Notes": "Resolution Notes", + "Resolution notes shown to the caller": "Resolution notes shown to the caller", + "Resolved By (User sys_id)": "Resolved By (User sys_id)", + "Optional sys_id of the user who resolved the incident. Defaults to the authenticated user.": "Optional sys_id of the user who resolved the incident. Defaults to the authenticated user.", + "Get Catalog Item": "Get Catalog Item", + "Retrieve full details of a catalog item including its variable definitions. Use this before \"Submit Catalog Request\" to discover the variables expected by the item.": "Retrieve full details of a catalog item including its variable definitions. Use this before \"Submit Catalog Request\" to discover the variables expected by the item." } \ No newline at end of file diff --git a/packages/pieces/community/service-now/src/index.ts b/packages/pieces/community/service-now/src/index.ts index 1d2f0c1662f..9ec7221a028 100644 --- a/packages/pieces/community/service-now/src/index.ts +++ b/packages/pieces/community/service-now/src/index.ts @@ -1,34 +1,66 @@ - -import { createPiece } from "@activepieces/pieces-framework"; +import { createPiece } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { PieceCategory } from '@activepieces/shared'; import { servicenowAuth } from './lib/common/props'; import { createRecordAction } from './lib/actions/create-record'; import { updateRecordAction } from './lib/actions/update-record'; import { getRecordAction } from './lib/actions/get-record'; import { findRecordAction } from './lib/actions/find-record'; +import { deleteRecordAction } from './lib/actions/delete-record'; import { attachFileToRecordAction } from './lib/actions/attach-file-to-record'; import { findFileAction } from './lib/actions/find-file'; +import { deleteAttachmentAction } from './lib/actions/delete-attachment'; +import { addCommentAction } from './lib/actions/add-comment'; +import { resolveIncidentAction } from './lib/actions/resolve-incident'; +import { submitCatalogItemAction } from './lib/actions/submit-catalog-item'; +import { getCatalogItemAction } from './lib/actions/get-catalog-item'; +import { searchKnowledgeArticlesAction } from './lib/actions/search-knowledge-articles'; +import { getKnowledgeArticleAction } from './lib/actions/get-knowledge-article'; +import { sendEmailAction } from './lib/actions/send-email'; +import { countRecordsAction } from './lib/actions/count-records'; import { newRecordTrigger } from './lib/triggers/new-record'; import { updatedRecordTrigger } from './lib/triggers/updated-record'; +import { newCommentTrigger } from './lib/triggers/new-comment'; export const serviceNow = createPiece({ - displayName: "ServiceNow", - description: "Enterprise IT service management platform for incident, change, and service request management", + displayName: 'ServiceNow', + description: + 'Enterprise IT service management platform for incident, change, and service request management', auth: servicenowAuth, minimumSupportedRelease: '0.36.1', - logoUrl: "https://cdn.activepieces.com/pieces/service-now.png", - authors: ["sparkybug"], + logoUrl: 'https://cdn.activepieces.com/pieces/service-now.png', + authors: ['sparkybug'], categories: [PieceCategory.PRODUCTIVITY], actions: [ createRecordAction, updateRecordAction, getRecordAction, findRecordAction, + deleteRecordAction, + addCommentAction, + resolveIncidentAction, attachFileToRecordAction, findFileAction, + deleteAttachmentAction, + getCatalogItemAction, + submitCatalogItemAction, + searchKnowledgeArticlesAction, + getKnowledgeArticleAction, + sendEmailAction, + countRecordsAction, + createCustomApiCallAction({ + auth: servicenowAuth, + baseUrl: (auth) => + auth ? auth.props.instanceUrl.replace(/\/$/, '') : '', + authMapping: async (auth) => { + const credentials = Buffer.from( + `${auth.props.username}:${auth.props.password}` + ).toString('base64'); + return { + Authorization: `Basic ${credentials}`, + }; + }, + }), ], - triggers: [ - newRecordTrigger, - updatedRecordTrigger, - ], -}); \ No newline at end of file + triggers: [newRecordTrigger, updatedRecordTrigger, newCommentTrigger], +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/add-comment.ts b/packages/pieces/community/service-now/src/lib/actions/add-comment.ts new file mode 100644 index 00000000000..1aa8598c390 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/add-comment.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ServiceNowRecordSchema } from '../common/types'; +import { + tableDropdown, + recordDropdown, + createServiceNowClient, + servicenowAuth, + resolveSysId, +} from '../common/props'; +import { JOURNAL_ELEMENT } from '../common/journal'; + +export const addCommentAction = createAction({ + auth: servicenowAuth, + name: 'add_comment', + displayName: 'Add Comment or Work Note', + description: + 'Append a customer-visible comment or an internal work note to a record (incident, request, problem, change, etc.)', + props: { + table: tableDropdown, + record: recordDropdown, + manual_sys_id: Property.ShortText({ + displayName: 'Or Enter Sys ID Manually', + description: 'Enter the sys_id directly if not found in dropdown', + required: false, + }), + comment_type: Property.StaticDropdown({ + displayName: 'Type', + description: 'Customer-visible comment or internal work note', + required: true, + defaultValue: JOURNAL_ELEMENT.COMMENTS, + options: { + disabled: false, + options: [ + { label: 'Customer-visible Comment', value: JOURNAL_ELEMENT.COMMENTS }, + { label: 'Internal Work Note', value: JOURNAL_ELEMENT.WORK_NOTES }, + ], + }, + }), + comment: Property.LongText({ + displayName: 'Comment', + description: 'The text to append to the record', + required: true, + }), + }, + async run(context) { + const { table, record, manual_sys_id, comment_type, comment } = + context.propsValue; + const sysId = resolveSysId({ selected: record, manual: manual_sys_id }); + + const client = createServiceNowClient(context.auth); + const result = await client.updateRecord(table, sysId, { + [comment_type]: comment, + }); + + return ServiceNowRecordSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/count-records.ts b/packages/pieces/community/service-now/src/lib/actions/count-records.ts new file mode 100644 index 00000000000..a16e8c7fba0 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/count-records.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + tableDropdown, + createServiceNowClient, + servicenowAuth, +} from '../common/props'; + +export const countRecordsAction = createAction({ + auth: servicenowAuth, + name: 'count_records', + displayName: 'Count Records', + description: + 'Return the number of records in a table that match an optional encoded query (uses the Aggregate API)', + props: { + table: tableDropdown, + query: Property.LongText({ + displayName: 'Query', + description: + 'Optional encoded query to filter records (e.g., active=true^state=1)', + required: false, + }), + }, + async run(context) { + const { table, query } = context.propsValue; + const client = createServiceNowClient(context.auth); + + const count = await client.countRecords({ table, query }); + + return { + table, + query: query ?? null, + count, + }; + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/delete-attachment.ts b/packages/pieces/community/service-now/src/lib/actions/delete-attachment.ts new file mode 100644 index 00000000000..414bfc1202f --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/delete-attachment.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { createServiceNowClient, servicenowAuth } from '../common/props'; + +export const deleteAttachmentAction = createAction({ + auth: servicenowAuth, + name: 'delete_attachment', + displayName: 'Delete Attachment', + description: 'Delete an attachment by its sys_id', + props: { + attachment_sys_id: Property.ShortText({ + displayName: 'Attachment Sys ID', + description: + 'The sys_id of the attachment to delete (use Find File to look it up)', + required: true, + }), + }, + async run(context) { + const { attachment_sys_id } = context.propsValue; + const client = createServiceNowClient(context.auth); + + await client.deleteAttachment({ attachment_sys_id }); + + return { + success: true, + attachment_sys_id, + }; + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/delete-record.ts b/packages/pieces/community/service-now/src/lib/actions/delete-record.ts new file mode 100644 index 00000000000..42cba4499d7 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/delete-record.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + tableDropdown, + recordDropdown, + createServiceNowClient, + servicenowAuth, + resolveSysId, +} from '../common/props'; + +export const deleteRecordAction = createAction({ + auth: servicenowAuth, + name: 'delete_record', + displayName: 'Delete Record', + description: 'Delete a record from a specified table', + props: { + table: tableDropdown, + record: recordDropdown, + manual_sys_id: Property.ShortText({ + displayName: 'Or Enter Sys ID Manually', + description: 'Enter the sys_id directly if not found in dropdown', + required: false, + }), + }, + async run(context) { + const { table, record, manual_sys_id } = context.propsValue; + const sysId = resolveSysId({ selected: record, manual: manual_sys_id }); + + const client = createServiceNowClient(context.auth); + await client.deleteRecord(table, sysId); + + return { + success: true, + table, + sys_id: sysId, + }; + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/get-catalog-item.ts b/packages/pieces/community/service-now/src/lib/actions/get-catalog-item.ts new file mode 100644 index 00000000000..5783f8a7b97 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/get-catalog-item.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { CatalogItemSchema } from '../common/types'; +import { + catalogItemDropdown, + createServiceNowClient, + servicenowAuth, + resolveSysId, +} from '../common/props'; + +export const getCatalogItemAction = createAction({ + auth: servicenowAuth, + name: 'get_catalog_item', + displayName: 'Get Catalog Item', + description: + 'Retrieve full details of a catalog item including its variable definitions. Use this before "Submit Catalog Request" to discover the variables expected by the item.', + props: { + item_sys_id: catalogItemDropdown, + manual_item_sys_id: Property.ShortText({ + displayName: 'Or Enter Catalog Item Sys ID Manually', + description: + 'Provide the catalog item sys_id directly if it is not in the dropdown', + required: false, + }), + }, + async run(context) { + const { item_sys_id, manual_item_sys_id } = context.propsValue; + const sysId = resolveSysId({ + selected: item_sys_id, + manual: manual_item_sys_id, + label: 'catalog item', + }); + + const client = createServiceNowClient(context.auth); + const result = await client.getCatalogItem({ item_sys_id: sysId }); + + return CatalogItemSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/get-knowledge-article.ts b/packages/pieces/community/service-now/src/lib/actions/get-knowledge-article.ts new file mode 100644 index 00000000000..64f0d9363cd --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/get-knowledge-article.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { KnowledgeArticleSchema } from '../common/types'; +import { createServiceNowClient, servicenowAuth } from '../common/props'; + +export const getKnowledgeArticleAction = createAction({ + auth: servicenowAuth, + name: 'get_knowledge_article', + displayName: 'Get Knowledge Article', + description: + 'Retrieve the full content of a knowledge article by sys_id', + props: { + article_sys_id: Property.ShortText({ + displayName: 'Article Sys ID', + description: + 'sys_id of the article. Use "Search Knowledge Articles" first if you only have a KB number.', + required: true, + }), + update_view: Property.Checkbox({ + displayName: 'Increment View Count', + description: + 'Whether to increment the article view counter when fetching', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { article_sys_id, update_view } = context.propsValue; + const client = createServiceNowClient(context.auth); + + const result = await client.getKnowledgeArticle({ + article_sys_id, + update_view, + }); + + return KnowledgeArticleSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/resolve-incident.ts b/packages/pieces/community/service-now/src/lib/actions/resolve-incident.ts new file mode 100644 index 00000000000..6a8fa181f30 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/resolve-incident.ts @@ -0,0 +1,77 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ServiceNowRecordSchema } from '../common/types'; +import { createServiceNowClient, servicenowAuth } from '../common/props'; + +export const resolveIncidentAction = createAction({ + auth: servicenowAuth, + name: 'resolve_incident', + displayName: 'Resolve or Close Incident', + description: + 'Move an incident to Resolved or Closed with a close code and resolution notes', + props: { + incident_sys_id: Property.ShortText({ + displayName: 'Incident sys_id', + description: 'sys_id of the incident to resolve or close', + required: true, + }), + resolution: Property.StaticDropdown({ + displayName: 'Resolution', + description: + 'Resolved keeps the record open for closure later; Closed finalizes it.', + required: true, + defaultValue: 'resolved', + options: { + disabled: false, + options: [ + { label: 'Resolved', value: 'resolved' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }), + close_code: Property.ShortText({ + displayName: 'Close Code', + description: + 'Close code (e.g., "Solved (Permanently)", "Solved Remotely", "Not Solved (Not Reproducible)")', + required: true, + }), + close_notes: Property.LongText({ + displayName: 'Resolution Notes', + description: 'Resolution notes shown to the caller', + required: true, + }), + resolved_by: Property.ShortText({ + displayName: 'Resolved By (User sys_id)', + description: + 'Optional sys_id of the user who resolved the incident. Defaults to the authenticated user.', + required: false, + }), + }, + async run(context) { + const { incident_sys_id, resolution, close_code, close_notes, resolved_by } = + context.propsValue; + + const fields: Record = { + state: + resolution === 'closed' ? INCIDENT_STATE.CLOSED : INCIDENT_STATE.RESOLVED, + close_code, + close_notes, + }; + if (resolved_by) { + fields['resolved_by'] = resolved_by; + } + + const client = createServiceNowClient(context.auth); + const result = await client.updateRecord( + 'incident', + incident_sys_id, + fields + ); + + return ServiceNowRecordSchema.parse(result); + }, +}); + +const INCIDENT_STATE = { + RESOLVED: '6', + CLOSED: '7', +} as const; diff --git a/packages/pieces/community/service-now/src/lib/actions/search-knowledge-articles.ts b/packages/pieces/community/service-now/src/lib/actions/search-knowledge-articles.ts new file mode 100644 index 00000000000..055c41b1e9a --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/search-knowledge-articles.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { z } from 'zod'; +import { KnowledgeSearchResultSchema } from '../common/types'; +import { createServiceNowClient, servicenowAuth } from '../common/props'; + +const LimitSchema = z.number().int().min(1).max(100).default(20); +const OffsetSchema = z.number().int().min(0).default(0); +const FieldsSchema = z.array(z.string()).optional(); + +export const searchKnowledgeArticlesAction = createAction({ + auth: servicenowAuth, + name: 'search_knowledge_articles', + displayName: 'Search Knowledge Articles', + description: + 'Search published knowledge base articles using free text. Requires the Knowledge API plugin to be active.', + props: { + query: Property.ShortText({ + displayName: 'Search Text', + description: 'Free-text search query', + required: false, + }), + kb: Property.ShortText({ + displayName: 'Knowledge Base sys_id', + description: 'Optional. Limit search to a specific knowledge base.', + required: false, + }), + language: Property.ShortText({ + displayName: 'Language', + description: 'Optional ISO language code (e.g., en, de, fr)', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of articles to return (1-100)', + required: false, + defaultValue: 20, + }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of articles to skip for pagination', + required: false, + defaultValue: 0, + }), + fields: Property.Array({ + displayName: 'Fields', + description: + 'Optional list of article field names to include in the response', + required: false, + }), + }, + async run(context) { + const { query, kb, language, limit, offset, fields } = context.propsValue; + + const client = createServiceNowClient(context.auth); + const result = await client.searchKnowledgeArticles({ + query, + kb, + language, + limit: LimitSchema.parse(limit ?? 20), + offset: OffsetSchema.parse(offset ?? 0), + fields: FieldsSchema.parse(fields), + }); + + return KnowledgeSearchResultSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/send-email.ts b/packages/pieces/community/service-now/src/lib/actions/send-email.ts new file mode 100644 index 00000000000..97b89bf236f --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/send-email.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { z } from 'zod'; +import { EmailSendResultSchema } from '../common/types'; +import { createServiceNowClient, servicenowAuth } from '../common/props'; + +const RecipientsSchema = z.array(z.string().email()).min(1); +const OptionalRecipientsSchema = z.array(z.string().email()).optional(); +const FromSchema = z.string().email().optional(); + +export const sendEmailAction = createAction({ + auth: servicenowAuth, + name: 'send_email', + displayName: 'Send Email', + description: + 'Send an email through your ServiceNow instance using the Email API. Requires the email outbound capability to be configured.', + props: { + to: Property.Array({ + displayName: 'To', + description: 'One or more recipient email addresses', + required: true, + }), + subject: Property.ShortText({ + displayName: 'Subject', + description: 'Email subject line', + required: true, + }), + body: Property.LongText({ + displayName: 'Body', + description: 'Email body. HTML is supported by ServiceNow.', + required: true, + }), + from: Property.ShortText({ + displayName: 'From', + description: + 'Optional sender address. Defaults to the instance email-from setting.', + required: false, + }), + cc: Property.Array({ + displayName: 'CC', + description: 'Optional CC recipients', + required: false, + }), + bcc: Property.Array({ + displayName: 'BCC', + description: 'Optional BCC recipients', + required: false, + }), + }, + async run(context) { + const { to, subject, body, from, cc, bcc } = context.propsValue; + + const client = createServiceNowClient(context.auth); + const result = await client.sendEmail({ + to: RecipientsSchema.parse(to), + subject, + body, + from: FromSchema.parse(from), + cc: OptionalRecipientsSchema.parse(cc), + bcc: OptionalRecipientsSchema.parse(bcc), + }); + + return EmailSendResultSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/actions/submit-catalog-item.ts b/packages/pieces/community/service-now/src/lib/actions/submit-catalog-item.ts new file mode 100644 index 00000000000..3aea936468d --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/actions/submit-catalog-item.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { z } from 'zod'; +import { CatalogOrderResultSchema } from '../common/types'; +import { + catalogItemDropdown, + createServiceNowClient, + servicenowAuth, + resolveSysId, +} from '../common/props'; + +const VariablesSchema = z.record(z.string(), z.unknown()).optional(); +const QuantitySchema = z.number().int().min(1).max(100).default(1); + +export const submitCatalogItemAction = createAction({ + auth: servicenowAuth, + name: 'submit_catalog_item', + displayName: 'Submit Catalog Request', + description: + 'Order a catalog item. Submits a service catalog request and returns the resulting request number.', + props: { + item_sys_id: catalogItemDropdown, + manual_item_sys_id: Property.ShortText({ + displayName: 'Or Enter Catalog Item Sys ID Manually', + description: + 'Provide the catalog item sys_id directly if it is not in the dropdown', + required: false, + }), + quantity: Property.Number({ + displayName: 'Quantity', + description: 'How many of this item to order', + required: false, + defaultValue: 1, + }), + requested_for: Property.ShortText({ + displayName: 'Requested For (User Sys ID)', + description: + 'sys_id of the user the request is for. Defaults to the authenticated user.', + required: false, + }), + variables: Property.Object({ + displayName: 'Variables', + description: + 'Catalog item variables as a JSON object (variable_name → value)', + required: false, + }), + }, + async run(context) { + const { item_sys_id, manual_item_sys_id, quantity, requested_for, variables } = + context.propsValue; + + const sysId = resolveSysId({ + selected: item_sys_id, + manual: manual_item_sys_id, + label: 'catalog item', + }); + const parsedQuantity = QuantitySchema.parse(quantity ?? 1); + const parsedVariables = VariablesSchema.parse(variables); + + const client = createServiceNowClient(context.auth); + const result = await client.orderCatalogItem({ + item_sys_id: sysId, + quantity: parsedQuantity, + variables: parsedVariables, + requested_for, + }); + + return CatalogOrderResultSchema.parse(result); + }, +}); diff --git a/packages/pieces/community/service-now/src/lib/common/client.ts b/packages/pieces/community/service-now/src/lib/common/client.ts index 404b32fdd2b..96d24ca8909 100644 --- a/packages/pieces/community/service-now/src/lib/common/client.ts +++ b/packages/pieces/community/service-now/src/lib/common/client.ts @@ -9,6 +9,12 @@ import { NotSupported, ServiceNowClientOptions, TriggerEvent, + CatalogItem, + CatalogOrderResult, + JournalEntry, + KnowledgeArticle, + KnowledgeSearchResult, + EmailSendResult, } from './types'; export class ServiceNowClient { @@ -527,4 +533,291 @@ export class ServiceNowClient { return []; } } + + async deleteAttachment({ + attachment_sys_id, + }: { + attachment_sys_id: string; + }): Promise { + await this.makeRequest( + HttpMethod.DELETE, + `/api/now/attachment/${attachment_sys_id}` + ); + } + + async countRecords({ + table, + query, + }: { + table: string; + query?: string; + }): Promise { + const endpoint = `/api/now/stats/${table}`; + const queryParams: Record = { + sysparm_count: 'true', + }; + if (query) { + queryParams['sysparm_query'] = query; + } + + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + queryParams, + timeout: 30000, + retries: 3, + }); + + const data = response.body as { + result?: { + stats?: { count?: string | number }; + }; + }; + + const rawCount = data?.result?.stats?.count ?? 0; + return typeof rawCount === 'number' ? rawCount : parseInt(String(rawCount), 10) || 0; + } + + async listCatalogItems({ + limit = 50, + query, + }: { + limit?: number; + query?: string; + } = {}): Promise { + const endpoint = `/api/sn_sc/servicecatalog/items`; + const queryParams: Record = { + sysparm_limit: limit.toString(), + }; + if (query) { + queryParams['sysparm_text'] = query; + } + + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + queryParams, + timeout: 30000, + retries: 3, + }); + + const data = response.body as { result: CatalogItem[] }; + return data.result || []; + } catch { + return []; + } + } + + async orderCatalogItem({ + item_sys_id, + quantity = 1, + variables, + requested_for, + }: { + item_sys_id: string; + quantity?: number; + variables?: Record; + requested_for?: string; + }): Promise { + const endpoint = `/api/sn_sc/servicecatalog/items/${item_sys_id}/order_now`; + const body: Record = { + sysparm_quantity: String(quantity), + }; + if (variables && Object.keys(variables).length > 0) { + body['variables'] = variables; + } + if (requested_for) { + body['sysparm_requested_for'] = requested_for; + } + + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + body, + timeout: 60000, + retries: 2, + }); + + const data = response.body as { result: CatalogOrderResult }; + return data.result; + } + + async pollJournalEntries({ + table, + elements, + record_sys_id, + limit = 100, + }: { + table: string; + elements: string[]; + record_sys_id?: string; + limit?: number; + }): Promise { + const endpoint = `/api/now/table/sys_journal_field`; + const elementClause = elements + .map((element) => `element=${element}`) + .join('^OR'); + const filters: string[] = [`name=${table}`]; + if (elementClause) { + filters.push(elementClause); + } + if (record_sys_id) { + filters.push(`element_id=${record_sys_id}`); + } + + const queryParams: Record = { + sysparm_query: `${filters.join('^')}^ORDERBYDESCsys_created_on`, + sysparm_limit: limit.toString(), + }; + + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + queryParams, + timeout: 30000, + retries: 3, + }); + + const data = response.body as { result: JournalEntry[] }; + return data.result || []; + } + + async getCatalogItem({ + item_sys_id, + }: { + item_sys_id: string; + }): Promise { + const endpoint = `/api/sn_sc/servicecatalog/items/${item_sys_id}`; + + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + timeout: 30000, + retries: 3, + }); + + const data = response.body as { result: CatalogItem }; + return data.result; + } + + async searchKnowledgeArticles({ + query, + kb, + language, + limit = 20, + offset = 0, + fields, + }: { + query?: string; + kb?: string; + language?: string; + limit?: number; + offset?: number; + fields?: string[]; + }): Promise { + const endpoint = `/api/sn_km_api/knowledge/articles`; + const queryParams: Record = { + sysparm_limit: limit.toString(), + sysparm_offset: offset.toString(), + }; + if (query) { + queryParams['query'] = query; + } + if (kb) { + queryParams['kb'] = kb; + } + if (language) { + queryParams['language'] = language; + } + if (fields && fields.length > 0) { + queryParams['fields'] = fields.join(','); + } + + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + queryParams, + timeout: 30000, + retries: 3, + }); + + return response.body as KnowledgeSearchResult; + } + + async getKnowledgeArticle({ + article_sys_id, + update_view, + }: { + article_sys_id: string; + update_view?: boolean; + }): Promise { + const endpoint = `/api/sn_km_api/knowledge/articles/${article_sys_id}`; + const queryParams: Record = {}; + if (update_view !== undefined) { + queryParams['update_view'] = update_view.toString(); + } + + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, + timeout: 30000, + retries: 3, + }); + + const data = response.body as { result: KnowledgeArticle }; + return data.result; + } + + async sendEmail({ + to, + subject, + body, + from, + cc, + bcc, + }: { + to: string[]; + subject: string; + body: string; + from?: string; + cc?: string[]; + bcc?: string[]; + }): Promise { + const endpoint = `/api/now/v1/email`; + const payload: Record = { + to: to.join(','), + subject, + body, + }; + if (from) { + payload['from'] = from; + } + if (cc && cc.length > 0) { + payload['cc'] = cc.join(','); + } + if (bcc && bcc.length > 0) { + payload['bcc'] = bcc.join(','); + } + + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${this.baseURL}${endpoint}`, + headers: this.getHeaders(), + body: payload, + timeout: 30000, + retries: 3, + }); + + const data = response.body as { result?: EmailSendResult }; + return data?.result ?? (response.body as EmailSendResult); + } } \ No newline at end of file diff --git a/packages/pieces/community/service-now/src/lib/common/journal.ts b/packages/pieces/community/service-now/src/lib/common/journal.ts new file mode 100644 index 00000000000..48197b5de27 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/common/journal.ts @@ -0,0 +1,12 @@ +export const JOURNAL_ELEMENT = { + COMMENTS: 'comments', + WORK_NOTES: 'work_notes', +} as const; + +export type JournalElement = + (typeof JOURNAL_ELEMENT)[keyof typeof JOURNAL_ELEMENT]; + +export const JOURNAL_ELEMENT_FILTER = { + BOTH: 'both', + ...JOURNAL_ELEMENT, +} as const; diff --git a/packages/pieces/community/service-now/src/lib/common/props.ts b/packages/pieces/community/service-now/src/lib/common/props.ts index a2268becd80..5e07a664657 100644 --- a/packages/pieces/community/service-now/src/lib/common/props.ts +++ b/packages/pieces/community/service-now/src/lib/common/props.ts @@ -102,6 +102,52 @@ export const recordDropdown = Property.Dropdown({ }, }); +export const catalogItemDropdown = Property.Dropdown({ + auth: servicenowAuth, + displayName: 'Catalog Item', + description: 'Select a catalog item to order', + required: true, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Please connect your ServiceNow account first', + options: [], + }; + } + + try { + const client = new ServiceNowClient({ + instanceUrl: (auth as any).instanceUrl, + auth: { + type: 'basic', + username: (auth as any).username, + password: (auth as any).password, + }, + }); + + const items = await client.listCatalogItems({ limit: 100 }); + return { + disabled: false, + options: items.map((item) => ({ + label: item.name + ? `${item.name} (${item.sys_id})` + : item.sys_id, + value: item.sys_id, + })), + }; + } catch { + return { + disabled: true, + placeholder: + 'Failed to load catalog items. Check your credentials and ensure the Service Catalog API is enabled.', + options: [], + }; + } + }, +}); + export function createServiceNowClient(auth: AppConnectionValueForAuthProperty): ServiceNowClient { return new ServiceNowClient({ instanceUrl: auth.props.instanceUrl, @@ -111,4 +157,22 @@ export function createServiceNowClient(auth: AppConnectionValueForAuthProperty; +export const CatalogItemSchema = z.object({ + sys_id: z.string(), + name: z.string().optional(), + short_description: z.string().optional(), + description: z.string().optional(), + price: z.string().optional(), + recurring_price: z.string().optional(), + picture: z.string().optional(), +}).passthrough(); +export type CatalogItem = z.infer; + +export const CatalogOrderResultSchema = z.object({ + sys_id: z.string().optional(), + request_number: z.string().optional(), + request_id: z.string().optional(), + table: z.string().optional(), +}).passthrough(); +export type CatalogOrderResult = z.infer; + +export const JournalEntrySchema = z.object({ + sys_id: z.string(), + element: z.string(), + element_id: z.string(), + name: z.string().optional(), + value: z.string().optional(), + sys_created_by: z.string().optional(), + sys_created_on: z.string().optional(), +}).passthrough(); +export type JournalEntry = z.infer; + +export const KnowledgeArticleSchema = z.object({ + sys_id: z.string().optional(), + number: z.string().optional(), + short_description: z.string().optional(), + title: z.string().optional(), + workflow_state: z.string().optional(), + content: z.string().optional(), + text: z.string().optional(), + link: z.string().optional(), + kb_knowledge_base: z + .union([z.string(), z.record(z.string(), z.any())]) + .optional(), + kb_category: z + .union([z.string(), z.record(z.string(), z.any())]) + .optional(), +}).passthrough(); +export type KnowledgeArticle = z.infer; + +export const KnowledgeSearchResultSchema = z.object({ + meta: z.record(z.string(), z.any()).optional(), + result: z.object({ + meta: z.record(z.string(), z.any()).optional(), + articles: z.array(KnowledgeArticleSchema).default([]), + }), +}).passthrough(); +export type KnowledgeSearchResult = z.infer; + +export const EmailSendResultSchema = z.object({ + sys_id: z.string().optional(), + status: z.string().optional(), +}).passthrough(); +export type EmailSendResult = z.infer; + export class NotSupported extends Error { constructor(message: string) { super(message); diff --git a/packages/pieces/community/service-now/src/lib/triggers/new-comment.ts b/packages/pieces/community/service-now/src/lib/triggers/new-comment.ts new file mode 100644 index 00000000000..bf31cfb3da2 --- /dev/null +++ b/packages/pieces/community/service-now/src/lib/triggers/new-comment.ts @@ -0,0 +1,122 @@ +import { + createTrigger, + TriggerStrategy, + Property, + AppConnectionValueForAuthProperty, +} from '@activepieces/pieces-framework'; +import { + DedupeStrategy, + Polling, + pollingHelper, +} from '@activepieces/pieces-common'; +import { + servicenowAuth, + tableDropdown, + createServiceNowClient, +} from '../common/props'; +import { JOURNAL_ELEMENT, JOURNAL_ELEMENT_FILTER } from '../common/journal'; + +type CommentTriggerProps = { + table: string; + entry_type: string; + record_sys_id?: string; +}; + +const polling: Polling< + AppConnectionValueForAuthProperty, + CommentTriggerProps +> = { + strategy: DedupeStrategy.LAST_ITEM, + items: async ({ auth, propsValue }) => { + const client = createServiceNowClient(auth); + const { table, entry_type, record_sys_id } = propsValue; + + const elements: string[] = + entry_type === JOURNAL_ELEMENT_FILTER.BOTH || !entry_type + ? [JOURNAL_ELEMENT.COMMENTS, JOURNAL_ELEMENT.WORK_NOTES] + : [entry_type]; + + const entries = await client.pollJournalEntries({ + table, + elements, + record_sys_id, + limit: 100, + }); + + return entries.map((entry) => ({ + id: entry.sys_id, + data: entry, + })); + }, +}; + +export const newCommentTrigger = createTrigger({ + auth: servicenowAuth, + name: 'new_comment', + displayName: 'New Comment or Work Note', + description: + 'Triggers when a new comment or work note is added to a record in the selected table', + type: TriggerStrategy.POLLING, + props: { + table: tableDropdown, + entry_type: Property.StaticDropdown({ + displayName: 'Entry Type', + description: 'Which journal entries to watch', + required: true, + defaultValue: JOURNAL_ELEMENT_FILTER.BOTH, + options: { + disabled: false, + options: [ + { label: 'Comments and Work Notes', value: JOURNAL_ELEMENT_FILTER.BOTH }, + { label: 'Customer-visible Comments only', value: JOURNAL_ELEMENT.COMMENTS }, + { label: 'Internal Work Notes only', value: JOURNAL_ELEMENT.WORK_NOTES }, + ], + }, + }), + record_sys_id: Property.ShortText({ + displayName: 'Record Sys ID Filter (optional)', + description: + 'If set, only fire for comments on this specific record sys_id', + required: false, + }), + }, + sampleData: { + sys_id: 'sample_journal_sys_id', + element: 'comments', + element_id: 'sample_record_sys_id', + name: 'incident', + value: 'New comment text', + sys_created_by: 'admin', + sys_created_on: '2026-04-30 12:00:00', + }, + async onEnable(context) { + await pollingHelper.onEnable(polling, { + auth: context.auth, + store: context.store, + propsValue: context.propsValue, + }); + }, + async onDisable(context) { + await pollingHelper.onDisable(polling, { + auth: context.auth, + store: context.store, + propsValue: context.propsValue, + }); + }, + async run(context) { + return await pollingHelper.poll(polling, { + auth: context.auth, + store: context.store, + propsValue: context.propsValue, + files: context.files, + }); + }, + async test(context) { + return await pollingHelper.test(polling, { + auth: context.auth, + store: context.store, + propsValue: context.propsValue, + files: context.files, + }); + }, +}); diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 778fc7b9760..ec90e3244c3 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -59,5 +59,5 @@ Email templates live in `src/assets/emails/`. When creating or modifying email t - Read existing code before making changes to understand patterns - Follow the existing controller/service pattern when adding new endpoints -- Write database migrations for schema changes, never modify entities directly without a migration +- Write database migrations for schema changes, never modify entities directly without a migration . use db-migration skill - Keep enterprise features isolated in `src/app/ee/` diff --git a/packages/server/api/src/app/authentication/authentication-utils.ts b/packages/server/api/src/app/authentication/authentication-utils.ts index 2d0b74d60f1..3b350cd7f8d 100644 --- a/packages/server/api/src/app/authentication/authentication-utils.ts +++ b/packages/server/api/src/app/authentication/authentication-utils.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, ApEdition, ApEnvironment, assertNotNullOrUndefined, AuthenticationResponse, EndpointScope, ErrorCode, isNil, PrincipalType, Project, ProjectType, TelemetryEventName, User, UserIdentity, UserIdentityProvider, UserStatus } from '@activepieces/shared' +import { ActivepiecesError, ApEdition, ApEnvironment, assertNotNullOrUndefined, AuthenticationResponse, EndpointScope, ErrorCode, isNil, PlatformRole, PrincipalType, Project, ProjectType, User, UserIdentity, UserIdentityProvider, UserStatus } from '@activepieces/shared' import { FastifyBaseLogger, FastifyRequest } from 'fastify' import { system } from '../helper/system/system' import { AppSystemProp } from '../helper/system/system-props' @@ -18,7 +18,7 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ const isInvited = await userInvitationsService(log).hasAnyAcceptedInvitations({ platformId, email, - + }) if (!isInvited) { throw new ActivepiecesError({ @@ -86,6 +86,39 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ } }, + async getOnboardingResponse({ identityId }: GetOnboardingResponseParams): Promise { + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + if (!identity.verified) { + throw new ActivepiecesError({ + code: ErrorCode.EMAIL_IS_NOT_VERIFIED, + params: { + email: identity.email, + }, + }) + } + + const token = await accessTokenManager(log).generateToken({ + id: identity.id, + type: PrincipalType.ONBOARDING, + tokenVersion: identity.tokenVersion, + }) + return { + id: identity.id, + platformId: null, + platformRole: PlatformRole.ADMIN, + status: UserStatus.ACTIVE, + externalId: null, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + trackEvents: identity.trackEvents, + newsLetter: identity.newsLetter, + verified: identity.verified, + token, + projectId: null, + } + }, + async assertDomainIsAllowed({ email, platformId, @@ -139,36 +172,20 @@ export const authenticationUtils = (log: FastifyBaseLogger) => ({ async sendTelemetry({ user, identity, - project, }: SendTelemetryParams): Promise { try { - await telemetry(log).identify(user, identity, project.id) - - await telemetry(log).trackProject(project.id, { - name: TelemetryEventName.SIGNED_UP, - payload: { - userId: identity.id, - email: identity.email, - firstName: identity.firstName, - lastName: identity.lastName, - projectId: project.id, - }, - }) + await telemetry(log).identify(identity, user) } catch (e) { log.warn({ err: e }, '[authenticationUtils#sendTelemetry] Failed to send telemetry') } }, - async saveNewsLetterSubscriber(user: User, platformId: string, identity: UserIdentity): Promise { - const platform = await platformService(log).getOneWithPlanOrThrow(platformId) + async saveNewsLetterSubscriber(identity: UserIdentity): Promise { const environment = system.get(AppSystemProp.ENVIRONMENT) if (environment !== ApEnvironment.PRODUCTION) { return } - if (platform.plan.embeddingEnabled) { - return - } try { const response = await fetch( 'https://us-central1-activepieces-b3803.cloudfunctions.net/addContact', @@ -204,8 +221,7 @@ function findPersonalProject(projects: Project[], userId: string): Project | und type SendTelemetryParams = { identity: UserIdentity - user: User - project: Project + user?: User } type AssertDomainIsAllowedParams = { @@ -223,9 +239,13 @@ type AssertUserIsInvitedToPlatformOrProjectParams = { platformId: string } +type GetOnboardingResponseParams = { + identityId: string +} + type GetProjectAndTokenParams = { userId: string platformId: string projectId: string | null scope?: EndpointScope -} \ No newline at end of file +} diff --git a/packages/server/api/src/app/authentication/authentication.controller.ts b/packages/server/api/src/app/authentication/authentication.controller.ts index 027cc501749..cdcbae12524 100644 --- a/packages/server/api/src/app/authentication/authentication.controller.ts +++ b/packages/server/api/src/app/authentication/authentication.controller.ts @@ -1,5 +1,5 @@ import { ApplicationEventName, - assertNotNullOrUndefined, + isNil, PrincipalType, SignInRequest, SignUpRequest, @@ -29,17 +29,19 @@ export const authenticationController: FastifyPluginAsyncZod = async ( platformId: platformId ?? null, }) - applicationEvents(request.log).sendUserEvent({ - platformId: signUpResponse.platformId!, - userId: signUpResponse.id, - projectId: signUpResponse.projectId, - ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), - }, { - action: ApplicationEventName.USER_SIGNED_UP, - data: { - source: 'credentials', - }, - }) + if (!isNil(signUpResponse.platformId)) { + applicationEvents(request.log).sendUserEvent({ + platformId: signUpResponse.platformId, + userId: signUpResponse.id, + projectId: signUpResponse.projectId ?? undefined, + ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), + }, { + action: ApplicationEventName.USER_SIGNED_UP, + data: { + source: 'credentials', + }, + }) + } return signUpResponse }) @@ -53,17 +55,17 @@ export const authenticationController: FastifyPluginAsyncZod = async ( predefinedPlatformId, }) - const responsePlatformId = response.platformId - assertNotNullOrUndefined(responsePlatformId, 'Platform ID is required') - applicationEvents(request.log).sendUserEvent({ - platformId: responsePlatformId, - userId: response.id, - projectId: response.projectId, - ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), - }, { - action: ApplicationEventName.USER_SIGNED_IN, - data: {}, - }) + if (!isNil(response.platformId)) { + applicationEvents(request.log).sendUserEvent({ + platformId: response.platformId, + userId: response.id, + projectId: response.projectId ?? undefined, + ip: networkUtils.extractClientRealIp(request, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), + }, { + action: ApplicationEventName.USER_SIGNED_IN, + data: {}, + }) + } return response }) diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 65658abb729..931f1f5b4d6 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -1,12 +1,11 @@ import { cryptoUtils } from '@activepieces/server-utils' -import { ActivepiecesError, ApEdition, ApFlagId, assertNotNullOrUndefined, AuthenticationResponse, ErrorCode, isNil, OtpType, PlatformRole, PlatformWithoutSensitiveData, ProjectType, User, UserIdentity, UserIdentityProvider } from '@activepieces/shared' +import { ActivepiecesError, ApEdition, ApFlagId, assertNotNullOrUndefined, AuthenticationResponse, ErrorCode, isNil, OtpType, PlatformWithoutSensitiveData, User, UserIdentity, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { otpService } from '../ee/authentication/otp/otp-service' import { flagService } from '../flags/flag.service' import { system } from '../helper/system/system' import { platformService } from '../platform/platform.service' import { platformUtils } from '../platform/platform.utils' -import { projectService } from '../project/project-service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' @@ -15,6 +14,7 @@ import { userIdentityService } from './user-identity/user-identity-service' export const authenticationService = (log: FastifyBaseLogger) => ({ async signUp(params: SignUpParams): Promise { const platformId = params.platformId + if (!isNil(platformId)) { await authenticationUtils(log).assertEmailAuthIsEnabled({ platformId, @@ -24,67 +24,66 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ email: params.email, platformId, }) - } - if (isNil(platformId)) { - const hasInvitations = await userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email: params.email }) - const isFederatedProvider = params.provider === UserIdentityProvider.GOOGLE || params.provider === UserIdentityProvider.JWT || params.provider === UserIdentityProvider.SAML + await authenticationUtils(log).assertUserIsInvitedToPlatformOrProject({ + email: params.email, + platformId, + }) const userIdentity = await userIdentityService(log).create({ ...params, - verified: hasInvitations || isFederatedProvider, + verified: true, + }) + const user = await userService(log).getOrCreateWithProject({ + identity: userIdentity, + platformId, }) - const response = await createUserAndPlatform(userIdentity, log) await userInvitationsService(log).provisionUserInvitation({ email: params.email }) - const preferredPlatformId = await getPreferredPlatformId(userIdentity.id, log) - if (!isNil(preferredPlatformId)) { - const user = await userService(log).getOneByIdentityAndPlatform({ - identityId: userIdentity.id, - platformId: preferredPlatformId, - }) - if (!isNil(user)) { - log.info({ email: params.email, provider: params.provider, preferredPlatformId }, 'User signed up with invitation, returning preferred platform token') - return authenticationUtils(log).getProjectAndToken({ - userId: user.id, - platformId: preferredPlatformId, - projectId: null, - }) - } - } - log.info({ email: params.email, provider: params.provider }, 'User signed up and platform created') - return response + + log.info({ email: params.email, platformId }, 'User signed up to existing platform') + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId, + projectId: null, + }) } - await authenticationUtils(log).assertUserIsInvitedToPlatformOrProject({ - email: params.email, - platformId, - }) + const hasInvitations = await userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email: params.email }) + const isFederatedProvider = params.provider === UserIdentityProvider.GOOGLE || params.provider === UserIdentityProvider.JWT || params.provider === UserIdentityProvider.SAML const userIdentity = await userIdentityService(log).create({ ...params, - verified: true, - }) - const user = await userService(log).getOrCreateWithProject({ - identity: userIdentity, - platformId, + verified: hasInvitations || isFederatedProvider, }) + await sendVerificationOrAutoVerify(userIdentity, log) + await flagService(log).save({ id: ApFlagId.USER_CREATED, value: true }) + await authenticationUtils(log).sendTelemetry({ identity: userIdentity }) + await authenticationUtils(log).saveNewsLetterSubscriber(userIdentity) await userInvitationsService(log).provisionUserInvitation({ email: params.email }) - log.info({ email: params.email, platformId }, 'User signed up to existing platform') - return authenticationUtils(log).getProjectAndToken({ - userId: user.id, - platformId, - projectId: null, - }) + const preferredPlatformId = await getPreferredPlatformId(userIdentity.id, log) + if (!isNil(preferredPlatformId)) { + const user = await userService(log).getOrCreateWithProject({ + identity: userIdentity, + platformId: preferredPlatformId, + }) + log.info({ email: params.email, provider: params.provider, preferredPlatformId }, 'User signed up with invitation, returning preferred platform token') + return authenticationUtils(log).getProjectAndToken({ + userId: user.id, + platformId: preferredPlatformId, + projectId: null, + }) + } + log.info({ email: params.email, provider: params.provider }, 'User signed up without platform') + return authenticationUtils(log).getOnboardingResponse({ identityId: userIdentity.id }) + }, async signInWithPassword(params: SignInWithPasswordParams): Promise { const identity = await userIdentityService(log).verifyIdentityPassword(params) const platformId = isNil(params.predefinedPlatformId) ? await getPreferredPlatformId(identity.id, log) : params.predefinedPlatformId - if (isNil(platformId)) { - throw new ActivepiecesError({ - code: ErrorCode.AUTHENTICATION, - params: { - message: 'No platform found for identity', - }, - }) + + if (isNil(platformId)) { // always cloud + log.info({ email: params.email }, 'User signed in without an active platform on cloud, returning onboarding token') + return authenticationUtils(log).getOnboardingResponse({ identityId: identity.id }) } + await authenticationUtils(log).assertEmailAuthIsEnabled({ platformId, provider: UserIdentityProvider.EMAIL, @@ -109,12 +108,10 @@ export const authenticationService = (log: FastifyBaseLogger) => ({ const platformId = isNil(params.predefinedPlatformId) ? await getPreferredPlatformIdForFederatedAuthn(params.email, log) : params.predefinedPlatformId const userIdentity = await userIdentityService(log).getIdentityByEmail(params.email) - if (isNil(platformId)) { + if (isNil(platformId)) { // always cloud if (!isNil(userIdentity)) { - // User already exists, create a new personal platform and return token - return createUserAndPlatform(userIdentity, log) + return authenticationUtils(log).getOnboardingResponse({ identityId: userIdentity.id }) } - // Create New Identity and Platform return authenticationService(log).signUp({ email: params.email, firstName: params.firstName, @@ -204,34 +201,13 @@ async function getUserForPlatform(identityId: string, platform: PlatformWithoutS return user } -async function createUserAndPlatform(userIdentity: UserIdentity, log: FastifyBaseLogger): Promise { - const user = await userService(log).create({ - identityId: userIdentity.id, - platformRole: PlatformRole.ADMIN, - platformId: null, - }) - const platform = await platformService(log).create({ - ownerId: user.id, - name: userIdentity.firstName + '\'s Platform', - }) - await userService(log).addOwnerToPlatform({ - platformId: platform.id, - id: user.id, - }) - const defaultProject = await projectService(log).create({ - displayName: userIdentity.firstName + '\'s Project', - ownerId: user.id, - platformId: platform.id, - type: ProjectType.PERSONAL, - }) - - const cloudEdition = system.getEdition() - - switch (cloudEdition) { +async function sendVerificationOrAutoVerify(userIdentity: UserIdentity, log: FastifyBaseLogger): Promise { + const edition = system.getEdition() + switch (edition) { case ApEdition.CLOUD: if (!userIdentity.verified) { await otpService(log).createAndSend({ - platformId: platform.id, + platformId: null, email: userIdentity.email, type: OtpType.EMAIL_VERIFICATION, }) @@ -242,23 +218,6 @@ async function createUserAndPlatform(userIdentity: UserIdentity, log: FastifyBas await userIdentityService(log).verify(userIdentity.id) break } - - await flagService(log).save({ - id: ApFlagId.USER_CREATED, - value: true, - }) - await authenticationUtils(log).sendTelemetry({ - identity: userIdentity, - user, - project: defaultProject, - }) - await authenticationUtils(log).saveNewsLetterSubscriber(user, platform.id, userIdentity) - - return authenticationUtils(log).getProjectAndToken({ - userId: user.id, - platformId: platform.id, - projectId: defaultProject.id, - }) } async function getPreferredPlatformIdForFederatedAuthn(email: string, log: FastifyBaseLogger): Promise { @@ -272,10 +231,12 @@ async function getPreferredPlatformIdForFederatedAuthn(email: string, log: Fasti async function getPreferredPlatformId(identityId: string, log: FastifyBaseLogger): Promise { const edition = system.getEdition() if (edition === ApEdition.CLOUD) { - const platforms = await platformService(log).listPlatformsForIdentityWithAtleastProject({ identityId }) + const platforms = await platformService(log).listPlatformsForIdentityWithAtleastProject({ identityId }) // this only gets platforms where user is active const nonDedicated = platforms.filter((p) => !platformUtils.isCustomerOnDedicatedDomain(p)) + const identity = await userIdentityService(log).getOneOrFail({ id: identityId }) + const lastUsed = !isNil(identity.lastLoggedInPlatformId) ? nonDedicated.find((p) => p.id === identity.lastLoggedInPlatformId) : undefined const licensed = nonDedicated.find((p) => !isNil(p.plan.licenseKey)) - return licensed?.id ?? nonDedicated[0]?.id ?? null + return lastUsed?.id ?? licensed?.id ?? nonDedicated[0]?.id ?? null } return null } diff --git a/packages/server/api/src/app/authentication/lib/access-token-manager.ts b/packages/server/api/src/app/authentication/lib/access-token-manager.ts index ef237e0ae53..4adbb9aa7f7 100644 --- a/packages/server/api/src/app/authentication/lib/access-token-manager.ts +++ b/packages/server/api/src/app/authentication/lib/access-token-manager.ts @@ -84,7 +84,21 @@ export const accessTokenManager = (log: FastifyBaseLogger) => ({ }) async function assertUserSession(log: FastifyBaseLogger, decoded: Principal | Principal): Promise { - if (decoded.type !== PrincipalType.USER) return + if (decoded.type !== PrincipalType.USER && decoded.type !== PrincipalType.ONBOARDING) return + + if (decoded.type === PrincipalType.ONBOARDING) { + const identity = await userIdentityService(log).getOneOrFail({ id: decoded.id }) + const isExpired = (identity.tokenVersion ?? null) !== (decoded.tokenVersion ?? null) + if (isExpired || !identity.verified) { + throw new ActivepiecesError({ + code: ErrorCode.SESSION_EXPIRED, + params: { + message: 'The session has expired or the user is not verified.', + }, + }) + } + return + } const user = await userService(log).getOneOrFail({ id: decoded.id }) const identity = await userIdentityService(log).getOneOrFail({ id: user.identityId }) @@ -97,6 +111,9 @@ async function assertUserSession(log: FastifyBaseLogger, decoded: Principal | Pr }, }) } + if (identity.lastLoggedInPlatformId !== decoded.platform.id) { + await userIdentityService(log).updateLastLoggedInPlatformId({ id: identity.id, lastLoggedInPlatformId: decoded.platform.id }) + } } type GenerateEngineTokenParams = { diff --git a/packages/server/api/src/app/authentication/user-identity/user-identity-entity.ts b/packages/server/api/src/app/authentication/user-identity/user-identity-entity.ts index d2e2d46d984..6ed5400ad45 100644 --- a/packages/server/api/src/app/authentication/user-identity/user-identity-entity.ts +++ b/packages/server/api/src/app/authentication/user-identity/user-identity-entity.ts @@ -1,6 +1,6 @@ import { UserIdentity } from '@activepieces/shared' import { EntitySchema } from 'typeorm' -import { BaseColumnSchemaPart } from '../../database/database-common' +import { ApIdSchema, BaseColumnSchemaPart } from '../../database/database-common' export const UserIdentityEntity = new EntitySchema({ name: 'user_identity', @@ -47,6 +47,10 @@ export const UserIdentityEntity = new EntitySchema({ type: String, nullable: true, }, + lastLoggedInPlatformId: { + ...ApIdSchema, + nullable: true, + }, }, indices: [ { diff --git a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts index e0f36b63cca..5aa7d0e4c98 100644 --- a/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts +++ b/packages/server/api/src/app/authentication/user-identity/user-identity-service.ts @@ -116,6 +116,9 @@ export const userIdentityService = (log: FastifyBaseLogger) => ({ ...spreadIfDefined('password', params.password ? await passwordHasher.hash(params.password) : undefined), }) }, + async updateLastLoggedInPlatformId({ id, lastLoggedInPlatformId }: UpdateLastLoggedInPlatformIdParams): Promise { + await userIdentityRepository().update(id, { lastLoggedInPlatformId }) + }, }) @@ -140,6 +143,11 @@ type UpdateParams = { imageUrl?: string | null } +type UpdateLastLoggedInPlatformIdParams = { + id: string + lastLoggedInPlatformId: string +} + type VerifyIdentityPasswordParams = { email: string password: string diff --git a/packages/server/api/src/app/database/migration/postgres/1777491000474-AddLastLoggedInPlatformIdToUserIdentity.ts b/packages/server/api/src/app/database/migration/postgres/1777491000474-AddLastLoggedInPlatformIdToUserIdentity.ts new file mode 100644 index 00000000000..1be8f7a8f4c --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1777491000474-AddLastLoggedInPlatformIdToUserIdentity.ts @@ -0,0 +1,22 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddLastLoggedInPlatformIdToUserIdentity1777491000474 implements Migration { + name = 'AddLastLoggedInPlatformIdToUserIdentity1777491000474' + breaking = false + release = '0.83.0' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user_identity" + ADD "lastLoggedInPlatformId" character varying(21) + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user_identity" DROP COLUMN "lastLoggedInPlatformId" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 0585cb15c9c..5596158ab82 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -362,6 +362,7 @@ import { AddChatTables1776200000000 } from './migration/postgres/1776200000000-A import { DropWaitpointTimeoutSeconds1776342514732 } from './migration/postgres/1776342514732-DropWaitpointTimeoutSeconds' import { AddMcpServerTokenIndex1776400000000 } from './migration/postgres/1776400000000-AddMcpServerTokenIndex' import { AddRunStatusCoverIndex1777370308000 } from './migration/postgres/1777370308000-AddRunStatusCoverIndex' +import { AddLastLoggedInPlatformIdToUserIdentity1777491000474 } from './migration/postgres/1777491000474-AddLastLoggedInPlatformIdToUserIdentity' import { DropChatTokenColumns1782000000000 } from './migration/postgres/1782000000000-DropChatTokenColumns' import { AddUserSandboxTable1784000000000 } from './migration/postgres/1784000000000-AddUserSandboxTable' import { ReplacesSandboxWithVercelAiSdk1785000000000 } from './migration/postgres/1785000000000-ReplacesSandboxWithVercelAiSdk' @@ -744,6 +745,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddRunStatusCoverIndex1777370308000, DropChatTokenColumns1782000000000, AddUserSandboxTable1784000000000, + AddLastLoggedInPlatformIdToUserIdentity1777491000474, ReplacesSandboxWithVercelAiSdk1785000000000, ] return migrations diff --git a/packages/server/api/src/app/database/seeds/dev-seeds.ts b/packages/server/api/src/app/database/seeds/dev-seeds.ts index 001ea484eaf..fde595b6e14 100644 --- a/packages/server/api/src/app/database/seeds/dev-seeds.ts +++ b/packages/server/api/src/app/database/seeds/dev-seeds.ts @@ -3,6 +3,7 @@ import { authenticationService } from '../../authentication/authentication.servi import { FlagEntity } from '../../flags/flag.entity' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' +import { platformService } from '../../platform/platform.service' import { databaseConnection } from '../database-connection' import { DataSeed } from './data-seed' @@ -35,7 +36,7 @@ const seedDevUser = async (): Promise => { const DEV_PASSWORD = '12345678' - await authenticationService(log).signUp({ + const response = await authenticationService(log).signUp({ email: DEV_EMAIL, password: DEV_PASSWORD, firstName: 'Dev', @@ -46,7 +47,13 @@ const seedDevUser = async (): Promise => { provider: UserIdentityProvider.EMAIL, }) - log.info({ email: DEV_EMAIL, password: DEV_PASSWORD }, '[devSeeds#seedDevUser] Dev user created') + await platformService(log).createPlatformWithProject({ + identityId: response.id, + name: 'dev\'s Platform', + invalidatePreviousTokens: true, + }) + + log.info({ email: DEV_EMAIL, password: DEV_PASSWORD }, '[devSeeds#seedDevUser] Dev user and platform created') } const seedDevData = async (): Promise => { if (currentEnvIsNotDev()) { diff --git a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts index b1ab934d17c..ad3f4bcda6a 100644 --- a/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts +++ b/packages/server/api/src/app/ee/authentication/federated-authn/federated-authn-module.ts @@ -1,7 +1,7 @@ import { ApplicationEventName, - ClaimTokenRequest, + isNil, ThirdPartyAuthnProviderEnum } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' @@ -33,17 +33,19 @@ const federatedAuthnController: FastifyPluginAsyncZod = async (app) => { platformId: platformId ?? undefined, code: req.body.code, }) - applicationEvents(req.log).sendUserEvent({ - platformId: response.platformId!, - userId: response.id, - projectId: response.projectId, - ip: networkUtils.extractClientRealIp(req, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), - }, { - action: ApplicationEventName.USER_SIGNED_UP, - data: { - source: 'sso', - }, - }) + if (!isNil(response.platformId)) { + applicationEvents(req.log).sendUserEvent({ + platformId: response.platformId, + userId: response.id, + projectId: response.projectId ?? undefined, + ip: networkUtils.extractClientRealIp(req, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), + }, { + action: ApplicationEventName.USER_SIGNED_UP, + data: { + source: 'sso', + }, + }) + } return response }) } diff --git a/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts b/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts index 4a4b81db9e1..1a08445b9aa 100644 --- a/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts +++ b/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts @@ -24,6 +24,7 @@ export const rbacService = (log: FastifyBaseLogger) => ({ switch (principal.type) { case PrincipalType.UNKNOWN: case PrincipalType.WORKER: + case PrincipalType.ONBOARDING: throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts index 912a057b595..09fab3f8854 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts @@ -29,7 +29,7 @@ export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => { applicationEvents(req.log).sendUserEvent({ platformId, userId: response.id, - projectId: response.projectId, + projectId: response.projectId ?? undefined, ip: networkUtils.extractClientRealIp(req, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), }, { action: ApplicationEventName.USER_SIGNED_UP, diff --git a/packages/server/api/src/app/ee/managed-authn/managed-authn-controller.ts b/packages/server/api/src/app/ee/managed-authn/managed-authn-controller.ts index 7e54cb559bb..a3364ce0c73 100644 --- a/packages/server/api/src/app/ee/managed-authn/managed-authn-controller.ts +++ b/packages/server/api/src/app/ee/managed-authn/managed-authn-controller.ts @@ -25,7 +25,7 @@ export const managedAuthnController: FastifyPluginAsyncZod = async ( applicationEvents(req.log).sendUserEvent({ platformId: response.platformId, userId: response.id, - projectId: response.projectId, + projectId: response.projectId ?? undefined, ip: networkUtils.extractClientRealIp(req, system.get(AppSystemProp.CLIENT_REAL_IP_HEADER)), }, { action: ApplicationEventName.USER_SIGNED_UP, diff --git a/packages/server/api/src/app/helper/telemetry.utils.ts b/packages/server/api/src/app/helper/telemetry.utils.ts index 18b6627d69d..0bd66e4988b 100644 --- a/packages/server/api/src/app/helper/telemetry.utils.ts +++ b/packages/server/api/src/app/helper/telemetry.utils.ts @@ -11,18 +11,18 @@ const telemetryEnabled = system.getBoolean(AppSystemProp.TELEMETRY_ENABLED) const analytics = new Analytics({ writeKey: '42TtMD2Fh9PEIcDO2CagCGFmtoPwOmqK' }) export const telemetry = (log: FastifyBaseLogger) => ({ - async identify(user: User, identity: UserIdentity, projectId: ProjectId): Promise { + async identify(identity: UserIdentity, user?: User, projectId?: ProjectId): Promise { if (!telemetryEnabled) { return } const identify = { - userId: user.id, + userId: user?.id ?? identity.id, traits: { email: identity.email, firstName: identity.firstName, lastName: identity.lastName, projectId, - firstSeenAt: user.created, + firstSeenAt: user?.created ?? identity.created, ...(await getMetadata()), }, } diff --git a/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts b/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts index c81aeb0ffbf..f735a604f9f 100644 --- a/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts +++ b/packages/server/api/src/app/pieces/metadata/piece-metadata-controller.ts @@ -154,7 +154,7 @@ const basePiecesController: FastifyPluginAsyncZod = async (app) => { } function getPlatformId(principal: Principal): string | undefined { - return principal.type === PrincipalType.WORKER || principal.type === PrincipalType.UNKNOWN ? undefined : principal.platform?.id + return principal.type === PrincipalType.WORKER || principal.type === PrincipalType.UNKNOWN || principal.type === PrincipalType.ONBOARDING ? undefined : principal.platform?.id } const RegistryPiecesRequest = { diff --git a/packages/server/api/src/app/platform/platform.controller.ts b/packages/server/api/src/app/platform/platform.controller.ts index ea7e8129952..cd5c1dad156 100644 --- a/packages/server/api/src/app/platform/platform.controller.ts +++ b/packages/server/api/src/app/platform/platform.controller.ts @@ -4,6 +4,8 @@ import { ApEdition, ApId, assertNotNullOrUndefined, + AuthenticationResponse, + CreatePlatformRequest, ErrorCode, FileType, PlatformWithoutSensitiveData, @@ -31,6 +33,18 @@ import { platformService } from './platform.service' const edition = system.getEdition() export const platformController: FastifyPluginAsyncZod = async (app) => { + app.post('/', CreatePlatformEndpoint, async (req) => { + const isOnboarding = req.principal.type === PrincipalType.ONBOARDING + const identityId = isOnboarding + ? req.principal.id + : (await userService(req.log).getOneOrFail({ id: req.principal.id })).identityId + return platformService(req.log).createPlatformWithProject({ + identityId, + name: req.body.name, + invalidatePreviousTokens: isOnboarding, + }) + }) + app.post('/:id', UpdatePlatformRequest, async (req, _res) => { if (req.principal.platform.id !== req.params.id) { throw new ActivepiecesError({ @@ -181,6 +195,18 @@ export const platformController: FastifyPluginAsyncZod = async (app) => { } } +const CreatePlatformEndpoint = { + config: { + security: securityAccess.unscoped([PrincipalType.ONBOARDING, PrincipalType.USER]), + }, + schema: { + body: CreatePlatformRequest, + response: { + [StatusCodes.OK]: AuthenticationResponse, + }, + }, +} + const UpdatePlatformRequest = { config: { security: securityAccess.platformAdminOnly([PrincipalType.USER]), diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 24322fea63d..2bf17bcbd3b 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -1,6 +1,7 @@ import { ActivepiecesError, ApEdition, apId, + AuthenticationResponse, ErrorCode, FederatedAuthnProviderConfig, FederatedAuthnProviderConfigWithoutSensitiveData, @@ -10,14 +11,19 @@ import { ActivepiecesError, Platform, PlatformId, PlatformPlanLimits, + PlatformRole, PlatformUsage, PlatformWithoutSensitiveData, + ProjectType, spreadIfDefined, UpdatePlatformRequestBody, UserId, UserStatus, } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { nanoid } from 'nanoid' +import { authenticationUtils } from '../authentication/authentication-utils' +import { userIdentityRepository } from '../authentication/user-identity/user-identity-service' import { repoFactory } from '../core/db/repo-factory' import { invalidateSamlClientCache } from '../ee/authentication/saml-authn/saml-client' import { platformPlanService } from '../ee/platform/platform-plan/platform-plan.service' @@ -85,6 +91,30 @@ export const platformService = (log: FastifyBaseLogger) => ({ log.info({ platformId: savedPlatform.id, ownerId }, 'Platform created') return savedPlatform }, + async createPlatformWithProject({ identityId, name, invalidatePreviousTokens }: CreatePlatformWithProjectParams): Promise { + const newUser = await userService(log).create({ + identityId, + platformRole: PlatformRole.ADMIN, + platformId: null, + }) + const platform = await this.create({ ownerId: newUser.id, name }) + const defaultProject = await projectService(log).create({ + displayName: `${name}'s Project`, + ownerId: newUser.id, + platformId: platform.id, + type: ProjectType.PERSONAL, + }) + if (invalidatePreviousTokens) { + await userIdentityRepository().update(identityId, { + tokenVersion: nanoid(), + }) + } + return authenticationUtils(log).getProjectAndToken({ + userId: newUser.id, + platformId: platform.id, + projectId: defaultProject.id, + }) + }, async getAll(): Promise { return platformRepo().find() }, @@ -238,6 +268,12 @@ type UpdateParams = UpdatePlatformRequestBody & { favIconUrl?: string } +type CreatePlatformWithProjectParams = { + identityId: string + name: string + invalidatePreviousTokens: boolean +} + type ListPlatformsForIdentityParams = { identityId: string -} \ No newline at end of file +} diff --git a/packages/server/api/src/app/template/template.controller.ts b/packages/server/api/src/app/template/template.controller.ts index 93f4a55d29c..e2e1a3ee8ca 100644 --- a/packages/server/api/src/app/template/template.controller.ts +++ b/packages/server/api/src/app/template/template.controller.ts @@ -270,7 +270,7 @@ async function loadCustomTemplatesOrReturnEmpty( if ((!isNil(query.type) && query.type !== TemplateType.CUSTOM)) { return [] } - const platformId = principal.type === PrincipalType.UNKNOWN || principal.type === PrincipalType.WORKER ? null : principal.platform.id + const platformId = principal.type === PrincipalType.UNKNOWN || principal.type === PrincipalType.WORKER || principal.type === PrincipalType.ONBOARDING ? null : principal.platform.id if (isNil(platformId)) { return [] } diff --git a/packages/server/api/src/app/user/platform/platform-user-controller.ts b/packages/server/api/src/app/user/platform/platform-user-controller.ts index b1d492fe955..c601e0c046d 100644 --- a/packages/server/api/src/app/user/platform/platform-user-controller.ts +++ b/packages/server/api/src/app/user/platform/platform-user-controller.ts @@ -1,4 +1,5 @@ import { + ApEdition, ApId, assertNotNullOrUndefined, ListUsersRequestBody, @@ -12,6 +13,7 @@ import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' import { securityAccess } from '../../core/security/authorization/fastify-security' +import { system } from '../../helper/system/system' import { userService } from '../user-service' export const platformUserController: FastifyPluginAsyncZod = async (app) => { @@ -45,10 +47,19 @@ export const platformUserController: FastifyPluginAsyncZod = async (app) => { const platformId = req.principal.platform.id assertNotNullOrUndefined(platformId, 'platformId') - await userService(req.log).delete({ - id: req.params.id, - platformId, - }) + const edition = system.getEdition() + if (edition === ApEdition.CLOUD) { + await userService(req.log).removeFromPlatform({ + id: req.params.id, + platformId, + }) + } + else { + await userService(req.log).delete({ + id: req.params.id, + platformId, + }) + } return res.status(StatusCodes.NO_CONTENT).send() }) diff --git a/packages/server/api/src/app/user/user-service.ts b/packages/server/api/src/app/user/user-service.ts index 6db2fcd458f..b8f3b1f7e5c 100644 --- a/packages/server/api/src/app/user/user-service.ts +++ b/packages/server/api/src/app/user/user-service.ts @@ -21,8 +21,9 @@ import { } from '@activepieces/shared' import dayjs from 'dayjs' import { FastifyBaseLogger } from 'fastify' -import { In } from 'typeorm' -import { userIdentityService } from '../authentication/user-identity/user-identity-service' +import { nanoid } from 'nanoid' +import { In, IsNull } from 'typeorm' +import { userIdentityRepository, userIdentityService } from '../authentication/user-identity/user-identity-service' import { repoFactory } from '../core/db/repo-factory' import { platformProjectService } from '../ee/projects/platform-project-service' import { projectMemberRepo } from '../ee/projects/project-role/project-role.service' @@ -137,7 +138,7 @@ export const userService = (log: FastifyBaseLogger) => ({ return userRepo().find({ where: { identityId } }) }, async getOneByIdentityAndPlatform({ identityId, platformId }: GetOneByIdentityIdParams): Promise { - return userRepo().findOneBy({ identityId, platformId }) + return userRepo().findOneBy({ identityId, platformId: isNil(platformId) ? IsNull() : platformId }) }, async get({ id }: IdParams): Promise { return userRepo().findOneBy({ id }) @@ -173,16 +174,7 @@ export const userService = (log: FastifyBaseLogger) => ({ } }, async delete({ id, platformId }: DeleteParams): Promise { - const platform = await platformService(log).getOneOrThrow(platformId) - if (platform.ownerId === id) { - throw new ActivepiecesError({ - code: ErrorCode.VALIDATION, - params: { - message: 'Platform owner cannot be deleted', - }, - }) - } - + await assertNotPlatformOwner({ id, platformId, log }) await platformProjectService(log).deletePersonalProjectForUser({ userId: id, platformId, @@ -192,6 +184,29 @@ export const userService = (log: FastifyBaseLogger) => ({ platformId, }) }, + async removeFromPlatform({ id, platformId }: DeleteParams): Promise { + await assertNotPlatformOwner({ id, platformId, log }) + const user = await this.getOneOrFail({ id }) + await platformProjectService(log).deletePersonalProjectForUser({ + userId: id, + platformId, + }) + await userRepo().update({ + id, + platformId, + }, { + platformId: null, + }) + await userIdentityRepository().update(user.identityId, { + tokenVersion: nanoid(), + }) + await userIdentityRepository().update({ + id: user.identityId, + lastLoggedInPlatformId: platformId, + }, { + lastLoggedInPlatformId: null, + }) + }, async getByPlatformRole(id: PlatformId, role: PlatformRole): Promise { return userRepo().find({ where: { platformId: id, platformRole: role }, relations: { identity: true } }) @@ -246,6 +261,18 @@ export const userService = (log: FastifyBaseLogger) => ({ }) +async function assertNotPlatformOwner({ id, platformId, log }: DeleteParams & { log: FastifyBaseLogger }): Promise { + const platform = await platformService(log).getOneOrThrow(platformId) + if (platform.ownerId === id) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'Platform owner cannot be deleted', + }, + }) + } +} + async function getUsersForProject(platformId: PlatformId, projectId: string): Promise { const platformAdmins = await userRepo().find({ where: { platformId, platformRole: PlatformRole.ADMIN } }).then((users) => users.map((user) => user.id)) const edition = system.getEdition() @@ -293,7 +320,7 @@ type GetByIdentityId = { type GetOneByIdentityIdParams = { identityId: string - platformId: PlatformId + platformId: PlatformId | null } type UpdateParams = { @@ -334,4 +361,4 @@ type UpdatePlatformIdParams = { type GetOrCreateWithProjectParams = { identity: UserIdentity platformId: string -} \ No newline at end of file +} diff --git a/packages/server/api/test/integration/ce/authentication/authentication.test.ts b/packages/server/api/test/integration/ce/authentication/authentication.test.ts index 77a52c6a43c..d02fa7b1e7f 100644 --- a/packages/server/api/test/integration/ce/authentication/authentication.test.ts +++ b/packages/server/api/test/integration/ce/authentication/authentication.test.ts @@ -2,7 +2,6 @@ import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/ import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { databaseConnection } from '../../../../src/app/database/database-connection' -import { db } from '../../../helpers/db' import { createMockSignInRequest, createMockSignUpRequest, @@ -23,10 +22,11 @@ beforeEach(async () => { await databaseConnection().getRepository('project').createQueryBuilder().delete().execute() await databaseConnection().getRepository('platform').createQueryBuilder().delete().execute() await databaseConnection().getRepository('user').createQueryBuilder().delete().execute() + await databaseConnection().getRepository('user_identity').createQueryBuilder().delete().execute() }) describe('Authentication API', () => { describe('Sign up Endpoint', () => { - it('Adds new user', async () => { + it('Adds new user with onboarding token', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() @@ -42,8 +42,6 @@ describe('Authentication API', () => { expect(response?.statusCode).toBe(StatusCodes.OK) expect(responseBody?.id).toHaveLength(21) - expect(responseBody?.created).toBeDefined() - expect(responseBody?.updated).toBeDefined() expect(responseBody?.verified).toBe(true) expect(responseBody?.email).toBe(mockSignUpRequest.email.toLocaleLowerCase().trim()) expect(responseBody?.firstName).toBe(mockSignUpRequest.firstName) @@ -51,13 +49,13 @@ describe('Authentication API', () => { expect(responseBody?.trackEvents).toBe(mockSignUpRequest.trackEvents) expect(responseBody?.newsLetter).toBe(mockSignUpRequest.newsLetter) expect(responseBody?.status).toBe('ACTIVE') - expect(responseBody?.platformId).toBeDefined() + expect(responseBody?.platformId).toBeNull() expect(responseBody?.externalId).toBe(null) - expect(responseBody?.projectId).toHaveLength(21) + expect(responseBody?.projectId).toBeNull() expect(responseBody?.token).toBeDefined() }) - it('Creates new project for user', async () => { + it('Does not create project or platform on signup', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() @@ -68,35 +66,134 @@ describe('Authentication API', () => { body: mockSignUpRequest, }) - const responseBody = response?.json() // assert expect(response?.statusCode).toBe(StatusCodes.OK) - const project = await db.findOneBy('project', { - id: responseBody.projectId, + const platformCount = await databaseConnection().getRepository('platform').count() + const projectCount = await databaseConnection().getRepository('project').count() + + expect(platformCount).toBe(0) + expect(projectCount).toBe(0) + }) + }) + + describe('Create Platform Endpoint', () => { + it('Creates platform and project with onboarding token', async () => { + // arrange + const mockSignUpRequest = createMockSignUpRequest() + const signUpResponse = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-up', + body: mockSignUpRequest, + }) + const signUpBody = signUpResponse?.json() + + // act + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { + authorization: `Bearer ${signUpBody.token}`, + }, + body: { + name: 'My Platform', + }, + }) + + // assert + const responseBody = response?.json() + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(responseBody?.platformId).toHaveLength(21) + expect(responseBody?.projectId).toHaveLength(21) + expect(responseBody?.token).toBeDefined() + expect(responseBody?.id).toHaveLength(21) + + const platformCount = await databaseConnection().getRepository('platform').count() + const projectCount = await databaseConnection().getRepository('project').count() + + expect(platformCount).toBe(1) + expect(projectCount).toBe(1) + }) + + it('Fails with missing name', async () => { + // arrange + const mockSignUpRequest = createMockSignUpRequest() + const signUpResponse = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-up', + body: mockSignUpRequest, + }) + const signUpBody = signUpResponse?.json() + + // act + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { + authorization: `Bearer ${signUpBody.token}`, + }, + body: {}, }) - expect(project?.ownerId).toBe(responseBody.id) - expect(project?.displayName).toBeDefined() - expect(project?.platformId).toBeDefined() + // assert + expect(response?.statusCode).toBe(StatusCodes.BAD_REQUEST) }) }) describe('Sign in Endpoint', () => { - it('Logs in existing users', async () => { + it('Logs in with onboarding token when no platform exists', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() + await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-up', + body: mockSignUpRequest, + }) - // First sign up the user + const mockSignInRequest = createMockSignInRequest({ + email: mockSignUpRequest.email, + password: mockSignUpRequest.password, + }) + + // act + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-in', + body: mockSignInRequest, + }) + + // assert + const responseBody = response?.json() + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(responseBody?.platformId).toBeNull() + expect(responseBody?.projectId).toBeNull() + expect(responseBody?.token).toBeDefined() + }) + + it('Logs in with user token after platform creation', async () => { + // arrange + const mockSignUpRequest = createMockSignUpRequest() const signUpResponse = await app?.inject({ method: 'POST', url: '/api/v1/authentication/sign-up', body: mockSignUpRequest, }) - const signUpBody = signUpResponse?.json() - // Then try to sign in + const createPlatformResponse = await app?.inject({ + method: 'POST', + url: '/api/v1/platforms', + headers: { + authorization: `Bearer ${signUpBody.token}`, + }, + body: { + name: 'My Platform', + }, + }) + const createPlatformBody = createPlatformResponse?.json() + const mockSignInRequest = createMockSignInRequest({ email: mockSignUpRequest.email, password: mockSignUpRequest.password, @@ -113,18 +210,14 @@ describe('Authentication API', () => { const responseBody = response?.json() expect(response?.statusCode).toBe(StatusCodes.OK) - expect(responseBody?.id).toBe(signUpBody.id) expect(responseBody?.email).toBe(mockSignUpRequest.email.toLowerCase().trim()) expect(responseBody?.firstName).toBe(mockSignUpRequest.firstName) expect(responseBody?.lastName).toBe(mockSignUpRequest.lastName) - expect(responseBody?.trackEvents).toBe(mockSignUpRequest.trackEvents) - expect(responseBody?.newsLetter).toBe(mockSignUpRequest.newsLetter) expect(responseBody?.password).toBeUndefined() expect(responseBody?.status).toBe('ACTIVE') expect(responseBody?.verified).toBe(true) - expect(responseBody?.platformId).toBe(signUpBody.platformId) - expect(responseBody?.externalId).toBe(null) - expect(responseBody?.projectId).toBe(signUpBody.projectId) + expect(responseBody?.platformId).toBe(createPlatformBody.platformId) + expect(responseBody?.projectId).toBe(createPlatformBody.projectId) expect(responseBody?.token).toBeDefined() }) diff --git a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts index 408b765c027..5c6f20c8da9 100644 --- a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts @@ -10,6 +10,8 @@ import { Platform, PlatformPlan, PlatformRole, + Principal, + PrincipalType, Project, ProjectRole, ProjectType, @@ -39,6 +41,7 @@ import { createMockSignInRequest, createMockSignUpRequest, } from '../../../helpers/mocks/authn' +import { jwtUtils } from 'packages/server/api/src/app/helper/jwt-utils' let app: FastifyInstance | null = null @@ -234,7 +237,7 @@ describe('Authentication API', () => { expect(sendOtpSpy).toHaveBeenCalledTimes(1) expect(sendOtpSpy).toHaveBeenCalledWith({ otp: expect.stringMatching(/^([0-9A-F]|-){36}$/i), - platformId: expect.any(String), + platformId: null, type: OtpType.EMAIL_VERIFICATION, userIdentity: expect.objectContaining({ email: mockSignUpRequest.email.trim().toLocaleLowerCase(), @@ -305,7 +308,7 @@ describe('Authentication API', () => { expect(teamProject).toBeDefined() const projectMember = await databaseConnection().getRepository('project_member').findOne({ where: { projectId: teamProject?.id, userId: responseBody?.id } }) - + expect(projectMember).toBeDefined() expect(projectMember?.userId).toBe(responseBody?.id) expect(projectMember?.projectId).toBe(teamProject?.id) @@ -366,9 +369,9 @@ describe('Authentication API', () => { .findOneBy({ id: mockUserInvitation.id }) expect(remainingInvitation).toBeNull() - // A personal platform was also created for the user + // No personal platform is auto-created; only the enterprise platform exists const allPlatforms = await databaseConnection().getRepository('platform').find() - expect(allPlatforms.length).toBe(2) + expect(allPlatforms.length).toBe(1) }) it('fails to sign up invited user platform if no project exist', async () => { @@ -732,7 +735,7 @@ describe('Authentication API', () => { expect(responseBody?.code).toBe('INVALID_CREDENTIALS') }) - it('Fails if user status is INACTIVE', async () => { + it('Onboarding response if user status is INACTIVE', async () => { // arrange const mockEmail = faker.internet.email() const mockPassword = 'password' @@ -783,12 +786,16 @@ describe('Authentication API', () => { url: '/api/v1/authentication/sign-in', body: mockSignInRequest, }) - const responseBody = response?.json() + // assert // In non-cloud editions, the sign-in fails with FORBIDDEN because the platform - // is not found via Host header resolution. In cloud edition, it returns UNAUTHORIZED. - expect([StatusCodes.UNAUTHORIZED, StatusCodes.FORBIDDEN]).toContain(response?.statusCode) + // is not found via Host header resolution. In cloud edition, it returns onboarding response for the user so he can create new platform. + expect([StatusCodes.OK]).toContain(response?.statusCode) + expect(responseBody?.token).toBeDefined() + const decoded = jwtUtils.decode({ jwt: responseBody?.token }) + expect(decoded.payload.type).toBe(PrincipalType.ONBOARDING) + }) }) @@ -814,4 +821,4 @@ async function createMockPlatformAndDomain({ platform, domain, plan }: { platfor }) await databaseConnection().getRepository('platform_plan').upsert(mockPlatformPlan, ['platformId']) return { mockUser: mockOwner, mockPlatform, mockCustomDomain, mockProject } -} \ No newline at end of file +} diff --git a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts index ea3f80c095b..b2787c21e33 100644 --- a/packages/server/api/test/integration/ee/authn/ee-authn.test.ts +++ b/packages/server/api/test/integration/ee/authn/ee-authn.test.ts @@ -21,7 +21,7 @@ afterAll(async () => { }) describe('Authentication API', () => { describe('Sign up Endpoint', () => { - it('Adds new user', async () => { + it('Adds new user with onboarding token', async () => { // arrange const mockSignUpRequest = createMockSignUpRequest() @@ -37,8 +37,6 @@ describe('Authentication API', () => { const responseBody = response?.json() expect(responseBody?.id).toHaveLength(21) - expect(responseBody?.created).toBeDefined() - expect(responseBody?.updated).toBeDefined() expect(responseBody?.email).toBe(mockSignUpRequest.email.toLocaleLowerCase().trim()) expect(responseBody?.firstName).toBe(mockSignUpRequest.firstName) expect(responseBody?.lastName).toBe(mockSignUpRequest.lastName) @@ -47,9 +45,9 @@ describe('Authentication API', () => { expect(responseBody?.password).toBeUndefined() expect(responseBody?.status).toBe('ACTIVE') expect(responseBody?.verified).toBe(true) - expect(responseBody?.platformId).toBeDefined() - expect(responseBody?.externalId).toBe(null) - expect(responseBody?.projectId).toHaveLength(21) + expect(responseBody?.platformId).toBeNull() + expect(responseBody?.externalId).toBeNull() + expect(responseBody?.projectId).toBeNull() expect(responseBody?.token).toBeDefined() }) }) diff --git a/packages/shared/src/lib/core/authentication/dto/authentication-response.ts b/packages/shared/src/lib/core/authentication/dto/authentication-response.ts index 780da69f3fa..a9ee7bd9a5e 100755 --- a/packages/shared/src/lib/core/authentication/dto/authentication-response.ts +++ b/packages/shared/src/lib/core/authentication/dto/authentication-response.ts @@ -11,7 +11,7 @@ export const AuthenticationResponse = UserWithoutPassword.merge( ).merge( z.object({ token: z.string(), - projectId: z.string(), + projectId: z.string().nullable(), }), ) export type AuthenticationResponse = z.infer diff --git a/packages/shared/src/lib/core/authentication/model/principal-type.ts b/packages/shared/src/lib/core/authentication/model/principal-type.ts index 4f1a2d473b0..7f02c590f25 100755 --- a/packages/shared/src/lib/core/authentication/model/principal-type.ts +++ b/packages/shared/src/lib/core/authentication/model/principal-type.ts @@ -4,6 +4,7 @@ export enum PrincipalType { SERVICE = 'SERVICE', WORKER = 'WORKER', UNKNOWN = 'UNKNOWN', + ONBOARDING = 'ONBOARDING', } export const ALL_PRINCIPAL_TYPES = Object.values(PrincipalType) diff --git a/packages/shared/src/lib/core/authentication/model/principal.ts b/packages/shared/src/lib/core/authentication/model/principal.ts index 8bb52622dbb..f14ee924ba9 100755 --- a/packages/shared/src/lib/core/authentication/model/principal.ts +++ b/packages/shared/src/lib/core/authentication/model/principal.ts @@ -40,6 +40,12 @@ export type EnginePrincipal = { } +export type OnboardingPrincipal = { + id: ApId + type: PrincipalType.ONBOARDING + tokenVersion?: string +} + export type PrincipalForType = Extract export type PrincipalForTypes = PrincipalForType @@ -50,3 +56,4 @@ export type Principal = | ServicePrincipal | UserPrincipal | EnginePrincipal + | OnboardingPrincipal diff --git a/packages/shared/src/lib/core/authentication/user-identity.ts b/packages/shared/src/lib/core/authentication/user-identity.ts index e093d0c7149..57f0257f0b0 100644 --- a/packages/shared/src/lib/core/authentication/user-identity.ts +++ b/packages/shared/src/lib/core/authentication/user-identity.ts @@ -20,6 +20,7 @@ export const UserIdentity = z.object({ tokenVersion: z.string().optional(), provider: z.nativeEnum(UserIdentityProvider), imageUrl: Nullable(z.string()), + lastLoggedInPlatformId: Nullable(z.string()), }) export type UserIdentity = z.infer diff --git a/packages/shared/src/lib/management/platform/platform.request.ts b/packages/shared/src/lib/management/platform/platform.request.ts index 1b219f0617f..cc0db18449b 100644 --- a/packages/shared/src/lib/management/platform/platform.request.ts +++ b/packages/shared/src/lib/management/platform/platform.request.ts @@ -13,6 +13,12 @@ export const Base64EncodedFile = z.object({ export type Base64EncodedFile = z.infer +export const CreatePlatformRequest = z.object({ + name: z.string().regex(new RegExp(SAFE_STRING_PATTERN)).min(1).max(100), +}) + +export type CreatePlatformRequest = z.infer + export const UpdatePlatformRequestBody = z.object({ name: z.string().regex(new RegExp(SAFE_STRING_PATTERN)).optional(), primaryColor: z.string().optional(), diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index a91c3a4249d..07d30761512 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -740,6 +740,9 @@ "All steps using this AI provider will fail after deletion.": "All steps using this AI provider will fail after deletion.", "Appearance": "Appearance", "Platform Name": "Platform Name", + "Create Platform": "Create Platform", + "Create your platform": "Create your platform", + "Give your platform a name to get started.": "Give your platform a name to get started.", "Logo": "", "Icon": "", "Favicon URL": "Favicon URL", diff --git a/packages/web/src/api/platforms-api.ts b/packages/web/src/api/platforms-api.ts index ff83256baee..4c0e9534919 100644 --- a/packages/web/src/api/platforms-api.ts +++ b/packages/web/src/api/platforms-api.ts @@ -1,4 +1,5 @@ import { + AuthenticationResponse, PlatformWithoutSensitiveData, UpdatePlatformRequestBody, } from '@activepieces/shared'; @@ -7,6 +8,9 @@ import { api } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; export const platformApi = { + createPlatform({ name }: { name: string }) { + return api.post('/v1/platforms', { name }); + }, deleteAccount() { return api.delete( `/v1/platforms/${authenticationSession.getPlatformId()}`, diff --git a/packages/web/src/app/guards/default-route.tsx b/packages/web/src/app/guards/default-route.tsx index d94c5df9076..fcec3832530 100644 --- a/packages/web/src/app/guards/default-route.tsx +++ b/packages/web/src/app/guards/default-route.tsx @@ -18,5 +18,8 @@ export const DefaultRoute = () => { > ); } + if (authenticationSession.isOnboarding()) { + return ; + } return ; }; diff --git a/packages/web/src/app/routes/auth-routes.tsx b/packages/web/src/app/routes/auth-routes.tsx index b765c11f05b..7b55d7dd32d 100644 --- a/packages/web/src/app/routes/auth-routes.tsx +++ b/packages/web/src/app/routes/auth-routes.tsx @@ -3,6 +3,7 @@ import { VerifyEmail } from '@/features/authentication'; import { AcceptInvitation } from '@/features/members'; import { ChangePasswordPage } from './change-password'; +import { CreatePlatformPage } from './create-platform'; import { ResetPasswordPage } from './forget-password'; import { SignInPage } from './sign-in'; import { SignUpPage } from './sign-up'; @@ -48,6 +49,14 @@ export const authRoutes = [ ), }, + { + path: '/create-platform', + element: ( + + + + ), + }, { path: '/invitation', element: ( diff --git a/packages/web/src/app/routes/create-platform.tsx b/packages/web/src/app/routes/create-platform.tsx new file mode 100644 index 00000000000..2e37679a84f --- /dev/null +++ b/packages/web/src/app/routes/create-platform.tsx @@ -0,0 +1,120 @@ +import { useMutation } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { Navigate } from 'react-router-dom'; + +import { platformApi } from '@/api/platforms-api'; +import { Button } from '@/components/ui/button'; +import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { AuthLayout } from '@/features/authentication/components/auth-form-template'; +import { authenticationSession } from '@/lib/authentication-session'; +import { useRedirectAfterLogin } from '@/lib/navigation-utils'; + +type CreatePlatformSchema = { + name: string; +}; + +function CreatePlatformForm() { + const redirectAfterLogin = useRedirectAfterLogin(); + const form = useForm({ + defaultValues: { + name: '', + }, + mode: 'onChange', + }); + + const { mutate, isPending } = useMutation({ + mutationFn: platformApi.createPlatform, + onSuccess: (data) => { + authenticationSession.saveResponse(data, false); + redirectAfterLogin(); + }, + onError: () => { + form.setError('root.serverError', { + message: t('Something went wrong, please try again later'), + }); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ name: data.name.trim() }); + }; + + return ( +
+ + ( + + + + + + )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + + ); +} + +function CreatePlatformPage() { + const token = authenticationSession.getToken(); + + if (!token) { + return ; + } + + if (!authenticationSession.isOnboarding()) { + return ; + } + + return ( + +
+

+ {t('Create your platform')} +

+

+ {t('Give your platform a name to get started.')} +

+
+ +
+ ); +} + +export { CreatePlatformPage }; diff --git a/packages/web/src/app/routes/embed/index.tsx b/packages/web/src/app/routes/embed/index.tsx index 3d243e1031d..470af9a669a 100644 --- a/packages/web/src/app/routes/embed/index.tsx +++ b/packages/web/src/app/routes/embed/index.tsx @@ -160,7 +160,9 @@ const EmbedPage = React.memo(() => { }); }); memoryRouter.navigate(initialRoute); - handleVendorNavigation({ projectId: data.projectId }); + if (data.projectId) { + handleVendorNavigation({ projectId: data.projectId }); + } handleClientNavigation(); notifyVendorPostAuthentication(); }, diff --git a/packages/web/src/features/authentication/components/sign-in-form.tsx b/packages/web/src/features/authentication/components/sign-in-form.tsx index 22101be741f..c6fb24a5d00 100644 --- a/packages/web/src/features/authentication/components/sign-in-form.tsx +++ b/packages/web/src/features/authentication/components/sign-in-form.tsx @@ -13,7 +13,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { Link, Navigate } from 'react-router-dom'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; import { z } from 'zod'; import { authenticationApi } from '@/api/authentication-api'; @@ -52,6 +52,7 @@ const SignInForm: React.FC = () => { const { data: userCreated } = flagsHooks.useFlag(ApFlagId.USER_CREATED); const redirectAfterLogin = useRedirectAfterLogin(); + const navigate = useNavigate(); const { mutate, isPending } = useMutation< AuthenticationResponse, @@ -61,6 +62,10 @@ const SignInForm: React.FC = () => { mutationFn: authenticationApi.signIn, onSuccess: (data) => { authenticationSession.saveResponse(data, false); + if (isNil(data.projectId)) { + navigate('/create-platform'); + return; + } redirectAfterLogin(); }, onError: (error) => { diff --git a/packages/web/src/features/authentication/components/sign-up-form.tsx b/packages/web/src/features/authentication/components/sign-up-form.tsx index bd061db5ea1..8d7f05b914e 100644 --- a/packages/web/src/features/authentication/components/sign-up-form.tsx +++ b/packages/web/src/features/authentication/components/sign-up-form.tsx @@ -9,7 +9,7 @@ import { t } from 'i18next'; import { Eye, EyeOff } from 'lucide-react'; import { useMemo, useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -88,11 +88,16 @@ const SignUpForm = ({ }, [edition, websiteName]); const redirectAfterLogin = useRedirectAfterLogin(); + const navigate = useNavigate(); const { mutate, isPending } = authMutations.useSignUp({ onSuccess: (data) => { if (data.verified) { authenticationSession.saveResponse(data, false); + if (isNil(data.projectId)) { + navigate('/create-platform'); + return; + } redirectAfterLogin(); } else { setShowCheckYourEmailNote(true); diff --git a/packages/web/src/features/projects/components/create-platform-dialog.tsx b/packages/web/src/features/projects/components/create-platform-dialog.tsx new file mode 100644 index 00000000000..30f63ca1285 --- /dev/null +++ b/packages/web/src/features/projects/components/create-platform-dialog.tsx @@ -0,0 +1,124 @@ +import { useMutation } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { SubmitHandler, useForm } from 'react-hook-form'; + +import { platformApi } from '@/api/platforms-api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authenticationSession } from '@/lib/authentication-session'; + +type CreatePlatformSchema = { + name: string; +}; + +function CreatePlatformDialogForm({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const form = useForm({ + defaultValues: { name: '' }, + mode: 'onChange', + }); + + const { mutate, isPending } = useMutation({ + mutationFn: platformApi.createPlatform, + onSuccess: (data) => { + authenticationSession.saveResponse(data, false); + window.location.href = '/'; + }, + onError: () => { + form.setError('root.serverError', { + message: t('Something went wrong, please try again later'), + }); + }, + }); + + const onSubmit: SubmitHandler = (data) => { + form.clearErrors('root.serverError'); + mutate({ name: data.name.trim() }); + }; + + return ( +
+ + ( + + + + + + )} + /> + {form?.formState?.errors?.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} +
+ + +
+ + + ); +} + +export function CreatePlatformDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + {t('Create Platform')} + + + + + ); +} diff --git a/packages/web/src/features/projects/components/platform-switcher.tsx b/packages/web/src/features/projects/components/platform-switcher.tsx index e4d1c22475f..2b9a5a3ae92 100644 --- a/packages/web/src/features/projects/components/platform-switcher.tsx +++ b/packages/web/src/features/projects/components/platform-switcher.tsx @@ -1,23 +1,32 @@ +import { ApEdition, ApFlagId } from '@activepieces/shared'; import { t } from 'i18next'; -import { Check } from 'lucide-react'; +import { Check, Plus } from 'lucide-react'; import * as React from 'react'; +import { useState } from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { projectHooks } from '@/features/projects/stores/project-collection'; +import { flagsHooks } from '@/hooks/flags-hooks'; import { authenticationSession } from '@/lib/authentication-session'; import { cn } from '@/lib/utils'; import { ScrollArea } from '../../../components/ui/scroll-area'; import { platformHooks } from '../../../hooks/platform-hooks'; +import { CreatePlatformDialog } from './create-platform-dialog'; + export function PlatformSwitcher({ children }: { children: React.ReactNode }) { const { data: allProjects } = projectHooks.useProjectsForPlatforms(); const { platform: currentPlatform } = platformHooks.useCurrentPlatform(); + const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const isCloud = edition === ApEdition.CLOUD; const platforms = React.useMemo(() => { if (!allProjects) return []; @@ -60,15 +69,35 @@ export function PlatformSwitcher({ children }: { children: React.ReactNode }) { ))} + {isCloud && ( + <> + + setCreateDialogOpen(true)} + className="text-sm p-2 cursor-pointer" + > + + {t('Create Platform')} + + + )} ); return ( - - - {children} - - {dropdownContent} - + <> + + + {children} + + {dropdownContent} + + {isCloud && ( + + )} + ); } diff --git a/packages/web/src/lib/authentication-session.ts b/packages/web/src/lib/authentication-session.ts index 959e18ff8d6..fe88e75aa26 100644 --- a/packages/web/src/lib/authentication-session.ts +++ b/packages/web/src/lib/authentication-session.ts @@ -1,7 +1,8 @@ import { AuthenticationResponse, isNil, - UserPrincipal, + Principal, + PrincipalType, } from '@activepieces/shared'; import dayjs from 'dayjs'; import { jwtDecode } from 'jwt-decode'; @@ -20,7 +21,9 @@ export const authenticationSession = { ApStorage.setInstanceToSessionStorage(); } ApStorage.getInstance().setItem(tokenKey, response.token); - ApStorage.getInstance().setItem(projectIdKey, response.projectId); + if (!isNil(response.projectId)) { + ApStorage.getInstance().setItem(projectIdKey, response.projectId); + } window.dispatchEvent(new Event('storage')); }, isJwtExpired(token: string): boolean { @@ -78,7 +81,18 @@ export const authenticationSession = { return null; } const decodedJwt = getDecodedJwt(token); - return decodedJwt.platform.id; + if ('platform' in decodedJwt && decodedJwt.platform) { + return decodedJwt.platform.id; + } + return null; + }, + isOnboarding(): boolean { + const token = this.getToken(); + if (isNil(token)) { + return false; + } + const decodedJwt = jwtDecode<{ type: string }>(token); + return decodedJwt.type === PrincipalType.ONBOARDING; }, async switchToPlatform(platformId: string) { if (authenticationSession.getPlatformId() === platformId) { @@ -88,7 +102,9 @@ export const authenticationSession = { platformId, }); ApStorage.getInstance().setItem(tokenKey, result.token); - ApStorage.getInstance().setItem(projectIdKey, result.projectId); + if (!isNil(result.projectId)) { + ApStorage.getInstance().setItem(projectIdKey, result.projectId); + } window.location.href = '/'; }, switchToProject(projectId: string) { @@ -115,6 +131,6 @@ export const authenticationSession = { }, }; -function getDecodedJwt(token: string): UserPrincipal { - return jwtDecode(token); +function getDecodedJwt(token: string): Principal { + return jwtDecode(token); } diff --git a/tsconfig.base.json b/tsconfig.base.json index c82c652ebc3..5376a6a2bd1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -945,6 +945,9 @@ "@activepieces/piece-pylon": [ "packages/pieces/community/pylon/src/index.ts" ], + "@activepieces/piece-qawafel": [ + "packages/pieces/community/qawafel/src/index.ts" + ], "@activepieces/piece-qdrant": [ "packages/pieces/community/qdrant/src/index.ts" ], @@ -1072,6 +1075,9 @@ "@activepieces/piece-seven": [ "packages/pieces/community/seven/src/index.ts" ], + "@activepieces/piece-service-now": [ + "packages/pieces/community/service-now/src/index.ts" + ], "@activepieces/piece-sftp": ["packages/pieces/core/sftp/src/index.ts"], "@activepieces/piece-shopify": [ "packages/pieces/community/shopify/src/index.ts"