From b2f6401ca78c09a198715a258b776118973dfa5e Mon Sep 17 00:00:00 2001 From: oguzhankoral Date: Tue, 22 Jul 2025 17:45:22 +0100 Subject: [PATCH] Webhooks on! --- .../project/page/acc/SyncStatus.vue | 4 +- .../components/project/page/acc/Syncs.vue | 15 +++-- .../acc/graph/resolvers/accSyncItems.ts | 3 +- packages/server/modules/acc/index.ts | 14 ++--- .../modules/acc/repositories/accSyncItems.ts | 58 ++++++++++++++++--- packages/server/modules/acc/webhook.ts | 38 ++++++++---- 6 files changed, 96 insertions(+), 36 deletions(-) diff --git a/packages/frontend-2/components/project/page/acc/SyncStatus.vue b/packages/frontend-2/components/project/page/acc/SyncStatus.vue index d2678e6fa..4d5dc3634 100644 --- a/packages/frontend-2/components/project/page/acc/SyncStatus.vue +++ b/packages/frontend-2/components/project/page/acc/SyncStatus.vue @@ -22,7 +22,7 @@ const runStatusClasses = (run: AccSyncItemStatus) => { case 'SYNCING': classParts.push('bg-info-lighter') break - case 'INITIALIZING': + case 'PENDING': classParts.push('bg-warning-lighter') break case 'PAUSED': @@ -31,7 +31,7 @@ const runStatusClasses = (run: AccSyncItemStatus) => { case 'FAILED': classParts.push('bg-danger-lighter') break - case 'SYNC': + case 'SUCCEEDED': classParts.push('bg-success-lighter') break } diff --git a/packages/frontend-2/components/project/page/acc/Syncs.vue b/packages/frontend-2/components/project/page/acc/Syncs.vue index 93537066a..436474d64 100644 --- a/packages/frontend-2/components/project/page/acc/Syncs.vue +++ b/packages/frontend-2/components/project/page/acc/Syncs.vue @@ -158,7 +158,7 @@ const props = defineProps<{ isLoggedIn: boolean }>() -// const internalSyncs = computed(() => props.syncs) +// TODO ACC: Need to think about data residency from "ACC > Speckle" and warn users accordingly const step = ref(0) @@ -424,20 +424,27 @@ const { mutate: createAccSyncItem } = useMutation(accSyncItemCreateMutation) const addSync = async () => { try { + // annoying but looks like ACC does not give the exact version number directly + const fileVersion = Number( + new URLSearchParams( + selectedFolderContent.value?.latestVersionId?.split('?')[1] + ).get('version') + ) + await createAccSyncItem({ input: { projectId: props.projectId, modelId: selectedModel.value?.id as string, accRegion: selectedHub.value?.attributes?.region as string, - accFileExtension: selectedFolderContent.value?.fileExtension as string, // TODO + accFileExtension: selectedFolderContent.value?.fileExtension as string, accHubId: selectedHubId.value!, accProjectId: selectedProjectId.value as string, accRootProjectFolderId: rootProjectFolderId.value!, accFileLineageId: selectedFolderContent.value?.id as string, accFileName: (selectedFolderContent.value?.attributes.displayName || selectedFolderContent.value?.attributes.name) as string, - accFileVersionIndex: 1, // TODO ACC - accFileVersionUrn: selectedFolderContent.value?.id as string // TODO ACC + accFileVersionIndex: fileVersion, + accFileVersionUrn: selectedFolderContent.value?.latestVersionId as string } }) // TODO: NEED TO GO AWAY WHEN WE HAVE PROPER SUBSCRIPTIONS diff --git a/packages/server/modules/acc/graph/resolvers/accSyncItems.ts b/packages/server/modules/acc/graph/resolvers/accSyncItems.ts index 87d3941eb..4cb42bf39 100644 --- a/packages/server/modules/acc/graph/resolvers/accSyncItems.ts +++ b/packages/server/modules/acc/graph/resolvers/accSyncItems.ts @@ -13,7 +13,6 @@ import { filteredSubscribe, ProjectSubscriptions } from '@/modules/shared/utils/subscriptions' -import { createTestAutomation } from '@/test/speckle-helpers/automationHelper' import cryptoRandomString from 'crypto-random-string' import { GraphQLError } from 'graphql/error' import { Knex } from 'knex' @@ -115,7 +114,7 @@ const resolvers: Resolvers = { await storeAutomationFactory({ db: projectDb })({ id: automationId, - name: "converter", + name: 'converter', userId: ctx.userId!, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/server/modules/acc/index.ts b/packages/server/modules/acc/index.ts index 6236d3ec9..b42e5fb63 100644 --- a/packages/server/modules/acc/index.ts +++ b/packages/server/modules/acc/index.ts @@ -1,6 +1,6 @@ /* eslint-disable camelcase */ import { createAccOidcFlow } from '@/modules/acc/oidcHelper' -import { registerAccWebhook } from '@/modules/acc/webhook' +import { tryRegisterAccWebhook } from '@/modules/acc/webhook' import { sessionMiddlewareFactory } from '@/modules/auth/middleware' import { SpeckleModule } from '@/modules/shared/helpers/typeHelper' import { moduleLogger } from '@/observability/logging' @@ -122,7 +122,7 @@ export default function accRestApi(app: Express) { throw new Error('whatever') } const { access_token } = req.session.accTokens - await registerAccWebhook({ + await tryRegisterAccWebhook({ accessToken: access_token, rootProjectId: accHubUrn, region: 'EMEA', @@ -141,8 +141,8 @@ export default function accRestApi(app: Express) { return res.status(400).send({ error: 'Missing lineageUrn' }) } - const sourceFileVersionIndex = Number.parseInt(req.body?.payload?.version ?? '0') - const sourceFileVersionUrn = req.body?.payload?.source + const accFileVersionIndex = Number.parseInt(req.body?.payload?.version ?? '0') + const accFileVersionUrn = req.body?.payload?.source // TODO ACC: need to know when svf2 is generated, whether with timeout or a webhook that unknown for now @@ -151,11 +151,11 @@ export default function accRestApi(app: Express) { const affectedRows = await db('acc_sync_items') .where({ accFileLineageId: lineageUrn }) - .andWhere(AccSyncItems.col.accFileVersionIndex, '<', sourceFileVersionIndex) + .andWhere(AccSyncItems.col.accFileVersionIndex, '<', accFileVersionIndex) .update({ status: 'PENDING', - sourceFileVersionIndex, - sourceFileVersionUrn + accFileVersionIndex, + accFileVersionUrn }) .returning('*') diff --git a/packages/server/modules/acc/repositories/accSyncItems.ts b/packages/server/modules/acc/repositories/accSyncItems.ts index 8e4a71e0f..16b58c88c 100644 --- a/packages/server/modules/acc/repositories/accSyncItems.ts +++ b/packages/server/modules/acc/repositories/accSyncItems.ts @@ -1,7 +1,6 @@ import { AccSyncItems } from '@/modules/acc/dbSchema' import { AccSyncItemEvents } from '@/modules/acc/domain/events' import { - DeleteAccSyncItem, QueryAllAccSyncItems, UpsertAccSyncItem } from '@/modules/acc/domain/operations' @@ -9,7 +8,7 @@ import { executeBatchedSelect } from '@/modules/shared/helpers/dbHelper' import { AccSyncItem } from '@/modules/acc/domain/types' import { EventBusEmit } from '@/modules/shared/services/eventBus' import { Knex } from 'knex' -import { omit } from 'lodash' +import { tryRegisterAccWebhook } from '@/modules/acc/webhook' const tables = { accSyncItems: (db: Knex) => db(AccSyncItems.name) @@ -19,18 +18,59 @@ export type CreateAccSyncItemAndNotify = ( input: Omit ) => Promise +export const getAutodeskAccessToken = async (): Promise => { + try { + const clientId = process.env.ACC_CLIENT_ID + const clientSecret = process.env.ACC_CLIENT_SECRET + + const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + + const response = await fetch( + 'https://developer.api.autodesk.com/authentication/v2/token', + { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + scope: 'data:read account:read viewables:read' + }).toString() + } + ) + + if (!response.ok) { + const errText = await response.text() + throw new Error(`Failed to get access token: ${response.status} ${errText}`) + } + + const json = await response.json() + if (!json.access_token) { + throw new Error('access token is not found') + } + return json.access_token as string + } catch (error) { + console.log(error) + throw error + } +} + export const createAccSyncItemAndNotifyFactory = (deps: { db: Knex eventEmit: EventBusEmit }): CreateAccSyncItemAndNotify => { return async (input) => { - // TODO ACC: register webhook if it is not yet - // const accWebhook = await registerAccWebhook({ - // accessToken: '', // TODO ACC: get the token from 2legged server-to-server auth - // rootProjectId: input.accRootProjectFolderId, - // region: input.accRegion, - // event: 'dm.version.added' // NOTE ACC: you can register an event only once - // }) + // TODO ACC: we might need to cache the retrieved token to prevent rate limiting etc. + + const token = await getAutodeskAccessToken() + await tryRegisterAccWebhook({ + accessToken: token, // TODO ACC: get the token from 2legged server-to-server auth + rootProjectId: input.accRootProjectFolderId, + region: input.accRegion, + event: 'dm.version.added' // NOTE ACC: you can register an event only once + }) // TODO ACC: get webhook id and store it in item -> not sure it is make sense to have many webhooks per file as `/acc/webhook/callback/:filelineageUrn` // TODO ACC: trigger automation and update status of sync item diff --git a/packages/server/modules/acc/webhook.ts b/packages/server/modules/acc/webhook.ts index 64232a8b8..97a9fa2b4 100644 --- a/packages/server/modules/acc/webhook.ts +++ b/packages/server/modules/acc/webhook.ts @@ -1,4 +1,5 @@ import { isDevEnv } from '@/modules/shared/helpers/envHelper' +import { logger } from '@/observability/logging' const tailscaleUrl = 'https://oguzhans-macbook-pro.mermaid-emperor.ts.net' // TODO ACC for dev: Get your local url from tailscale and then we will got rid of @@ -6,9 +7,9 @@ const accWebhookCallbackUrl = `${ isDevEnv() ? tailscaleUrl : process.env.FRONTEND_HOST }/acc/webhook/callback` -export async function registerAccWebhook({ +export async function tryRegisterAccWebhook({ accessToken, - rootProjectId: hubUrn, + rootProjectId, region, event }: { @@ -17,6 +18,14 @@ export async function registerAccWebhook({ region: string event: string }) { + const body = { + callbackUrl: accWebhookCallbackUrl, + scope: { + folder: rootProjectId + } + } + logger.info(body) + logger.info(accessToken) const response = await fetch( `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`, { @@ -24,21 +33,26 @@ export async function registerAccWebhook({ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', - 'x-ads-region': `${region}` + 'x-ads-region': region }, - body: JSON.stringify({ - callbackUrl: `${accWebhookCallbackUrl}/${event}`, - scope: { - folder: { - hubUrn - } - } - }) + body: JSON.stringify(body) } ) if (!response.ok) { - throw new Error(`Webhook registration failed: ${await response.text()}`) + const errJson = await response.json().catch(() => null) + + const isConflict = + response.status === 409 && + errJson?.code === 'CONFLICT_ERROR' && + errJson?.detail?.includes('Failed to save duplicate webhooks scope') + + if (isConflict) { + logger.warn('Webhook already exists. Skipping registration.') + return null // Swallow and return + } + + throw new Error(`Webhook registration failed: ${JSON.stringify(errJson, null, 2)}`) } const res = await response.json()