From c577d0891eb4ca106662c29de327e841fd2fc21d Mon Sep 17 00:00:00 2001 From: DarkPhoenix2704 Date: Wed, 29 Oct 2025 13:23:42 +0530 Subject: [PATCH 1/5] feat: zendesk integeration --- .../packages/zendesk-auth/package.json | 19 + .../packages/zendesk-auth/src/config.ts | 7 + .../packages/zendesk-auth/src/form.ts | 94 ++++ .../packages/zendesk-auth/src/index.ts | 17 + .../packages/zendesk-auth/src/integration.ts | 89 ++++ .../packages/zendesk-auth/src/manifest.ts | 11 + .../packages/zendesk-auth/tsconfig.json | 8 + .../packages/zendesk-sync/package.json | 18 + .../packages/zendesk-sync/src/form.ts | 49 ++ .../packages/zendesk-sync/src/index.ts | 18 + .../packages/zendesk-sync/src/integration.ts | 469 ++++++++++++++++++ .../packages/zendesk-sync/src/manifest.ts | 14 + .../packages/zendesk-sync/tsconfig.json | 8 + packages/noco-integrations/pnpm-lock.yaml | 40 +- packages/nocodb/src/ee/integrations/index.ts | 4 + .../sync/module/services/sync.processor.ts | 5 + 16 files changed, 859 insertions(+), 11 deletions(-) create mode 100644 packages/noco-integrations/packages/zendesk-auth/package.json create mode 100644 packages/noco-integrations/packages/zendesk-auth/src/config.ts create mode 100644 packages/noco-integrations/packages/zendesk-auth/src/form.ts create mode 100644 packages/noco-integrations/packages/zendesk-auth/src/index.ts create mode 100644 packages/noco-integrations/packages/zendesk-auth/src/integration.ts create mode 100644 packages/noco-integrations/packages/zendesk-auth/src/manifest.ts create mode 100644 packages/noco-integrations/packages/zendesk-auth/tsconfig.json create mode 100644 packages/noco-integrations/packages/zendesk-sync/package.json create mode 100644 packages/noco-integrations/packages/zendesk-sync/src/form.ts create mode 100644 packages/noco-integrations/packages/zendesk-sync/src/index.ts create mode 100644 packages/noco-integrations/packages/zendesk-sync/src/integration.ts create mode 100644 packages/noco-integrations/packages/zendesk-sync/src/manifest.ts create mode 100644 packages/noco-integrations/packages/zendesk-sync/tsconfig.json diff --git a/packages/noco-integrations/packages/zendesk-auth/package.json b/packages/noco-integrations/packages/zendesk-auth/package.json new file mode 100644 index 0000000000..ea5dbe4f90 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/package.json @@ -0,0 +1,19 @@ +{ + "name": "@noco-integrations/zendesk-auth", + "version": "0.1.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rimraf dist", + "lint": "eslint src --ext .ts" + }, + "dependencies": { + "@noco-integrations/core": "workspace:*", + "axios": "^1.9.0" + }, + "devDependencies": { + "rimraf": "^5.0.10", + "typescript": "^5.8.3" + } +} diff --git a/packages/noco-integrations/packages/zendesk-auth/src/config.ts b/packages/noco-integrations/packages/zendesk-auth/src/config.ts new file mode 100644 index 0000000000..71461c8856 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/src/config.ts @@ -0,0 +1,7 @@ +/** + * Centralized configuration for Zendesk Auth Integration + */ + +export const getTokenUri = (subdomain: string): string => { + return `https://${subdomain}.zendesk.com/oauth/tokens`; +}; diff --git a/packages/noco-integrations/packages/zendesk-auth/src/form.ts b/packages/noco-integrations/packages/zendesk-auth/src/form.ts new file mode 100644 index 0000000000..5a9eb4e1cf --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/src/form.ts @@ -0,0 +1,94 @@ +import { + FormBuilderInputType, + FormBuilderValidatorType, +} from '@noco-integrations/core'; +import { AuthType } from '@noco-integrations/core'; +import type { FormDefinition } from '@noco-integrations/core'; + +export const form: FormDefinition = [ + { + type: FormBuilderInputType.Input, + label: 'Integration name', + width: 100, + model: 'title', + placeholder: 'Integration name', + category: 'General', + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'Integration name is required', + }, + ], + }, + { + type: FormBuilderInputType.Input, + label: 'Zendesk Subdomain', + width: 100, + model: 'config.subdomain', + category: 'General', + placeholder: 'e.g., yourcompany (from yourcompany.zendesk.com)', + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'Zendesk subdomain is required', + }, + ], + }, + { + type: FormBuilderInputType.Select, + label: 'Auth Type', + width: 48, + model: 'config.type', + category: 'Authentication', + placeholder: 'Select auth type', + defaultValue: AuthType.ApiKey, + options: [ + { + label: 'API Key', + value: AuthType.ApiKey, + }, + ], + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'Auth type is required', + }, + ], + }, + { + type: FormBuilderInputType.Input, + label: 'Email Address', + width: 100, + model: 'config.email', + category: 'Authentication', + placeholder: 'Enter your Zendesk email address', + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'Email is required', + }, + ], + condition: { + model: 'config.type', + value: AuthType.ApiKey, + }, + }, + { + type: FormBuilderInputType.Input, + label: 'API Token', + width: 100, + model: 'config.token', + category: 'Authentication', + placeholder: 'Enter your API Token', + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'API Token is required', + }, + ], + condition: { + model: 'config.type', + value: AuthType.ApiKey, + }, + }, +]; diff --git a/packages/noco-integrations/packages/zendesk-auth/src/index.ts b/packages/noco-integrations/packages/zendesk-auth/src/index.ts new file mode 100644 index 0000000000..ce866ab5e7 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/src/index.ts @@ -0,0 +1,17 @@ +import { + type IntegrationEntry, + IntegrationType, +} from '@noco-integrations/core'; +import { ZendeskAuthIntegration } from './integration'; +import { form } from './form'; +import { manifest } from './manifest'; + +const integration: IntegrationEntry = { + type: IntegrationType.Auth, + sub_type: 'zendesk', + wrapper: ZendeskAuthIntegration, + form, + manifest, +}; + +export default integration; diff --git a/packages/noco-integrations/packages/zendesk-auth/src/integration.ts b/packages/noco-integrations/packages/zendesk-auth/src/integration.ts new file mode 100644 index 0000000000..7c382b33fa --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/src/integration.ts @@ -0,0 +1,89 @@ +import axios from 'axios'; +import { AuthIntegration, AuthType } from '@noco-integrations/core'; +import type { + AuthResponse, + TestConnectionResponse, +} from '@noco-integrations/core'; + +interface ZendeskClient { + subdomain: string; + token: string; + email?: string; + apiVersion: string; +} + +export class ZendeskAuthIntegration extends AuthIntegration { + public client: ZendeskClient | null = null; + + public async authenticate(): Promise> { + switch (this.config.type) { + case AuthType.ApiKey: + if (!this.config.subdomain || !this.config.email || !this.config.token) { + throw new Error('Missing required Zendesk configuration'); + } + + this.client = { + subdomain: this.config.subdomain, + email: this.config.email, + token: this.config.token, + apiVersion: 'v2', + }; + + return this.client; + default: + throw new Error('Not implemented'); + } + } + + public async testConnection(): Promise { + try { + const client = await this.authenticate(); + + if (!client) { + return { + success: false, + message: 'Missing Zendesk client', + }; + } + + // Test connection by fetching current user information + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.config.type === AuthType.ApiKey) { + const auth = Buffer.from( + `${client.email}/token:${client.token}`, + ).toString('base64'); + headers['Authorization'] = `Basic ${auth}`; + } + + const response = await axios.get( + `https://${client.subdomain}.zendesk.com/api/v2/users/me.json`, + { headers }, + ); + + if (response.data && response.data.user) { + return { + success: true, + }; + } + + return { + success: false, + message: 'Failed to verify Zendesk connection', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + public async exchangeToken(payload: { + code: string; + }): Promise<{ oauth_token: string }> { + throw new Error('Not implemented'); + } +} diff --git a/packages/noco-integrations/packages/zendesk-auth/src/manifest.ts b/packages/noco-integrations/packages/zendesk-auth/src/manifest.ts new file mode 100644 index 0000000000..50a4096b5b --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/src/manifest.ts @@ -0,0 +1,11 @@ +import type { IntegrationManifest } from '@noco-integrations/core'; + +export const manifest: IntegrationManifest = { + title: 'Zendesk', + icon: 'zendeskSolid', + description: 'Zendesk authentication integration for NocoDB', + version: '0.1.0', + author: 'NocoDB', + website: 'https://www.zendesk.com', + order: 100, +}; diff --git a/packages/noco-integrations/packages/zendesk-auth/tsconfig.json b/packages/noco-integrations/packages/zendesk-auth/tsconfig.json new file mode 100644 index 0000000000..90d76d7e8d --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-auth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/noco-integrations/packages/zendesk-sync/package.json b/packages/noco-integrations/packages/zendesk-sync/package.json new file mode 100644 index 0000000000..fd27a944e0 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/package.json @@ -0,0 +1,18 @@ +{ + "name": "@noco-integrations/zendesk-sync", + "version": "0.1.0", + "description": "Zendesk Sync integration for NocoDB", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "dependencies": { + "@noco-integrations/core": "workspace:*", + "axios": "^1.9.0" + }, + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/packages/noco-integrations/packages/zendesk-sync/src/form.ts b/packages/noco-integrations/packages/zendesk-sync/src/form.ts new file mode 100644 index 0000000000..dcd37a8552 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/src/form.ts @@ -0,0 +1,49 @@ +import { + FormBuilderInputType, + FormBuilderValidatorType, + type FormDefinition, + IntegrationType, +} from '@noco-integrations/core'; + +const form: FormDefinition = [ + { + type: FormBuilderInputType.SelectIntegration, + label: 'Zendesk Connection', + width: 100, + model: 'config.authIntegrationId', + category: 'Authentication', + integrationFilter: { + type: IntegrationType.Auth, + sub_type: 'zendesk', + }, + validators: [ + { + type: FormBuilderValidatorType.Required, + message: 'Zendesk connection is required', + }, + ], + }, + { + type: FormBuilderInputType.Switch, + label: 'Include closed tickets', + width: 48, + model: 'config.includeClosed', + category: 'Source', + defaultValue: true, + }, + { + type: FormBuilderInputType.Space, + width: 4, + category: 'Source', + }, + { + type: FormBuilderInputType.Switch, + label: 'Include archived tickets', + width: 48, + model: 'config.includeArchived', + category: 'Source', + defaultValue: false, + }, +]; + +export default form; diff --git a/packages/noco-integrations/packages/zendesk-sync/src/index.ts b/packages/noco-integrations/packages/zendesk-sync/src/index.ts new file mode 100644 index 0000000000..b5b17b2b84 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/src/index.ts @@ -0,0 +1,18 @@ +import { + type IntegrationEntry, + IntegrationType, +} from '@noco-integrations/core'; +import ZendeskSyncIntegration from './integration'; +import manifest from './manifest'; +import form from './form'; + +const integration: IntegrationEntry = { + type: IntegrationType.Sync, + sub_type: 'zendesk', + wrapper: ZendeskSyncIntegration, + form, + manifest, +}; + +export { manifest, form, ZendeskSyncIntegration }; +export default integration; diff --git a/packages/noco-integrations/packages/zendesk-sync/src/integration.ts b/packages/noco-integrations/packages/zendesk-sync/src/integration.ts new file mode 100644 index 0000000000..4736fb2e56 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/src/integration.ts @@ -0,0 +1,469 @@ +import axios from 'axios'; +import { + DataObjectStream, + SCHEMA_TICKETING, + SyncIntegration, + TARGET_TABLES, +} from '@noco-integrations/core'; +import type { + AuthResponse, + SyncLinkValue, + SyncRecord, + TicketingCommentRecord, + TicketingTeamRecord, + TicketingTicketRecord, + TicketingUserRecord, +} from '@noco-integrations/core'; + +interface ZendeskClient { + subdomain: string; + token: string; + email?: string; + apiVersion: string; +} + +export interface ZendeskSyncPayload { + includeClosed: boolean; + includeArchived: boolean; +} + +export default class ZendeskSyncIntegration extends SyncIntegration { + public getTitle() { + return 'Zendesk Tickets'; + } + + public async getDestinationSchema(_auth: AuthResponse) { + return SCHEMA_TICKETING; + } + + private getAuthHeaders(client: ZendeskClient): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (client.email) { + const auth = Buffer.from(`${client.email}/token:${client.token}`).toString( + 'base64', + ); + headers['Authorization'] = `Basic ${auth}`; + } else { + headers['Authorization'] = `Bearer ${client.token}`; + } + + return headers; + } + + public async fetchData( + auth: AuthResponse, + args: { + targetTables?: TARGET_TABLES[]; + targetTableIncrementalValues?: Record; + }, + ): Promise< + DataObjectStream< + | TicketingTicketRecord + | TicketingUserRecord + | TicketingCommentRecord + | TicketingTeamRecord + > + > { + const client = auth; + const { includeClosed, includeArchived } = this.config; + const { targetTableIncrementalValues } = args; + + const stream = new DataObjectStream< + | TicketingTicketRecord + | TicketingUserRecord + | TicketingCommentRecord + | TicketingTeamRecord + >(); + + const userMap = new Map(); + const ticketMap = new Map(); + + (async () => { + try { + const headers = this.getAuthHeaders(client); + const baseUrl = `https://${client.subdomain}.zendesk.com/api/v2`; + + const ticketIncrementalValue = + targetTableIncrementalValues?.[TARGET_TABLES.TICKETING_TICKET]; + + // Build query parameters for regular tickets API + const queryParams = new URLSearchParams({ + per_page: '100', + }); + + if (ticketIncrementalValue) { + queryParams.set('updated_after', ticketIncrementalValue); + } + + if (!includeClosed) { + queryParams.set('status', 'open'); + } + + // Fetch tickets using regular API (not incremental) + this.log('[Zendesk Sync] Fetching tickets'); + + let page = 1; + let totalTickets = 0; + let hasMore = true; + + while (hasMore) { + queryParams.set('page', page.toString()); + const url = `${baseUrl}/tickets.json?${queryParams.toString()}`; + this.log(`[Zendesk Sync] Fetching page ${page}`); + + const response: any = await axios.get(url, { headers }); + const data: any = response.data; + + this.log(`[Zendesk Sync] Fetched ${data.tickets.length} tickets`); + + // Break if no tickets returned + if (!data.tickets || data.tickets.length === 0) { + hasMore = false; + break; + } + + totalTickets += data.tickets.length; + + for (const ticket of data.tickets) { + // Filter based on status + if (!includeClosed && ['closed', 'solved'].includes(ticket.status)) { + continue; + } + + // Filter archived tickets + if (!includeArchived && ticket.status === 'archived') { + continue; + } + + ticketMap.set(ticket.id.toString(), true); + + // Process ticket + const ticketData = this.formatTicket(ticket); + stream.push({ + recordId: ticket.id.toString(), + targetTable: TARGET_TABLES.TICKETING_TICKET, + data: ticketData.data as TicketingTicketRecord, + links: ticketData.links, + }); + + // Process users (requester, assignee, submitter) + const userIds = [ + ticket.requester_id, + ticket.assignee_id, + ticket.submitter_id, + ].filter((id) => id); + + for (const userId of userIds) { + if (!userMap.has(userId.toString())) { + userMap.set(userId.toString(), true); + } + } + } + + page++; + + // Respect rate limits + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + this.log(`[Zendesk Sync] Total tickets fetched: ${totalTickets}`); + + // Fetch users + if (userMap.size > 0) { + this.log(`[Zendesk Sync] Fetching ${userMap.size} users`); + + const userIds = Array.from(userMap.keys()); + const batchSize = 100; + + for (let i = 0; i < userIds.length; i += batchSize) { + const batch = userIds.slice(i, i + batchSize); + const userQueryParams = new URLSearchParams({ + ids: batch.join(','), + }); + + try { + const response = await axios.get( + `${baseUrl}/users/show_many.json?${userQueryParams.toString()}`, + { headers }, + ); + + for (const user of response.data.users) { + const userData = this.formatUser(user); + stream.push({ + recordId: user.id.toString(), + targetTable: TARGET_TABLES.TICKETING_USER, + data: userData.data as TicketingUserRecord, + }); + } + } catch (error) { + console.error(error); + this.log(`[Zendesk Sync] Error fetching users batch: ${error}`); + } + + // Respect rate limits + if (i + batchSize < userIds.length) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + } + + // Fetch comments if requested + if (args.targetTables?.includes(TARGET_TABLES.TICKETING_COMMENT)) { + this.log('[Zendesk Sync] Fetching comments'); + + for (const ticketId of ticketMap.keys()) { + try { + const response = await axios.get( + `${baseUrl}/tickets/${ticketId}/comments.json`, + { headers }, + ); + + if (!response.data.comments) { + continue; + } + + for (const comment of response.data.comments) { + const commentData = this.formatComment({ + ...comment, + ticketId, + }); + stream.push({ + recordId: comment.id.toString(), + targetTable: TARGET_TABLES.TICKETING_COMMENT, + data: commentData.data as TicketingCommentRecord, + links: commentData.links, + }); + + // Add comment author to users if not already added + if (comment.author_id && !userMap.has(comment.author_id.toString())) { + userMap.set(comment.author_id.toString(), true); + + try { + const userResponse = await axios.get( + `${baseUrl}/users/${comment.author_id}.json`, + { headers }, + ); + + const userData = this.formatUser(userResponse.data.user); + stream.push({ + recordId: comment.author_id.toString(), + targetTable: TARGET_TABLES.TICKETING_USER, + data: userData.data as TicketingUserRecord, + }); + } catch (error) { + console.error(error); + this.log( + `[Zendesk Sync] Error fetching comment author ${comment.author_id}: ${error}`, + ); + } + } + } + + // Respect rate limits + await new Promise((resolve) => setTimeout(resolve, 200)); + } catch (error: any) { + // Log but don't fail - comments might not be accessible + if (error.response?.status === 401) { + this.log( + `[Zendesk Sync] No permission to fetch comments for ticket ${ticketId}. Skipping comments.`, + ); + // Stop trying to fetch more comments if we get 401 + break; + } else { + console.error(error); + this.log( + `[Zendesk Sync] Error fetching comments for ticket ${ticketId}: ${error.message || error}`, + ); + } + } + } + } + + // Fetch organization (team) if requested + if (args.targetTables?.includes(TARGET_TABLES.TICKETING_TEAM)) { + this.log('[Zendesk Sync] Fetching organizations'); + + try { + let orgNextPage: string | null = `${baseUrl}/organizations.json`; + + while (orgNextPage) { + const response: any = await axios.get(orgNextPage, { headers }); + const data: any = response.data; + + for (const org of data.organizations) { + const teamData = this.formatTeam(org); + stream.push({ + recordId: org.id.toString(), + targetTable: TARGET_TABLES.TICKETING_TEAM, + data: teamData.data as TicketingTeamRecord, + }); + } + + orgNextPage = data.next_page; + + if (orgNextPage) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + } catch (error) { + console.error(error); + this.log(`[Zendesk Sync] Error fetching organizations: ${error}`); + } + } + + stream.push(null); // End the stream + } catch (error) { + console.error(error); + this.log(`[Zendesk Sync] Error fetching data: ${error}`); + stream.emit('error', error); + } + })(); + + return stream; + } + + public formatData( + targetTable: TARGET_TABLES, + data: any, + ): { + data: SyncRecord; + links?: Record; + } { + switch (targetTable) { + case TARGET_TABLES.TICKETING_TICKET: + return this.formatTicket(data); + case TARGET_TABLES.TICKETING_USER: + return this.formatUser(data); + case TARGET_TABLES.TICKETING_COMMENT: + return this.formatComment(data); + case TARGET_TABLES.TICKETING_TEAM: + return this.formatTeam(data); + default: { + return { + data: { + RemoteRaw: JSON.stringify(data), + }, + }; + } + } + } + + private formatTicket(ticket: any): { + data: TicketingTicketRecord; + links?: Record; + } { + const ticketData: TicketingTicketRecord = { + Name: ticket.subject || null, + Description: ticket.description || null, + 'Due Date': ticket.due_at || null, + Priority: ticket.priority || null, + Status: ticket.status || null, + Tags: ticket.tags?.join(', ') || null, + 'Ticket Type': ticket.type || null, + Url: ticket.url || null, + 'Is Active': !['closed', 'solved', 'archived'].includes(ticket.status), + 'Completed At': ticket.status === 'closed' || ticket.status === 'solved' ? ticket.updated_at : null, + 'Ticket Number': ticket.id?.toString() || null, + RemoteCreatedAt: ticket.created_at || null, + RemoteUpdatedAt: ticket.updated_at || null, + RemoteRaw: JSON.stringify(ticket), + }; + + const links: Record = {}; + + if (ticket.assignee_id) { + links.Assignees = [ticket.assignee_id.toString()]; + } + + if (ticket.requester_id) { + links.Creator = [ticket.requester_id.toString()]; + } + + if (ticket.organization_id) { + links.Team = [ticket.organization_id.toString()]; + } + + return { + data: ticketData, + links, + }; + } + + private formatUser(user: any): { + data: TicketingUserRecord; + } { + const userData: TicketingUserRecord = { + Name: user.name || null, + Email: user.email || null, + Url: user.url || null, + RemoteCreatedAt: user.created_at || null, + RemoteUpdatedAt: user.updated_at || null, + RemoteRaw: JSON.stringify(user), + }; + + return { + data: userData, + }; + } + + private formatComment(comment: any): { + data: TicketingCommentRecord; + links?: Record; + } { + const commentData: TicketingCommentRecord = { + Title: `Comment on ticket #${comment.ticketId}`, + Body: comment.body || comment.html_body || null, + Url: null, + RemoteCreatedAt: comment.created_at || null, + RemoteUpdatedAt: null, + RemoteRaw: JSON.stringify(comment), + }; + + const links: Record = {}; + + if (comment.ticketId) { + links.Ticket = [comment.ticketId.toString()]; + } + + if (comment.author_id) { + links['Created By'] = [comment.author_id.toString()]; + } + + return { + data: commentData, + links, + }; + } + + private formatTeam(team: any): { + data: TicketingTeamRecord; + } { + const teamData: TicketingTeamRecord = { + Name: team.name || null, + Description: team.details || null, + RemoteCreatedAt: team.created_at || null, + RemoteUpdatedAt: team.updated_at || null, + RemoteRaw: JSON.stringify(team), + }; + + return { + data: teamData, + }; + } + + public getIncrementalKey(targetTable: TARGET_TABLES): string { + switch (targetTable) { + case TARGET_TABLES.TICKETING_TICKET: + return 'RemoteUpdatedAt'; + case TARGET_TABLES.TICKETING_COMMENT: + return 'RemoteCreatedAt'; + case TARGET_TABLES.TICKETING_USER: + case TARGET_TABLES.TICKETING_TEAM: + default: + return ''; + } + } +} diff --git a/packages/noco-integrations/packages/zendesk-sync/src/manifest.ts b/packages/noco-integrations/packages/zendesk-sync/src/manifest.ts new file mode 100644 index 0000000000..680879c836 --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/src/manifest.ts @@ -0,0 +1,14 @@ +import { + type IntegrationManifest, + SyncCategory, +} from '@noco-integrations/core'; + +const manifest: IntegrationManifest = { + title: 'Zendesk', + icon: 'zendeskSolid', + version: '0.1.0', + description: 'Sync Zendesk tickets and users', + sync_category: SyncCategory.TICKETING, +}; + +export default manifest; diff --git a/packages/noco-integrations/packages/zendesk-sync/tsconfig.json b/packages/noco-integrations/packages/zendesk-sync/tsconfig.json new file mode 100644 index 0000000000..90d76d7e8d --- /dev/null +++ b/packages/noco-integrations/packages/zendesk-sync/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/noco-integrations/pnpm-lock.yaml b/packages/noco-integrations/pnpm-lock.yaml index 52569b2136..e51a9b8339 100644 --- a/packages/noco-integrations/pnpm-lock.yaml +++ b/packages/noco-integrations/pnpm-lock.yaml @@ -550,6 +550,35 @@ importers: specifier: ^5.1.6 version: 5.8.3 + packages/zendesk-auth: + dependencies: + '@noco-integrations/core': + specifier: workspace:* + version: link:../../core + axios: + specifier: ^1.9.0 + version: 1.9.0 + devDependencies: + rimraf: + specifier: ^5.0.10 + version: 5.0.10 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + packages/zendesk-sync: + dependencies: + '@noco-integrations/core': + specifier: workspace:* + version: link:../../core + axios: + specifier: ^1.9.0 + version: 1.9.0 + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.8.3 + packages: '@ai-sdk/amazon-bedrock@2.2.9': @@ -1136,67 +1165,56 @@ packages: resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.40.2': resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.40.2': resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.40.2': resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.40.2': resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.40.2': resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.40.2': resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.40.2': resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.40.2': resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.40.2': resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} diff --git a/packages/nocodb/src/ee/integrations/index.ts b/packages/nocodb/src/ee/integrations/index.ts index 0d130a54d4..1a1e4ea1b8 100644 --- a/packages/nocodb/src/ee/integrations/index.ts +++ b/packages/nocodb/src/ee/integrations/index.ts @@ -25,6 +25,8 @@ import OpenaiAi from '@noco-local-integrations/openai-ai'; import OpenaiCompatibleAi from '@noco-local-integrations/openai-compatible-ai'; import PostgresAuth from '@noco-local-integrations/postgres-auth'; import PostgresSync from '@noco-local-integrations/postgres-sync'; +import ZendeskAuth from '@noco-local-integrations/zendesk-auth'; +import ZendeskSync from '@noco-local-integrations/zendesk-sync'; import type { IntegrationEntry } from '@noco-local-integrations/core'; @@ -51,4 +53,6 @@ export default [ OpenaiCompatibleAi, PostgresAuth, PostgresSync, + ZendeskAuth, + ZendeskSync, ] as IntegrationEntry[]; diff --git a/packages/nocodb/src/ee/integrations/sync/module/services/sync.processor.ts b/packages/nocodb/src/ee/integrations/sync/module/services/sync.processor.ts index 90608ef624..653cfd2f77 100644 --- a/packages/nocodb/src/ee/integrations/sync/module/services/sync.processor.ts +++ b/packages/nocodb/src/ee/integrations/sync/module/services/sync.processor.ts @@ -905,6 +905,11 @@ export class SyncModuleSyncDataProcessor { } } + req.query = { + ...req.query, + typecast: 'true', + }; + if (dataToUpdate.length) { await this.dataTableService.dataUpdate(context, { baseId: model.base_id, From 35861bfcbb9ac18c29c637207a763e3640f02b04 Mon Sep 17 00:00:00 2001 From: DarkPhoenix2704 Date: Wed, 29 Oct 2025 13:37:56 +0530 Subject: [PATCH 2/5] fix: zendesk integration --- packages/nocodb/src/db/BaseModelSqlv2.ts | 17 ++++++++++++++++- packages/nocodb/src/models/Column.ts | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index 7e98d0c68a..010ff3b416 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -4196,7 +4196,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 { await this.validateOptions(column, data); } catch (ex) { if (ex instanceof OptionsNotExistsError && typecast) { - await Column.update(this.context, column.id, { + const UpdatedColumn = await Column.update(this.context, column.id, { ...column, colOptions: { options: [ @@ -4212,6 +4212,21 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 { ], }, }); + + const table = await Model.getWithInfo(this.context, { + id: column.fk_model_id, + }); + + NocoSocket.broadcastEvent(this.context, { + event: EventType.META_EVENT, + payload: { + action: 'column_update', + payload: { + table, + column: UpdatedColumn, + }, + }, + }); } else { throw ex; } diff --git a/packages/nocodb/src/models/Column.ts b/packages/nocodb/src/models/Column.ts index afa04cdfaf..48f890bd9e 100644 --- a/packages/nocodb/src/models/Column.ts +++ b/packages/nocodb/src/models/Column.ts @@ -1903,6 +1903,8 @@ export default class Column implements ColumnType { cleanBaseSchemaCacheForBase(context.base_id).catch(() => { logger.error('Failed to clean base schema cache'); }); + + return this.get(context, { colId }, ncMeta); } static async updateCustomIndexName( From fa70da3c902ccc045cdd7fe637a47facb041786a Mon Sep 17 00:00:00 2001 From: DarkPhoenix2704 Date: Wed, 29 Oct 2025 16:12:34 +0530 Subject: [PATCH 3/5] feat: modernize sync management UI with NcTable and enhanced cards - Replaced custom table implementation with NcTable component for better maintainability and consistency - Refactored sync filtering logic into a computed property for improved performance - Enhanced CategorySelect component with modern card-based design and improved visual feedback - Improved sync creation flow with better state management and validation - Added structured column definitions for sync table configuration - Stream --- .../components/dashboard/settings/Syncs.vue | 259 ++++++++---------- .../settings/sync/CategorySelect.vue | 70 +++-- .../dashboard/settings/sync/Create.vue | 253 +++++++---------- .../dashboard/settings/sync/Edit.vue | 202 +++++++------- .../settings/sync/IntegrationConfig.vue | 55 ++-- .../settings/sync/IntegrationTabs.vue | 112 +++++--- .../dashboard/settings/sync/Review.vue | 195 +++++++------ .../dashboard/settings/sync/Settings.vue | 219 ++++++++++----- .../dashboard/settings/sync/Steps.vue | 101 ++++++- .../components/nc/form-builder/index.vue | 5 +- .../packages/zendesk-sync/src/form.ts | 15 +- .../packages/zendesk-sync/src/integration.ts | 10 +- .../packages/zendesk-sync/src/manifest.ts | 2 +- 13 files changed, 846 insertions(+), 652 deletions(-) diff --git a/packages/nc-gui/components/dashboard/settings/Syncs.vue b/packages/nc-gui/components/dashboard/settings/Syncs.vue index a91aa35027..1cc954b338 100644 --- a/packages/nc-gui/components/dashboard/settings/Syncs.vue +++ b/packages/nc-gui/components/dashboard/settings/Syncs.vue @@ -147,14 +147,60 @@ const handleDeleteSync = async (syncId: string) => { } } -const isSearchResultAvailable = () => { - if (!searchQuery.value) return true - return syncs.value.some( +const filteredSyncs = computed(() => { + if (!searchQuery.value) return syncs.value + return syncs.value.filter( (sync) => sync.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) || sync.sync_type?.toLowerCase().includes(searchQuery.value.toLowerCase()), ) -} +}) + +const columns = [ + { + key: 'name', + title: 'Name', + name: 'Name', + dataIndex: 'title', + minWidth: 160, + padding: '0px 24px', + }, + { + key: 'type', + title: 'Type', + name: 'Type', + dataIndex: 'sync_type', + width: 150, + minWidth: 150, + padding: '0px 24px', + }, + { + key: 'frequency', + title: 'Frequency', + name: 'Frequency', + dataIndex: 'frequency', + width: 150, + minWidth: 150, + padding: '0px 24px', + }, + { + key: 'last_sync', + title: 'Last Run', + name: 'Last Run', + dataIndex: 'last_sync_at', + width: 240, + minWidth: 240, + padding: '0px 24px', + }, + { + key: 'actions', + title: 'Actions', + name: 'Actions', + width: 100, + minWidth: 100, + padding: '0px 24px', + }, +] as NcTableColumnProps[] // Load syncs when component is mounted onMounted(() => { @@ -216,105 +262,67 @@ watch(
-
-
-
-
Name
-
Type
-
Frequency
-
Last Run
-
Actions
+ + + +
@@ -338,50 +346,5 @@ watch( diff --git a/packages/nc-gui/components/dashboard/settings/sync/CategorySelect.vue b/packages/nc-gui/components/dashboard/settings/sync/CategorySelect.vue index 2c547d620d..e7934ed8b3 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/CategorySelect.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/CategorySelect.vue @@ -19,23 +19,57 @@ const selectCategory = (value: SyncCategory) => { + + diff --git a/packages/nc-gui/components/dashboard/settings/sync/Create.vue b/packages/nc-gui/components/dashboard/settings/sync/Create.vue index fa6a0a9563..4879bdca31 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/Create.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/Create.vue @@ -26,10 +26,16 @@ enum Step { } const step = ref(Step.Category) -const goToDashboard = ref(false) -const goBack = ref(false) const progressRef = ref() -const creatingSync = ref(false) +const syncState = ref<{ + creating: boolean + completed: boolean + failed: boolean +}>({ + creating: false, + completed: false, + failed: false, +}) // Create a new integration configs store instance for this component const { @@ -46,7 +52,7 @@ const { const handleSubmit = async () => { isLoading.value = true - creatingSync.value = true + syncState.value.creating = true try { const syncData = await createSync() @@ -71,19 +77,14 @@ const handleSubmit = async () => { if (data.status !== 'close') { if (data.status === JobStatus.COMPLETED) { progressRef.value?.pushProgress('Done!', data.status) - await loadTables() - refreshCommandPalette() - goToDashboard.value = true + syncState.value.completed = true } else if (data.status === JobStatus.FAILED) { progressRef.value?.pushProgress(data.data?.error?.message ?? 'Sync failed', data.status) - await loadTables() - refreshCommandPalette() - - goBack.value = true + syncState.value.failed = true } else { progressRef.value?.pushProgress(data.data?.message ?? 'Syncing...', 'progress') } @@ -94,49 +95,45 @@ const handleSubmit = async () => { ) } catch (e: any) { message.error(await extractSdkResponseErrorMsg(e)) - creatingSync.value = false + syncState.value.creating = false } finally { isLoading.value = false } } +const validateDestinationSchema = () => { + if (!formState.value.config.custom_schema) return false + + for (const table of Object.values(formState.value.config.custom_schema) as { + systemFields: { primaryKey: string[] } + }[]) { + if (!table.systemFields.primaryKey || table.systemFields.primaryKey.length === 0) { + message.error('Every table must have at least one unique identifier column') + return false + } + } + return true +} + const nextStep = async () => { switch (step.value) { case Step.Category: step.value = Step.SyncSettings break - case Step.Integration: - if (await saveCurrentFormState()) { - if (syncConfigForm.value.sync_category === 'custom') { - step.value = Step.DestinationSchema - } else { - step.value = Step.Create - } - } - break case Step.SyncSettings: try { await validateSyncConfig() step.value = Step.Integration } catch {} break + case Step.Integration: + if (await saveCurrentFormState()) { + step.value = syncConfigForm.value.sync_category === 'custom' ? Step.DestinationSchema : Step.Create + } + break case Step.DestinationSchema: - if (formState.value.config.custom_schema) { - // make sure every table has a primary key - for (const table of Object.values(formState.value.config.custom_schema) as { - systemFields: { - primaryKey: string[] - } - }[]) { - if (!table.systemFields.primaryKey || table.systemFields.primaryKey.length === 0) { - message.error('Every table must have at least one unique identifier column') - return - } - } - - if (await saveCurrentFormState()) { - step.value = Step.Create - } + if (validateDestinationSchema() && (await saveCurrentFormState())) { + step.value = Step.Create } break case Step.Create: @@ -145,27 +142,19 @@ const nextStep = async () => { } } +const stepFlow = { + [Step.Category]: Step.Category, + [Step.SyncSettings]: Step.Category, + [Step.Integration]: Step.SyncSettings, + [Step.DestinationSchema]: Step.Integration, + [Step.Create]: Step.Integration, +} + const previousStep = () => { - switch (step.value) { - case Step.Category: - step.value = Step.Category - break - case Step.SyncSettings: - step.value = Step.Category - break - case Step.Integration: - step.value = Step.SyncSettings - break - case Step.DestinationSchema: - step.value = Step.Integration - break - case Step.Create: - if (syncConfigForm.value.sync_category === 'custom') { - step.value = Step.DestinationSchema - } else { - step.value = Step.Integration - } - break + if (step.value === Step.Create && syncConfigForm.value.sync_category === 'custom') { + step.value = Step.DestinationSchema + } else { + step.value = stepFlow[step.value] } } @@ -208,25 +197,20 @@ watch( }, ) -const refreshState = () => { - goBack.value = false - creatingSync.value = false - goToDashboard.value = false -} - -function onDashboard() { - refreshState() - vOpen.value = false +const resetSyncState = () => { + syncState.value = { + creating: false, + completed: false, + failed: false, + } } const onClose = () => { - refreshState() + resetSyncState() vOpen.value = false } -const isModalClosable = computed(() => { - return !creatingSync.value -}) +const isModalClosable = computed(() => !syncState.value.creating) diff --git a/packages/nc-gui/components/dashboard/settings/sync/IntegrationTabs.vue b/packages/nc-gui/components/dashboard/settings/sync/IntegrationTabs.vue index 774b248801..94a6560001 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/IntegrationTabs.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/IntegrationTabs.vue @@ -23,42 +23,88 @@ const configs = computed(() => { + + diff --git a/packages/nc-gui/components/dashboard/settings/sync/Review.vue b/packages/nc-gui/components/dashboard/settings/sync/Review.vue index a36275366c..3e4f6f3128 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/Review.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/Review.vue @@ -41,137 +41,132 @@ const selectedModels = computed(() => { diff --git a/packages/nc-gui/components/dashboard/settings/sync/Settings.vue b/packages/nc-gui/components/dashboard/settings/sync/Settings.vue index d283131cc8..739fa21f0f 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/Settings.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/Settings.vue @@ -29,98 +29,193 @@ const onCheckboxChange = (model: string) => { const formModel = computed(() => { return editMode.value ? syncConfigEditForm.value : syncConfigForm.value }) + +const selectAllModels = () => { + syncAllModels.value = true + syncConfigEditFormChanged.value = true +} + +const selectSpecificModels = () => { + syncAllModels.value = false + syncConfigEditFormChanged.value = true +} + + diff --git a/packages/nc-gui/components/dashboard/settings/sync/Steps.vue b/packages/nc-gui/components/dashboard/settings/sync/Steps.vue index 4f237d8bd8..e4fad5c366 100644 --- a/packages/nc-gui/components/dashboard/settings/sync/Steps.vue +++ b/packages/nc-gui/components/dashboard/settings/sync/Steps.vue @@ -1,17 +1,102 @@ + + diff --git a/packages/nc-gui/components/nc/form-builder/index.vue b/packages/nc-gui/components/nc/form-builder/index.vue index b7d07cb36f..c3788b98db 100644 --- a/packages/nc-gui/components/nc/form-builder/index.vue +++ b/packages/nc-gui/components/nc/form-builder/index.vue @@ -135,9 +135,9 @@ watch( :required="false" :data-testid="`nc-form-input-${field.model}`" > -