From e8a7d6c68f9d931afbe4e1585acaaf127fbf5799 Mon Sep 17 00:00:00 2001 From: Aubin <60398825+aubin-tchoi@users.noreply.github.com> Date: Fri, 25 Oct 2024 17:19:06 +0200 Subject: [PATCH] [connectors] Content node retrieval functions for Zendesk (#8216) * refactor: remove unused function * feat: implement retrieveContentNodeParents * feat: implement the batch content node retrieval * refactor: replace then with awaits * feat: use the new permission system for batch node retrieval * feat: set Help Centers and Tickets to be folders instead of databases * refactor: define a generic method to identify the type of an internal ID in batch/parent retrieval and permissions setting * refactor: use the generic getIdFromInternalId when retrieving help center permissions * refactor: use the generic getIdFromInternalId when retrieving tickets permissions * refactor: prevent get...IdFromInternalId functions from being exported * refactor: use the generic getIdFromInternalId when retrieving help center permissions * refactor: use the generic getIdFromInternalId when retrieving tickets permissions * refactor: add a method toContentNode for the ZendeskArticleResource * perf: replace a Promise.all of 3 requests with a single one + filters * refactor: add methods getHelpCenterContentNode and getTicketsContentNode to centralize the content node definition in zendesk_resources.ts * fix: remove an obsolete catch * refactor: add a method toContentNode for the ZendeskTicketResource * fix: remove an unused import * refactor: use the toContentNode methods in retrieveSelectedNodes * refactor: regroup the permission retrieval functions into one that handles all cases * update connectors/src/connectors/zendesk/lib/permissions.ts Co-authored-by: Thomas Draier * feat: throw errors on unrecognized internal IDs * refactor: add assertNevers on id types and handle missing cases --------- Co-authored-by: Thomas Draier --- connectors/src/connectors/zendesk/index.ts | 394 ++++++++++++------ .../zendesk/lib/brand_permissions.ts | 76 +--- .../zendesk/lib/help_center_permissions.ts | 160 +------ .../connectors/zendesk/lib/id_conversions.ts | 64 ++- .../src/connectors/zendesk/lib/permissions.ts | 237 +++++++++-- .../zendesk/lib/ticket_permissions.ts | 82 +--- connectors/src/resources/zendesk_resources.ts | 315 +++++++++++++- 7 files changed, 819 insertions(+), 509 deletions(-) diff --git a/connectors/src/connectors/zendesk/index.ts b/connectors/src/connectors/zendesk/index.ts index f58faf852572..5f3d88685034 100644 --- a/connectors/src/connectors/zendesk/index.ts +++ b/connectors/src/connectors/zendesk/index.ts @@ -5,6 +5,7 @@ import type { ContentNodesViewType, Result, } from "@dust-tt/types"; +import { assertNever } from "@dust-tt/types"; import { Err } from "@dust-tt/types"; import { Ok } from "@dust-tt/types"; @@ -12,32 +13,39 @@ import type { ConnectorManagerError } from "@connectors/connectors/interface"; import { BaseConnectorManager } from "@connectors/connectors/interface"; import { allowSyncZendeskBrand, - retrieveZendeskBrandPermissions, revokeSyncZendeskBrand, } from "@connectors/connectors/zendesk/lib/brand_permissions"; import { allowSyncZendeskCategory, allowSyncZendeskHelpCenter, - retrieveZendeskHelpCenterPermissions, revokeSyncZendeskCategory, revokeSyncZendeskHelpCenter, } from "@connectors/connectors/zendesk/lib/help_center_permissions"; import { - getBrandIdFromHelpCenterId, - getBrandIdFromInternalId, - getBrandIdFromTicketsId, - getCategoryIdFromInternalId, + getBrandInternalId, + getCategoryInternalId, + getHelpCenterInternalId, + getIdFromInternalId, + getTicketsInternalId, } from "@connectors/connectors/zendesk/lib/id_conversions"; -import { retrieveSelectedNodes } from "@connectors/connectors/zendesk/lib/permissions"; +import { + retrieveChildrenNodes, + retrieveSelectedNodes, +} from "@connectors/connectors/zendesk/lib/permissions"; import { allowSyncZendeskTickets, - retrieveZendeskTicketPermissions, revokeSyncZendeskTickets, } from "@connectors/connectors/zendesk/lib/ticket_permissions"; import { getZendeskAccessToken } from "@connectors/connectors/zendesk/lib/zendesk_access_token"; import logger from "@connectors/logger/logger"; import { ConnectorResource } from "@connectors/resources/connector_resource"; import { ZendeskConfigurationResource } from "@connectors/resources/zendesk_resources"; +import { + ZendeskArticleResource, + ZendeskBrandResource, + ZendeskCategoryResource, + ZendeskTicketResource, +} from "@connectors/resources/zendesk_resources"; import type { DataSourceConfig } from "@connectors/types/data_source_config"; export class ZendeskConnectorManager extends BaseConnectorManager { @@ -97,43 +105,28 @@ export class ZendeskConnectorManager extends BaseConnectorManager { filterPermission: ConnectorPermission | null; viewType: ContentNodesViewType; }): Promise> { - const connector = await ConnectorResource.fetchById(this.connectorId); + const connectorId = this.connectorId; + const connector = await ConnectorResource.fetchById(connectorId); if (!connector) { - logger.error( - { connectorId: this.connectorId }, - "[Zendesk] Connector not found." - ); + logger.error({ connectorId }, "[Zendesk] Connector not found."); return new Err(new Error("Connector not found")); } if (filterPermission === "read" && parentInternalId === null) { // We want all selected nodes despite the hierarchy - const selectedNodes = await retrieveSelectedNodes({ - connectorId: this.connectorId, - }); + const selectedNodes = await retrieveSelectedNodes({ connectorId }); return new Ok(selectedNodes); } try { - const brandNodes = await retrieveZendeskBrandPermissions({ - connectorId: this.connectorId, - parentInternalId, - filterPermission, - viewType: "documents", - }); - const helpCenterNodes = await retrieveZendeskHelpCenterPermissions({ - connectorId: this.connectorId, - parentInternalId, - filterPermission, - viewType: "documents", - }); - const ticketNodes = await retrieveZendeskTicketPermissions({ - connectorId: this.connectorId, - parentInternalId, - filterPermission, - viewType: "documents", - }); - return new Ok([...brandNodes, ...helpCenterNodes, ...ticketNodes]); + return new Ok( + await retrieveChildrenNodes({ + connectorId, + parentInternalId, + filterPermission, + viewType: "documents", + }) + ); } catch (e) { return new Err(e as Error); } @@ -144,12 +137,11 @@ export class ZendeskConnectorManager extends BaseConnectorManager { }: { permissions: Record; }): Promise> { + const connectorId = this.connectorId; + const connector = await ConnectorResource.fetchById(this.connectorId); if (!connector) { - logger.error( - { connectorId: this.connectorId }, - "[Zendesk] Connector not found." - ); + logger.error({ connectorId }, "[Zendesk] Connector not found."); return new Err(new Error("Connector not found")); } @@ -159,7 +151,7 @@ export class ZendeskConnectorManager extends BaseConnectorManager { ); if (!zendeskConfiguration) { logger.error( - { connectorId: this.connectorId }, + { connectorId }, "[Zendesk] ZendeskConfiguration not found. Cannot set permissions." ); return new Err(new Error("ZendeskConfiguration not found")); @@ -170,102 +162,117 @@ export class ZendeskConnectorManager extends BaseConnectorManager { const toBeSignaledTicketsIds = new Set(); const toBeSignaledHelpCenterIds = new Set(); const toBeSignaledCategoryIds = new Set(); + for (const [id, permission] of Object.entries(permissions)) { if (permission !== "none" && permission !== "read") { return new Err( new Error( - `Invalid permission ${permission} for connector ${this.connectorId}` + `Invalid permission ${permission} for connector ${connectorId}` ) ); } - const brandId = getBrandIdFromInternalId(this.connectorId, id); - const brandHelpCenterId = getBrandIdFromHelpCenterId( - this.connectorId, - id - ); - const brandTicketsId = getBrandIdFromTicketsId(this.connectorId, id); - const categoryId = getCategoryIdFromInternalId(this.connectorId, id); - - if (brandId) { - toBeSignaledBrandIds.add(brandId); - if (permission === "none") { - await revokeSyncZendeskBrand({ - connectorId: this.connectorId, - brandId, - }); - } - if (permission === "read") { - await allowSyncZendeskBrand({ - subdomain, - connectorId: this.connectorId, - connectionId, - brandId, - }); - } - } else if (brandHelpCenterId) { - if (permission === "none") { - const revokedCollection = await revokeSyncZendeskHelpCenter({ - connectorId: this.connectorId, - brandId: brandHelpCenterId, - }); - if (revokedCollection) { - toBeSignaledHelpCenterIds.add(revokedCollection.brandId); + const { type, objectId } = getIdFromInternalId(connectorId, id); + switch (type) { + case "brand": { + toBeSignaledBrandIds.add(objectId); + if (permission === "none") { + await revokeSyncZendeskBrand({ + connectorId, + brandId: objectId, + }); } - } - if (permission === "read") { - const newBrand = await allowSyncZendeskHelpCenter({ - connectorId: this.connectorId, - connectionId, - brandId: brandHelpCenterId, - subdomain, - }); - if (newBrand) { - toBeSignaledHelpCenterIds.add(newBrand.brandId); + if (permission === "read") { + await allowSyncZendeskBrand({ + subdomain, + connectorId, + connectionId, + brandId: objectId, + }); } + break; } - } else if (brandTicketsId) { - if (permission === "none") { - const revokedCollection = await revokeSyncZendeskTickets({ - connectorId: this.connectorId, - brandId: brandTicketsId, - }); - if (revokedCollection) { - toBeSignaledTicketsIds.add(revokedCollection.brandId); + case "help-center": { + if (permission === "none") { + const revokedCollection = await revokeSyncZendeskHelpCenter({ + connectorId, + brandId: objectId, + }); + if (revokedCollection) { + toBeSignaledHelpCenterIds.add(revokedCollection.brandId); + } } - } - if (permission === "read") { - const newBrand = await allowSyncZendeskTickets({ - connectorId: this.connectorId, - connectionId, - brandId: brandTicketsId, - subdomain, - }); - if (newBrand) { - toBeSignaledTicketsIds.add(newBrand.brandId); + if (permission === "read") { + const newBrand = await allowSyncZendeskHelpCenter({ + connectorId, + connectionId, + brandId: objectId, + subdomain, + }); + if (newBrand) { + toBeSignaledHelpCenterIds.add(newBrand.brandId); + } } + break; } - } else if (categoryId) { - if (permission === "none") { - const revokedCategory = await revokeSyncZendeskCategory({ - connectorId: this.connectorId, - categoryId, - }); - if (revokedCategory) { - toBeSignaledCategoryIds.add(revokedCategory.categoryId); + case "tickets": { + if (permission === "none") { + const revokedCollection = await revokeSyncZendeskTickets({ + connectorId, + brandId: objectId, + }); + if (revokedCollection) { + toBeSignaledTicketsIds.add(revokedCollection.brandId); + } + } + if (permission === "read") { + const newBrand = await allowSyncZendeskTickets({ + connectorId, + connectionId, + brandId: objectId, + subdomain, + }); + if (newBrand) { + toBeSignaledTicketsIds.add(newBrand.brandId); + } } + break; } - if (permission === "read") { - const newCategory = await allowSyncZendeskCategory({ - subdomain, - connectorId: this.connectorId, - connectionId, - categoryId, - }); - if (newCategory) { - toBeSignaledCategoryIds.add(newCategory.categoryId); + case "category": { + if (permission === "none") { + const revokedCategory = await revokeSyncZendeskCategory({ + connectorId, + categoryId: objectId, + }); + if (revokedCategory) { + toBeSignaledCategoryIds.add(revokedCategory.categoryId); + } + } + if (permission === "read") { + const newCategory = await allowSyncZendeskCategory({ + subdomain, + connectorId, + connectionId, + categoryId: objectId, + }); + if (newCategory) { + toBeSignaledCategoryIds.add(newCategory.categoryId); + } } + break; } + // we do not set permissions for single articles and tickets + case "article": + case "ticket": + logger.error( + { connectorId, objectId }, + "[Zendesk] Cannot set permissions for a single article or ticket" + ); + throw new Error( + "Cannot set permissions for a single article or ticket" + ); + default: + assertNever(type); } } @@ -280,8 +287,78 @@ export class ZendeskConnectorManager extends BaseConnectorManager { internalIds: string[]; viewType: ContentNodesViewType; }): Promise> { - logger.info({ internalIds }, "Retrieving batch content nodes"); - throw new Error("Method not implemented."); + const brandIds: number[] = []; + const brandHelpCenterIds: number[] = []; + const brandTicketsIds: number[] = []; + const categoryIds: number[] = []; + internalIds.forEach((internalId) => { + const { type, objectId } = getIdFromInternalId( + this.connectorId, + internalId + ); + switch (type) { + case "brand": { + brandIds.push(objectId); + return; + } + case "tickets": { + brandTicketsIds.push(objectId); + return; + } + case "help-center": { + brandHelpCenterIds.push(objectId); + return; + } + case "category": { + categoryIds.push(objectId); + return; + } + case "article": + case "ticket": { + logger.error( + { connectorId, objectId }, + "[Zendesk] Cannot retrieve single articles or tickets" + ); + throw new Error("Cannot retrieve single articles or tickets"); + } + default: { + assertNever(type); + } + } + }); + + const connectorId = this.connectorId; + + const allBrandIds = [ + ...new Set([...brandIds, ...brandTicketsIds, ...brandHelpCenterIds]), + ]; + const allBrands = await ZendeskBrandResource.fetchByBrandIds({ + connectorId, + brandIds: allBrandIds, + }); + const brands = allBrands.filter((brand) => + brandIds.includes(brand.brandId) + ); + const helpCenters = allBrands.filter((brand) => + brandHelpCenterIds.includes(brand.brandId) + ); + const tickets = allBrands.filter((brand) => + brandTicketsIds.includes(brand.brandId) + ); + + const categories = await ZendeskCategoryResource.fetchByCategoryIds({ + connectorId, + categoryIds, + }); + + return new Ok([ + ...brands.map((brand) => brand.toContentNode({ connectorId })), + ...helpCenters.map((brand) => + brand.getHelpCenterContentNode({ connectorId }) + ), + ...tickets.map((brand) => brand.getTicketsContentNode({ connectorId })), + ...categories.map((category) => category.toContentNode({ connectorId })), + ]); } /** @@ -294,8 +371,81 @@ export class ZendeskConnectorManager extends BaseConnectorManager { internalId: string; memoizationKey?: string; }): Promise> { - logger.info({ internalId }, "Retrieving content node parents"); - throw new Error("Method not implemented."); + const connectorId = this.connectorId; + + const { type, objectId } = getIdFromInternalId(connectorId, internalId); + switch (type) { + case "brand": { + return new Ok([internalId]); + } + /// Help Centers and tickets are just beneath their brands, so they have one parent. + case "help-center": + case "tickets": { + return new Ok([internalId, getBrandInternalId(connectorId, objectId)]); + } + /// Categories have two parents: the Help Center and the brand. + case "category": { + const category = await ZendeskCategoryResource.fetchByCategoryId({ + connectorId, + categoryId: objectId, + }); + if (category) { + return new Ok([ + internalId, + getHelpCenterInternalId(connectorId, category.brandId), + getBrandInternalId(connectorId, category.brandId), + ]); + } else { + logger.error( + { connectorId, categoryId: objectId }, + "[Zendesk] Category not found" + ); + return new Err(new Error("Category not found")); + } + } + /// Articles have three parents: the category, the Help Center and the brand. + case "article": { + const article = await ZendeskArticleResource.fetchByArticleId({ + connectorId, + articleId: objectId, + }); + if (article) { + return new Ok([ + internalId, + getCategoryInternalId(connectorId, article.categoryId), + getHelpCenterInternalId(connectorId, article.brandId), + getBrandInternalId(connectorId, article.brandId), + ]); + } else { + logger.error( + { connectorId, articleId: objectId }, + "[Zendesk] Article not found" + ); + return new Err(new Error("Article not found")); + } + } + case "ticket": { + const ticket = await ZendeskTicketResource.fetchByTicketId({ + connectorId, + ticketId: objectId, + }); + if (ticket) { + return new Ok([ + internalId, + getTicketsInternalId(connectorId, ticket.brandId), + getBrandInternalId(connectorId, ticket.brandId), + ]); + } else { + logger.error( + { connectorId, ticketId: objectId }, + "[Zendesk] Ticket not found" + ); + return new Err(new Error("Ticket not found")); + } + } + default: + assertNever(type); + } } async setConfigurationKey({ diff --git a/connectors/src/connectors/zendesk/lib/brand_permissions.ts b/connectors/src/connectors/zendesk/lib/brand_permissions.ts index 13b684e1d12b..3f6cdbc967e6 100644 --- a/connectors/src/connectors/zendesk/lib/brand_permissions.ts +++ b/connectors/src/connectors/zendesk/lib/brand_permissions.ts @@ -1,17 +1,10 @@ -import type { - ConnectorPermission, - ContentNode, - ContentNodesViewType, - ModelId, -} from "@dust-tt/types"; +import type { ModelId } from "@dust-tt/types"; import { allowSyncZendeskHelpCenter } from "@connectors/connectors/zendesk/lib/help_center_permissions"; -import { getBrandInternalId } from "@connectors/connectors/zendesk/lib/id_conversions"; import { allowSyncZendeskTickets } from "@connectors/connectors/zendesk/lib/ticket_permissions"; import { getZendeskAccessToken } from "@connectors/connectors/zendesk/lib/zendesk_access_token"; import { createZendeskClient } from "@connectors/connectors/zendesk/lib/zendesk_api"; import logger from "@connectors/logger/logger"; -import { ConnectorResource } from "@connectors/resources/connector_resource"; import { ZendeskBrandResource } from "@connectors/resources/zendesk_resources"; /** @@ -106,70 +99,3 @@ export async function revokeSyncZendeskBrand({ await brand.revokePermissions(); return brand; } - -export async function retrieveZendeskBrandPermissions({ - connectorId, - parentInternalId, - filterPermission, -}: { - connectorId: ModelId; - parentInternalId: string | null; - filterPermission: ConnectorPermission | null; - viewType: ContentNodesViewType; -}): Promise { - const connector = await ConnectorResource.fetchById(connectorId); - if (!connector) { - logger.error({ connectorId }, "[Zendesk] Connector not found."); - throw new Error("Connector not found"); - } - - const isReadPermissionsOnly = filterPermission === "read"; - const isRootLevel = !parentInternalId; - let nodes: ContentNode[] = []; - - // At the root level, we show one node for each brand. - if (isRootLevel) { - if (isReadPermissionsOnly) { - const brandsInDatabase = await ZendeskBrandResource.fetchAllReadOnly({ - connectorId, - }); - nodes = brandsInDatabase.map((brand) => ({ - provider: connector.type, - internalId: getBrandInternalId(connectorId, brand.brandId), - parentInternalId: null, - type: "folder", - title: brand.name, - sourceUrl: brand.url, - expandable: true, - permission: - brand.helpCenterPermission === "read" && - brand.ticketsPermission === "read" - ? "read" - : "none", - dustDocumentId: null, - lastUpdatedAt: brand.updatedAt.getTime(), - })); - } else { - const token = await getZendeskAccessToken(connector.connectionId); - const zendeskApiClient = createZendeskClient({ token }); - - const { result: brands } = await zendeskApiClient.brand.list(); - nodes = brands - .filter((brand) => brand.has_help_center) - .map((brand) => ({ - provider: connector.type, - internalId: getBrandInternalId(connectorId, brand.id), - parentInternalId: null, - type: "folder", - title: brand.name || "Brand", - sourceUrl: brand.brand_url, - expandable: true, - permission: "none", - dustDocumentId: null, - lastUpdatedAt: null, - })); - } - } - nodes.sort((a, b) => a.title.localeCompare(b.title)); - return nodes; -} diff --git a/connectors/src/connectors/zendesk/lib/help_center_permissions.ts b/connectors/src/connectors/zendesk/lib/help_center_permissions.ts index 02bee2b6210c..e0c3fbbdbe70 100644 --- a/connectors/src/connectors/zendesk/lib/help_center_permissions.ts +++ b/connectors/src/connectors/zendesk/lib/help_center_permissions.ts @@ -1,25 +1,11 @@ -import type { - ConnectorPermission, - ContentNode, - ContentNodesViewType, - ModelId, -} from "@dust-tt/types"; +import type { ModelId } from "@dust-tt/types"; -import { - getArticleInternalId, - getBrandIdFromHelpCenterId, - getBrandIdFromInternalId, - getCategoryIdFromInternalId, - getCategoryInternalId, - getHelpCenterInternalId, -} from "@connectors/connectors/zendesk/lib/id_conversions"; import { getZendeskAccessToken } from "@connectors/connectors/zendesk/lib/zendesk_access_token"; import { changeZendeskClientSubdomain, createZendeskClient, } from "@connectors/connectors/zendesk/lib/zendesk_api"; import logger from "@connectors/logger/logger"; -import { ConnectorResource } from "@connectors/resources/connector_resource"; import { ZendeskBrandResource, ZendeskCategoryResource, @@ -192,147 +178,3 @@ export async function revokeSyncZendeskCategory({ await category.revokePermissions(); return category; } - -export async function retrieveZendeskHelpCenterPermissions({ - connectorId, - parentInternalId, - filterPermission, -}: { - connectorId: ModelId; - parentInternalId: string | null; - filterPermission: ConnectorPermission | null; - viewType: ContentNodesViewType; -}): Promise { - const connector = await ConnectorResource.fetchById(connectorId); - if (!connector) { - logger.error({ connectorId }, "[Zendesk] Connector not found."); - throw new Error("Connector not found"); - } - - const token = await getZendeskAccessToken(connector.connectionId); - const zendeskApiClient = createZendeskClient({ token }); - - const isReadPermissionsOnly = filterPermission === "read"; - const isRootLevel = !parentInternalId; - let nodes: ContentNode[] = []; - - // There is no help center at the root level. - if (isRootLevel) { - return []; - } - - // If the parent is a Brand, we return a single node for its help center if it has one. - let brandId = getBrandIdFromInternalId(connectorId, parentInternalId); - if (brandId) { - let hasHelpCenter = false; - if (isReadPermissionsOnly) { - const brandInDatabase = await ZendeskBrandResource.fetchByBrandId({ - connectorId, - brandId, - }); - hasHelpCenter = brandInDatabase !== null && brandInDatabase.hasHelpCenter; - } else { - try { - const fetchedBrand = await zendeskApiClient.brand.show(brandId); - hasHelpCenter = fetchedBrand.result.brand.has_help_center; - } catch (e) { - logger.error( - { connectorId, brandId }, - "[Zendesk] Could not fetch brand." - ); - } - } - if (hasHelpCenter) { - const helpCenterNode: ContentNode = { - provider: connector.type, - internalId: getHelpCenterInternalId(connectorId, brandId), - parentInternalId: parentInternalId, - type: "database", - title: "Help Center", - sourceUrl: null, - expandable: true, - permission: "none", - dustDocumentId: null, - lastUpdatedAt: null, - }; - return [helpCenterNode]; - } - } - - // If the parent is a brand's help center, we retrieve the list of Categories for this brand. - // If isReadPermissionsOnly, we retrieve the list of Categories from the DB that have permission == "read" - // If isReadPermissionsOnly, we retrieve the list of Categories from Zendesk - brandId = getBrandIdFromHelpCenterId(connectorId, parentInternalId); - if (brandId) { - const categoriesInDatabase = - await ZendeskBrandResource.fetchReadOnlyCategories({ - connectorId, - brandId, - }); - if (isReadPermissionsOnly) { - nodes = categoriesInDatabase.map((category) => ({ - provider: connector.type, - internalId: getCategoryInternalId(connectorId, category.categoryId), - parentInternalId: parentInternalId, - type: "folder", - title: category.name, - sourceUrl: category.url, - expandable: false, - permission: category.permission, - dustDocumentId: null, - lastUpdatedAt: category.updatedAt.getTime(), - })); - } else { - await changeZendeskClientSubdomain({ - client: zendeskApiClient, - brandId, - }); - const categories = await zendeskApiClient.helpcenter.categories.list(); - nodes = categories.map((category) => { - const matchingDbEntry = categoriesInDatabase.find( - (c) => c.categoryId === category.id - ); - return { - provider: connector.type, - internalId: getCategoryInternalId(connectorId, category.id), - parentInternalId: parentInternalId, - type: "folder", - title: category.name, - sourceUrl: category.html_url, - - expandable: false, - permission: matchingDbEntry ? "read" : "none", - dustDocumentId: null, - lastUpdatedAt: matchingDbEntry?.updatedAt.getTime() ?? null, - }; - }); - } - } - - // If the parent is a category, we retrieve the list of articles for this category. - // If isReadPermissionsOnly = true, we retrieve the list of Articles from the DB that have permission == "read" - // If isReadPermissionsOnly = false, we do not show anything. - const categoryId = getCategoryIdFromInternalId(connectorId, parentInternalId); - if (categoryId && isReadPermissionsOnly) { - const articlesInDatabase = - await ZendeskCategoryResource.fetchReadOnlyArticles({ - connectorId, - categoryId, - }); - nodes = articlesInDatabase.map((article) => ({ - provider: connector.type, - internalId: getArticleInternalId(connectorId, article.categoryId), - parentInternalId: parentInternalId, - type: "file", - title: article.name, - sourceUrl: article.url, - expandable: false, - permission: article.permission, - dustDocumentId: null, - lastUpdatedAt: article.updatedAt.getTime(), - })); - } - - nodes.sort((a, b) => a.title.localeCompare(b.title)); - return nodes; -} diff --git a/connectors/src/connectors/zendesk/lib/id_conversions.ts b/connectors/src/connectors/zendesk/lib/id_conversions.ts index eed462f14e83..1e60bce4e5d8 100644 --- a/connectors/src/connectors/zendesk/lib/id_conversions.ts +++ b/connectors/src/connectors/zendesk/lib/id_conversions.ts @@ -1,5 +1,7 @@ import type { ModelId } from "@dust-tt/types"; +import logger from "@connectors/logger/logger"; + /** * Conversion from an id to an internalId. */ @@ -45,13 +47,6 @@ export function getTicketInternalId( return `zendesk-ticket-${connectorId}-${teamId}`; } -export function getConversationInternalId( - connectorId: ModelId, - conversationId: number -): string { - return `zendesk-category-${connectorId}-${conversationId}`; -} - /** * Conversion from an internalId to an id. */ @@ -61,14 +56,57 @@ function _getIdFromInternal(internalId: string, prefix: string): number | null { : null; } -export function getBrandIdFromInternalId( +export type InternalIdType = + | "brand" + | "help-center" + | "tickets" + | "category" + | "article" + | "ticket"; + +export function getIdFromInternalId( + connectorId: ModelId, + internalId: string +): { type: InternalIdType; objectId: number } { + let objectId = getBrandIdFromInternalId(connectorId, internalId); + if (objectId) { + return { type: "brand", objectId }; + } + objectId = getBrandIdFromHelpCenterId(connectorId, internalId); + if (objectId) { + return { type: "help-center", objectId }; + } + objectId = getBrandIdFromTicketsId(connectorId, internalId); + if (objectId) { + return { type: "tickets", objectId }; + } + objectId = getCategoryIdFromInternalId(connectorId, internalId); + if (objectId) { + return { type: "category", objectId }; + } + objectId = getArticleIdFromInternalId(connectorId, internalId); + if (objectId) { + return { type: "article", objectId }; + } + objectId = getTicketIdFromInternalId(connectorId, internalId); + if (objectId) { + return { type: "ticket", objectId }; + } + logger.error( + { connectorId, internalId }, + "[Zendesk] Internal ID not recognized" + ); + throw new Error("Internal ID not recognized"); +} + +function getBrandIdFromInternalId( connectorId: ModelId, internalId: string ): number | null { return _getIdFromInternal(internalId, `zendesk-brand-${connectorId}-`); } -export function getBrandIdFromHelpCenterId( +function getBrandIdFromHelpCenterId( connectorId: ModelId, helpCenterInternalId: string ): number | null { @@ -78,21 +116,21 @@ export function getBrandIdFromHelpCenterId( ); } -export function getCategoryIdFromInternalId( +function getCategoryIdFromInternalId( connectorId: ModelId, internalId: string ): number | null { return _getIdFromInternal(internalId, `zendesk-category-${connectorId}-`); } -export function getArticleIdFromInternalId( +function getArticleIdFromInternalId( connectorId: ModelId, internalId: string ): number | null { return _getIdFromInternal(internalId, `zendesk-article-${connectorId}-`); } -export function getBrandIdFromTicketsId( +function getBrandIdFromTicketsId( connectorId: ModelId, ticketsInternalId: string ): number | null { @@ -102,7 +140,7 @@ export function getBrandIdFromTicketsId( ); } -export function getTicketIdFromInternalId( +function getTicketIdFromInternalId( connectorId: ModelId, internalId: string ): number | null { diff --git a/connectors/src/connectors/zendesk/lib/permissions.ts b/connectors/src/connectors/zendesk/lib/permissions.ts index 81a5aa308eea..2f30c5fcd861 100644 --- a/connectors/src/connectors/zendesk/lib/permissions.ts +++ b/connectors/src/connectors/zendesk/lib/permissions.ts @@ -1,11 +1,23 @@ -import type { ContentNode, ModelId } from "@dust-tt/types"; +import type { + ConnectorPermission, + ContentNode, + ContentNodesViewType, + ModelId, +} from "@dust-tt/types"; +import { assertNever } from "@dust-tt/types"; import { getBrandInternalId, getCategoryInternalId, getHelpCenterInternalId, + getIdFromInternalId, getTicketsInternalId, } from "@connectors/connectors/zendesk/lib/id_conversions"; +import { getZendeskAccessToken } from "@connectors/connectors/zendesk/lib/zendesk_access_token"; +import { + changeZendeskClientSubdomain, + createZendeskClient, +} from "@connectors/connectors/zendesk/lib/zendesk_api"; import logger from "@connectors/logger/logger"; import { ConnectorResource } from "@connectors/resources/connector_resource"; import { @@ -53,49 +65,17 @@ export async function retrieveSelectedNodes({ const helpCenterNodes: ContentNode[] = brands .filter((brand) => brand.hasHelpCenter) - .map((brand) => ({ - provider: connector.type, - internalId: getHelpCenterInternalId(connectorId, brand.id), - parentInternalId: getBrandInternalId(connectorId, brand.brandId), - type: "database", - title: "Help Center", - sourceUrl: null, - expandable: true, - permission: brand.helpCenterPermission, - dustDocumentId: null, - lastUpdatedAt: brand.updatedAt.getTime(), - })); + .map((brand) => brand.getHelpCenterContentNode({ connectorId })); const categories = await ZendeskCategoryResource.fetchAllReadOnly({ connectorId, }); - const categoriesNodes: ContentNode[] = categories.map((category) => { - return { - provider: connector.type, - internalId: getCategoryInternalId(connectorId, category.categoryId), - parentInternalId: getHelpCenterInternalId(connectorId, category.brandId), - type: "folder", - title: category.name, - sourceUrl: category.url, - expandable: false, - permission: category.permission, - dustDocumentId: null, - lastUpdatedAt: category.updatedAt.getTime() || null, - }; - }); - - const ticketNodes: ContentNode[] = brands.map((brand) => ({ - provider: connector.type, - internalId: getTicketsInternalId(connectorId, brand.id), - parentInternalId: getBrandInternalId(connectorId, brand.brandId), - type: "database", - title: "Tickets", - sourceUrl: null, - expandable: true, - permission: brand.ticketsPermission, - dustDocumentId: null, - lastUpdatedAt: brand.updatedAt.getTime(), - })); + const categoriesNodes: ContentNode[] = categories.map((category) => + category.toContentNode({ connectorId }) + ); + const ticketNodes: ContentNode[] = brands.map((brand) => + brand.getTicketsContentNode({ connectorId }) + ); return [ ...brandNodes, @@ -104,3 +84,180 @@ export async function retrieveSelectedNodes({ ...ticketNodes, ]; } + +export async function retrieveChildrenNodes({ + connectorId, + parentInternalId, + filterPermission, +}: { + connectorId: ModelId; + parentInternalId: string | null; + filterPermission: ConnectorPermission | null; + viewType: ContentNodesViewType; +}): Promise { + const connector = await ConnectorResource.fetchById(connectorId); + if (!connector) { + logger.error({ connectorId }, "[Zendesk] Connector not found."); + throw new Error("Connector not found"); + } + + const isReadPermissionsOnly = filterPermission === "read"; + let nodes: ContentNode[] = []; + + const token = await getZendeskAccessToken(connector.connectionId); + const zendeskApiClient = createZendeskClient({ token }); + + // At the root level, we show one node for each brand. + if (!parentInternalId) { + if (isReadPermissionsOnly) { + const brandsInDatabase = + await ZendeskBrandResource.fetchBrandsWithHelpCenter({ connectorId }); + nodes = brandsInDatabase.map((brand) => + brand.toContentNode({ connectorId }) + ); + } else { + const { result: brands } = await zendeskApiClient.brand.list(); + nodes = brands + .filter((brand) => brand.has_help_center) + .map((brand) => ({ + provider: connector.type, + internalId: getBrandInternalId(connectorId, brand.id), + parentInternalId: null, + type: "folder", + title: brand.name || "Brand", + sourceUrl: brand.brand_url, + expandable: true, + permission: "none", + dustDocumentId: null, + lastUpdatedAt: null, + })); + } + } else { + const { type, objectId } = getIdFromInternalId( + connectorId, + parentInternalId + ); + switch (type) { + // If the parent is a Brand, we return a node for its tickets and one for its help center. + case "brand": { + const ticketsNode: ContentNode = { + provider: connector.type, + internalId: getTicketsInternalId(connectorId, objectId), + parentInternalId: parentInternalId, + type: "folder", + title: "Tickets", + sourceUrl: null, + expandable: false, + permission: "none", + dustDocumentId: null, + lastUpdatedAt: null, + }; + nodes.push(ticketsNode); + + let hasHelpCenter = false; + if (isReadPermissionsOnly) { + const brandInDatabase = await ZendeskBrandResource.fetchByBrandId({ + connectorId, + brandId: objectId, + }); + hasHelpCenter = + brandInDatabase !== null && brandInDatabase.hasHelpCenter; + } else { + const fetchedBrand = await zendeskApiClient.brand.show(objectId); + hasHelpCenter = fetchedBrand.result.brand.has_help_center; + } + if (hasHelpCenter) { + const helpCenterNode: ContentNode = { + provider: connector.type, + internalId: getHelpCenterInternalId(connectorId, objectId), + parentInternalId: parentInternalId, + type: "folder", + title: "Help Center", + sourceUrl: null, + expandable: true, + permission: "none", + dustDocumentId: null, + lastUpdatedAt: null, + }; + nodes.push(helpCenterNode); + } + break; + } + // If the parent is a brand's tickets, we retrieve the list of tickets for the brand. + case "tickets": { + if (isReadPermissionsOnly) { + const ticketsInDb = await ZendeskBrandResource.fetchReadOnlyTickets({ + connectorId, + brandId: objectId, + }); + nodes = ticketsInDb.map((ticket) => + ticket.toContentNode({ connectorId }) + ); + } + break; + } + // If the parent is a brand's help center, we retrieve the list of Categories for this brand. + case "help-center": { + const categoriesInDatabase = + await ZendeskBrandResource.fetchReadOnlyCategories({ + connectorId, + brandId: objectId, + }); + if (isReadPermissionsOnly) { + nodes = categoriesInDatabase.map((category) => + category.toContentNode({ connectorId }) + ); + } else { + await changeZendeskClientSubdomain({ + client: zendeskApiClient, + brandId: objectId, + }); + const categories = + await zendeskApiClient.helpcenter.categories.list(); + nodes = categories.map((category) => { + const matchingDbEntry = categoriesInDatabase.find( + (c) => c.categoryId === category.id + ); + return { + provider: connector.type, + internalId: getCategoryInternalId(connectorId, category.id), + parentInternalId: parentInternalId, + type: "folder", + title: category.name, + sourceUrl: category.html_url, + + expandable: false, + permission: matchingDbEntry ? "read" : "none", + dustDocumentId: null, + lastUpdatedAt: matchingDbEntry?.updatedAt.getTime() ?? null, + }; + }); + } + break; + } + // If the parent is a category, we retrieve the list of articles for this category. + case "category": { + if (isReadPermissionsOnly) { + const articlesInDb = + await ZendeskCategoryResource.fetchReadOnlyArticles({ + connectorId, + categoryId: objectId, + }); + nodes = articlesInDb.map((article) => + article.toContentNode({ connectorId }) + ); + } + break; + } + // Single tickets and articles have no children. + case "ticket": + case "article": + return []; + default: + assertNever(type); + } + } + + nodes.sort((a, b) => a.title.localeCompare(b.title)); + return nodes; +} diff --git a/connectors/src/connectors/zendesk/lib/ticket_permissions.ts b/connectors/src/connectors/zendesk/lib/ticket_permissions.ts index 3cbe4fcd42b4..c1cfec466ce9 100644 --- a/connectors/src/connectors/zendesk/lib/ticket_permissions.ts +++ b/connectors/src/connectors/zendesk/lib/ticket_permissions.ts @@ -1,20 +1,8 @@ -import type { - ConnectorPermission, - ContentNode, - ContentNodesViewType, - ModelId, -} from "@dust-tt/types"; +import type { ModelId } from "@dust-tt/types"; -import { - getBrandIdFromInternalId, - getBrandIdFromTicketsId, - getTicketInternalId, - getTicketsInternalId, -} from "@connectors/connectors/zendesk/lib/id_conversions"; import { getZendeskAccessToken } from "@connectors/connectors/zendesk/lib/zendesk_access_token"; import { createZendeskClient } from "@connectors/connectors/zendesk/lib/zendesk_api"; import logger from "@connectors/logger/logger"; -import { ConnectorResource } from "@connectors/resources/connector_resource"; import { ZendeskBrandResource } from "@connectors/resources/zendesk_resources"; export async function allowSyncZendeskTickets({ @@ -96,71 +84,3 @@ export async function revokeSyncZendeskTickets({ await brand.revokeTicketsPermissions(); return brand; } - -export async function retrieveZendeskTicketPermissions({ - connectorId, - parentInternalId, - filterPermission, -}: { - connectorId: ModelId; - parentInternalId: string | null; - filterPermission: ConnectorPermission | null; - viewType: ContentNodesViewType; -}): Promise { - const connector = await ConnectorResource.fetchById(connectorId); - if (!connector) { - logger.error({ connectorId }, "[Zendesk] Connector not found."); - throw new Error("Connector not found"); - } - - const isRootLevel = !parentInternalId; - - // There is no ticket at the root level, only the brands. - if (isRootLevel) { - return []; - } - let brandId = getBrandIdFromInternalId(connectorId, parentInternalId); - // If the parent is a Brand, we return a single node for its help center. - if (brandId) { - const ticketsNode: ContentNode = { - provider: connector.type, - internalId: getTicketsInternalId(connectorId, brandId), - parentInternalId: parentInternalId, - type: "database", - title: "Tickets", - sourceUrl: null, - expandable: false, - permission: "none", - dustDocumentId: null, - lastUpdatedAt: null, - }; - - return [ticketsNode]; - } - - // If the parent is a brand's tickets, we retrieve the list of tickets for the brand. - // In read-only mode, we retrieve the list of Tickets from the DB that have permission == "read" - // Otherwise, we do not show anything. - brandId = getBrandIdFromTicketsId(connectorId, parentInternalId); - if (brandId && filterPermission === "read") { - const ticketsInDatabase = await ZendeskBrandResource.fetchReadOnlyTickets({ - connectorId, - brandId, - }); - const nodes: ContentNode[] = ticketsInDatabase.map((ticket) => ({ - provider: connector.type, - internalId: getTicketInternalId(connectorId, ticket.ticketId), - parentInternalId: parentInternalId, - type: "file", - title: ticket.name, - sourceUrl: ticket.url, - expandable: false, - permission: ticket.permission, - dustDocumentId: null, - lastUpdatedAt: ticket.updatedAt.getTime(), - })); - nodes.sort((a, b) => a.title.localeCompare(b.title)); - return nodes; - } - return []; -} diff --git a/connectors/src/resources/zendesk_resources.ts b/connectors/src/resources/zendesk_resources.ts index 9f5df74d2493..47df88008614 100644 --- a/connectors/src/resources/zendesk_resources.ts +++ b/connectors/src/resources/zendesk_resources.ts @@ -1,4 +1,4 @@ -import type { Result } from "@dust-tt/types"; +import type { ContentNode, Result } from "@dust-tt/types"; import { Ok } from "@dust-tt/types"; import type { Attributes, @@ -6,7 +6,16 @@ import type { ModelStatic, Transaction, } from "sequelize"; +import { Op } from "sequelize"; +import { + getArticleInternalId, + getBrandInternalId, + getCategoryInternalId, + getHelpCenterInternalId, + getTicketInternalId, + getTicketsInternalId, +} from "@connectors/connectors/zendesk/lib/id_conversions"; import { ZendeskArticle, ZendeskBrand, @@ -168,18 +177,32 @@ export class ZendeskBrandResource extends BaseResource { return blob && new this(this.model, blob.get()); } + static async fetchByBrandIds({ + connectorId, + brandIds, + }: { + connectorId: number; + brandIds: number[]; + }): Promise { + const brands = await ZendeskBrand.findAll({ + where: { connectorId, brandId: { [Op.in]: brandIds } }, + }); + return brands.map((brand) => new this(this.model, brand.get())); + } + static async fetchAllReadOnly({ connectorId, }: { connectorId: number; }): Promise { - return ZendeskBrand.findAll({ + const brands = await ZendeskBrand.findAll({ where: { connectorId, helpCenterPermission: "read", ticketsPermission: "read", }, - }).then((brands) => brands.map((brand) => new this(this.model, brand))); + }); + return brands.map((brand) => new this(this.model, brand.get())); } static async fetchReadOnlyTickets({ @@ -188,10 +211,13 @@ export class ZendeskBrandResource extends BaseResource { }: { connectorId: number; brandId: number; - }): Promise { - return ZendeskTicket.findAll({ + }): Promise { + const tickets = await ZendeskTicket.findAll({ where: { connectorId, brandId, permission: "read" }, }); + return tickets.map( + (ticket) => new ZendeskTicketResource(ZendeskTicket, ticket) + ); } static async fetchReadOnlyCategories({ @@ -201,14 +227,82 @@ export class ZendeskBrandResource extends BaseResource { connectorId: number; brandId: number; }): Promise { - return ZendeskCategory.findAll({ + const categories = await ZendeskCategory.findAll({ where: { connectorId, brandId, permission: "read" }, - }).then((categories) => - categories.map( - (category) => new ZendeskCategoryResource(ZendeskCategory, category) - ) + }); + return categories.map( + (category) => + category && new ZendeskCategoryResource(ZendeskCategory, category) ); } + + static async fetchBrandsWithHelpCenter({ + connectorId, + }: { + connectorId: number; + }): Promise { + const brands = await ZendeskBrand.findAll({ + where: { + connectorId: connectorId, + helpCenterPermission: "read", + hasHelpCenter: true, + }, + }); + return brands.map((brand) => new this(this.model, brand.get())); + } + + toContentNode({ connectorId }: { connectorId: number }): ContentNode { + return { + provider: "zendesk", + internalId: getBrandInternalId(connectorId, this.brandId), + parentInternalId: null, + type: "folder", + title: this.name, + sourceUrl: this.url, + expandable: true, + permission: + this.helpCenterPermission === "read" && + this.ticketsPermission === "read" + ? "read" + : "none", + dustDocumentId: null, + lastUpdatedAt: this.updatedAt.getTime(), + }; + } + + getHelpCenterContentNode({ + connectorId, + }: { + connectorId: number; + }): ContentNode { + return { + provider: "zendesk", + internalId: getHelpCenterInternalId(connectorId, this.brandId), + parentInternalId: getBrandInternalId(connectorId, this.brandId), + type: "folder", + title: "Help Center", + sourceUrl: null, + expandable: true, + permission: this.helpCenterPermission, + dustDocumentId: null, + lastUpdatedAt: null, + }; + } + + getTicketsContentNode({ connectorId }: { connectorId: number }): ContentNode { + return { + provider: "zendesk", + internalId: getTicketsInternalId(connectorId, this.brandId), + parentInternalId: getBrandInternalId(connectorId, this.brandId), + type: "folder", + title: "Tickets", + sourceUrl: null, + expandable: false, + permission: this.ticketsPermission, + dustDocumentId: null, + lastUpdatedAt: null, + }; + } } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -280,10 +374,25 @@ export class ZendeskCategoryResource extends BaseResource { connectorId: number; categoryId: number; }): Promise { - const blob = await ZendeskCategory.findOne({ + const category = await ZendeskCategory.findOne({ where: { connectorId, categoryId }, }); - return blob && new this(this.model, blob.get()); + return category && new this(this.model, category.get()); + } + + static async fetchByCategoryIds({ + connectorId, + categoryIds, + }: { + connectorId: number; + categoryIds: number[]; + }): Promise { + const categories = await ZendeskCategory.findAll({ + where: { connectorId, categoryId: { [Op.in]: categoryIds } }, + }); + return categories.map( + (category) => category && new this(this.model, category.get()) + ); } static async fetchAllReadOnly({ @@ -291,11 +400,10 @@ export class ZendeskCategoryResource extends BaseResource { }: { connectorId: number; }): Promise { - return ZendeskCategory.findAll({ + const categories = await ZendeskCategory.findAll({ where: { connectorId, permission: "read" }, - }).then((categories) => - categories.map((category) => new this(this.model, category)) - ); + }); + return categories.map((category) => new this(this.model, category.get())); } static async fetchReadOnlyArticles({ @@ -304,15 +412,184 @@ export class ZendeskCategoryResource extends BaseResource { }: { connectorId: number; categoryId: number; - }): Promise { - return ZendeskArticle.findAll({ + }): Promise { + const articles = await ZendeskArticle.findAll({ where: { connectorId, categoryId, permission: "read" }, }); + return articles.map( + (article) => new ZendeskArticleResource(ZendeskArticle, article) + ); } async revokePermissions(): Promise { - if (this?.permission === "read") { + if (this.permission === "read") { await this.update({ permission: "none" }); } } + + toContentNode({ connectorId }: { connectorId: number }): ContentNode { + return { + provider: "zendesk", + internalId: getCategoryInternalId(connectorId, this.categoryId), + parentInternalId: getHelpCenterInternalId(connectorId, this.brandId), + type: "folder", + title: this.name, + sourceUrl: this.url, + expandable: false, + permission: this.permission, + dustDocumentId: null, + lastUpdatedAt: this.updatedAt.getTime(), + }; + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface ZendeskTicketResource + extends ReadonlyAttributesType {} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class ZendeskTicketResource extends BaseResource { + static model: ModelStatic = ZendeskTicket; + + constructor( + model: ModelStatic, + blob: Attributes + ) { + super(ZendeskTicket, blob); + } + + async postFetchHook(): Promise { + return; + } + + async delete(transaction?: Transaction): Promise> { + await this.model.destroy({ + where: { + connectorId: this.connectorId, + }, + transaction, + }); + return new Ok(undefined); + } + + toJSON(): Record { + return { + id: this.id, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + + name: this.name, + url: this.url, + ticketId: this.ticketId, + brandId: this.brandId, + permission: this.permission, + + connectorId: this.connectorId, + }; + } + + toContentNode({ connectorId }: { connectorId: number }): ContentNode { + return { + provider: "zendesk", + internalId: getTicketInternalId(connectorId, this.ticketId), + parentInternalId: getBrandInternalId(connectorId, this.brandId), + type: "file", + title: this.name, + sourceUrl: this.url, + expandable: false, + permission: this.permission, + dustDocumentId: null, + lastUpdatedAt: this.updatedAt.getTime(), + }; + } + + static async fetchByTicketId({ + connectorId, + ticketId, + }: { + connectorId: number; + ticketId: number; + }): Promise { + const ticket = await ZendeskTicket.findOne({ + where: { connectorId, ticketId }, + }); + return ticket && new this(this.model, ticket.get()); + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface ZendeskArticleResource + extends ReadonlyAttributesType {} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class ZendeskArticleResource extends BaseResource { + static model: ModelStatic = ZendeskArticle; + + constructor( + model: ModelStatic, + blob: Attributes + ) { + super(ZendeskArticle, blob); + } + + async postFetchHook(): Promise { + return; + } + + async delete(transaction?: Transaction): Promise> { + await this.model.destroy({ + where: { + connectorId: this.connectorId, + }, + transaction, + }); + return new Ok(undefined); + } + + toJSON(): Record { + return { + id: this.id, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + + name: this.name, + url: this.url, + articleId: this.articleId, + categoryId: this.categoryId, + brandId: this.brandId, + permission: this.permission, + + connectorId: this.connectorId, + }; + } + + toContentNode({ connectorId }: { connectorId: number }): ContentNode { + return { + provider: "zendesk", + internalId: getArticleInternalId(connectorId, this.articleId), + parentInternalId: getCategoryInternalId(connectorId, this.categoryId), + type: "file", + title: this.name, + sourceUrl: this.url, + expandable: false, + permission: this.permission, + dustDocumentId: null, + lastUpdatedAt: this.updatedAt.getTime(), + }; + } + + static async fetchByArticleId({ + connectorId, + articleId, + }: { + connectorId: number; + articleId: number; + }): Promise { + const article = await ZendeskArticle.findOne({ + where: { connectorId, articleId }, + }); + return article && new this(this.model, article.get()); + } }