diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index de433b45da..5437a5f45b 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -38292,6 +38292,9 @@ importers: '@hcengineering/postgres': specifier: workspace:^0.7.22 version: link:../../../foundations/server/packages/postgres + '@hcengineering/products': + specifier: workspace:^0.7.0 + version: link:../../../plugins/products '@hcengineering/server-client': specifier: workspace:^0.7.16 version: link:../../../foundations/server/packages/client diff --git a/services/export/pod-export/package.json b/services/export/pod-export/package.json index 1ba1d1fa95..f3972f4e9e 100644 --- a/services/export/pod-export/package.json +++ b/services/export/pod-export/package.json @@ -85,6 +85,7 @@ "ws": "^8.18.2", "@hcengineering/drive": "workspace:^0.7.0", "@hcengineering/export": "workspace:^0.7.0", + "@hcengineering/products": "workspace:^0.7.0", "uuid": "^8.3.2", "tar": "^7.4.3", "archiver": "^7.0.1", diff --git a/services/export/pod-export/src/__tests__/product-version-handler.test.ts b/services/export/pod-export/src/__tests__/product-version-handler.test.ts new file mode 100644 index 0000000000..ac5e1703ab --- /dev/null +++ b/services/export/pod-export/src/__tests__/product-version-handler.test.ts @@ -0,0 +1,424 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type AttachedDoc, + type Class, + type Doc, + type Hierarchy, + type LowLevelStorage, + type MeasureContext, + type Ref, + type Space, + type TxOperations +} from '@hcengineering/core' +import productsPlugin, { ProductVersionState, type Product, type ProductVersion } from '@hcengineering/products' + +import { createProductVersionHandler } from '../handlers/product-version-handler' +import { DocumentExporter } from '../workspace/document-exporter' +import { type CustomExportHandler, type CustomExportHandlerContext, type ExportState } from '../workspace/types' +import { type DataMapper } from '../workspace/data-mapper' +import { type RelationExporter } from '../workspace/relation-exporter' +import { type SpaceExporter } from '../workspace/space-exporter' +import { type AttachmentExporter } from '../workspace/attachment-exporter' + +const sourceProductId = 'test:product:source-A' as Ref +const otherSourceProductId = 'test:product:source-B' as Ref +const targetProductIdA = 'test:product:target-A' as Ref +const targetProductIdB = 'test:product:target-B' as Ref + +function makeContext (): MeasureContext { + return { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn() + } as unknown as MeasureContext +} + +function makeState (): ExportState { + return { + idMapping: new Map(), + spaceMapping: new Map(), + processingDocs: new Set(), + uniqueFieldValues: new Map() + } +} + +function productVersion (id: string, space: Ref, extras: Partial = {}): ProductVersion { + const base: ProductVersion = { + _id: id as Ref, + _class: productsPlugin.class.ProductVersion, + space, + name: '1.0.0', + readonly: false, + major: 1, + minor: 0, + patch: 0, + description: '', + state: ProductVersionState.Active, + parent: productsPlugin.ids.NoParentVersion, + modifiedOn: 0, + modifiedBy: 'test:account:user' as any + } + return { ...base, ...extras } +} + +/** + * Tiny in-memory TxOperations stub. Only implements findOne/createDoc and + * getHierarchy() — the handler does not call anything else. + */ +function makeTargetClient (existing: ProductVersion[] = []): { + client: TxOperations + created: ProductVersion[] +} { + const docs: ProductVersion[] = [...existing] + const created: ProductVersion[] = [] + + const client = { + findOne: jest.fn(async (classRef: Ref>, query: any): Promise => { + for (const d of docs) { + if (d._class !== classRef) continue + let ok = true + for (const [k, v] of Object.entries(query)) { + if ((d as any)[k] !== v) { + ok = false + break + } + } + if (ok) return d as unknown as T + } + return undefined + }), + createDoc: jest.fn( + async ( + classRef: Ref>, + space: Ref, + data: Record, + id: Ref + ): Promise> => { + const doc = { _id: id, _class: classRef, space, ...data } as unknown as ProductVersion + docs.push(doc) + created.push(doc) + return id + } + ), + addCollection: jest.fn(), + updateDoc: jest.fn(), + findAll: jest.fn(async () => []), + getHierarchy: jest.fn(() => makeTargetHierarchy()) + } as unknown as TxOperations + + return { client, created } +} + +function makeTargetHierarchy (): Hierarchy { + return { + isDerived: jest.fn((cls: Ref>, base: Ref>) => cls === base), + findDomain: jest.fn(() => 'domain:products'), + getAllAttributes: jest.fn(() => new Map()), + isMixin: jest.fn(() => false), + hasMixin: jest.fn(() => undefined), + getClass: jest.fn(() => ({ label: 'ProductVersion' })) + } as unknown as Hierarchy +} + +/** + * Build the ctx object the handler's resolve() receives. The spaceExporter + * stub maps source Product ids onto preconfigured target Product ids so we + * can assert dedup happens after space mapping. + */ +function makeHandlerCtx ( + targetClient: TxOperations, + spaceMap: Record> +): { + ctx: CustomExportHandlerContext + spaceExporter: { getOrCreateTargetSpace: jest.Mock } + } { + const spaceExporter = { + getOrCreateTargetSpace: jest.fn(async (sourceSpaceId: Ref) => { + const target = spaceMap[sourceSpaceId as unknown as string] + if (target === undefined) { + throw new Error(`no target mapping for source space ${sourceSpaceId}`) + } + return target + }) + } + + const ctx: CustomExportHandlerContext = { + context: makeContext(), + targetClient, + state: makeState(), + spaceExporter, + sourceHierarchy: {} as unknown as Hierarchy, + sourceLowLevel: {} as unknown as LowLevelStorage + } + return { ctx, spaceExporter } +} + +describe('createProductVersionHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('creates a new 1.0.0 ProductVersion when none exists in the target Product', async () => { + const { client, created } = makeTargetClient() + const { ctx, spaceExporter } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA }) + const handler = createProductVersionHandler() + + const sourceVersion = productVersion('test:version:s1', sourceProductId, { + major: 3, + minor: 7, + patch: 2, + state: ProductVersionState.Released + }) + + const targetId = await handler.resolve(sourceVersion, ctx) + + expect(targetId).toBeDefined() + expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledWith( + sourceProductId, + ctx.sourceHierarchy, + ctx.sourceLowLevel + ) + expect(client.findOne).toHaveBeenCalledWith(productsPlugin.class.ProductVersion, { + space: targetProductIdA, + major: 1, + minor: 0, + patch: 0 + }) + expect(client.createDoc).toHaveBeenCalledTimes(1) + expect(created).toHaveLength(1) + expect(created[0]).toMatchObject({ + _class: productsPlugin.class.ProductVersion, + space: targetProductIdA, + name: '1.0.0', + major: 1, + minor: 0, + patch: 0, + state: ProductVersionState.Active, + readonly: false, + parent: productsPlugin.ids.NoParentVersion + }) + expect(targetId).toEqual(created[0]._id) + }) + + it('reuses an existing 1.0.0 ProductVersion in the target Product without creating a new one', async () => { + const existing = productVersion('test:version:existing', targetProductIdA) + const { client, created } = makeTargetClient([existing]) + const { ctx } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA }) + const handler = createProductVersionHandler() + + const sourceVersion = productVersion('test:version:s1', sourceProductId, { major: 2, minor: 5, patch: 1 }) + + const targetId = await handler.resolve(sourceVersion, ctx) + + expect(targetId).toEqual(existing._id) + expect(client.createDoc).not.toHaveBeenCalled() + expect(created).toHaveLength(0) + }) + + it('caches the target version across multiple source versions of the same Product', async () => { + const { client, created } = makeTargetClient() + const { ctx, spaceExporter } = makeHandlerCtx(client, { [sourceProductId]: targetProductIdA }) + const handler = createProductVersionHandler() + + const v1 = productVersion('test:version:s1', sourceProductId, { major: 1 }) + const v2 = productVersion('test:version:s2', sourceProductId, { major: 2 }) + const v3 = productVersion('test:version:s3', sourceProductId, { major: 3 }) + + const t1 = await handler.resolve(v1, ctx) + const t2 = await handler.resolve(v2, ctx) + const t3 = await handler.resolve(v3, ctx) + + // All three source versions collapse onto the single target id. + expect(t1).toEqual(t2) + expect(t2).toEqual(t3) + // findOne and createDoc happen exactly once across all source versions. + expect(client.findOne).toHaveBeenCalledTimes(1) + expect(client.createDoc).toHaveBeenCalledTimes(1) + expect(created).toHaveLength(1) + // The space exporter is still queried for each source version (it has its + // own cache via state.spaceMapping in the real implementation). + expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledTimes(3) + }) + + it('creates a separate 1.0.0 per distinct target Product', async () => { + const { client, created } = makeTargetClient() + const { ctx } = makeHandlerCtx(client, { + [sourceProductId]: targetProductIdA, + [otherSourceProductId]: targetProductIdB + }) + const handler = createProductVersionHandler() + + const va = productVersion('test:version:a', sourceProductId) + const vb = productVersion('test:version:b', otherSourceProductId) + + const ta = await handler.resolve(va, ctx) + const tb = await handler.resolve(vb, ctx) + + expect(ta).not.toEqual(tb) + expect(client.createDoc).toHaveBeenCalledTimes(2) + expect(created).toHaveLength(2) + expect(created[0].space).toEqual(targetProductIdA) + expect(created[1].space).toEqual(targetProductIdB) + }) + + it('exposes ProductVersion as the matched class', () => { + const handler = createProductVersionHandler() + expect(handler.class).toEqual(productsPlugin.class.ProductVersion) + }) +}) + +describe('DocumentExporter custom handler dispatch', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('short-circuits ProductVersion export through the registered handler and records idMapping', async () => { + const state = makeState() + const context = makeContext() + + const fakeTargetVersionId = 'test:version:target-fixed' as Ref + + // Hand-rolled handler so the test does not depend on real createDoc behavior. + const handler: CustomExportHandler = { + class: productsPlugin.class.ProductVersion, + resolve: jest.fn(async () => fakeTargetVersionId) + } + + const targetClient = { + getHierarchy: () => ({ + isDerived: (cls: Ref>, base: Ref>) => + cls === base || (cls === productsPlugin.class.ProductVersion && base === productsPlugin.class.ProductVersion) + }) + } as unknown as TxOperations + + const spaceExporter = { + getOrCreateTargetSpace: jest.fn() + } as unknown as SpaceExporter + + // These should never be invoked when the handler short-circuits. + const dataMapper = { prepareDocumentData: jest.fn() } as unknown as DataMapper + const attachmentExporter = { + exportAttachments: jest.fn(), + exportCollaborativeContent: jest.fn() + } as unknown as AttachmentExporter + const relationExporter = { + exportForwardRelations: jest.fn(), + exportInverseRelations: jest.fn(), + exportAllRelations: jest.fn() + } as unknown as RelationExporter + + const exporter = new DocumentExporter(context, targetClient, state, dataMapper, spaceExporter, attachmentExporter) + exporter.setRelationExporter(relationExporter) + exporter.setCustomHandlers([handler as unknown as CustomExportHandler]) + + const sourceVersion = productVersion('test:version:source-from-doc-exporter', sourceProductId) + + const result = await exporter.exportDocument( + sourceVersion, + 'duplicate', + true, + {} as unknown as Hierarchy, + {} as unknown as LowLevelStorage, + new Map(), + [] + ) + + expect(result).toBe(true) + expect(handler.resolve).toHaveBeenCalledTimes(1) + expect(state.idMapping.get(sourceVersion._id as Ref)).toEqual(fakeTargetVersionId) + // The default flow's createDoc/dataMapper/relations paths must not run. + expect(dataMapper.prepareDocumentData).not.toHaveBeenCalled() + expect(spaceExporter.getOrCreateTargetSpace).not.toHaveBeenCalled() + expect(relationExporter.exportForwardRelations).not.toHaveBeenCalled() + expect(relationExporter.exportInverseRelations).not.toHaveBeenCalled() + }) + + it('falls through to the default flow when no handler matches the doc class', async () => { + const state = makeState() + const context = makeContext() + + const handler: CustomExportHandler = { + class: productsPlugin.class.ProductVersion, + resolve: jest.fn(async () => 'should-not-be-used' as Ref) + } + + const targetClient = { + getHierarchy: () => ({ + isDerived: (_cls: Ref>, _base: Ref>) => false + }) + } as unknown as TxOperations + + const spaceExporter = { + getOrCreateTargetSpace: jest.fn(async () => 'test:target:space' as Ref) + } as unknown as SpaceExporter + + const dataMapper = { prepareDocumentData: jest.fn(async () => ({})) } as unknown as DataMapper + const attachmentExporter = { + exportAttachments: jest.fn(), + exportCollaborativeContent: jest.fn() + } as unknown as AttachmentExporter + const relationExporter = { + exportForwardRelations: jest.fn(), + exportInverseRelations: jest.fn(), + exportAllRelations: jest.fn() + } as unknown as RelationExporter + + // Provide a target client with the createDoc stub the default flow uses. + ;(targetClient as any).createDoc = jest.fn(async () => 'test:doc:created' as Ref) + ;(targetClient as any).addCollection = jest.fn(async () => 'test:doc:attached' as Ref) + + const exporter = new DocumentExporter(context, targetClient, state, dataMapper, spaceExporter, attachmentExporter) + exporter.setRelationExporter(relationExporter) + exporter.setCustomHandlers([handler as unknown as CustomExportHandler]) + + const unrelatedClass = 'test:class:Unrelated' as Ref> + const unrelatedDoc: Doc = { + _id: 'test:doc:unrelated' as Ref, + _class: unrelatedClass, + space: 'test:source:space' as Ref, + modifiedOn: 0, + modifiedBy: 'test:account:user' as any + } + + const sourceHierarchy = { + isDerived: jest.fn(() => false), + findDomain: jest.fn(() => 'domain:test'), + getAllAttributes: jest.fn(() => new Map()) + } as unknown as Hierarchy + const sourceLowLevel = { + rawFindAll: jest.fn(async () => []) + } as unknown as LowLevelStorage + + const result = await exporter.exportDocument( + unrelatedDoc, + 'duplicate', + true, + sourceHierarchy, + sourceLowLevel, + new Map(), + [] + ) + + expect(result).toBe(true) + expect(handler.resolve).not.toHaveBeenCalled() + // Default flow ran: space exporter, data mapper, and createDoc were invoked. + expect(spaceExporter.getOrCreateTargetSpace).toHaveBeenCalledTimes(1) + expect(dataMapper.prepareDocumentData).toHaveBeenCalledTimes(1) + expect((targetClient as any).createDoc).toHaveBeenCalledTimes(1) + }) +}) diff --git a/services/export/pod-export/src/handlers/product-version-handler.ts b/services/export/pod-export/src/handlers/product-version-handler.ts new file mode 100644 index 0000000000..11013e59f2 --- /dev/null +++ b/services/export/pod-export/src/handlers/product-version-handler.ts @@ -0,0 +1,83 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { generateId, type Data, type Ref } from '@hcengineering/core' +import productsPlugin, { ProductVersionState, type Product, type ProductVersion } from '@hcengineering/products' + +import { type CustomExportHandler } from '../workspace/types' + +/** + * Custom export handler that consolidates all source ProductVersions of a + * given Product onto a single 1.0.0 ProductVersion in the target Product. + */ +export function createProductVersionHandler (): CustomExportHandler { + const cache = new Map, Ref>() + + return { + class: productsPlugin.class.ProductVersion, + resolve: async (sourceDoc, ctx) => { + const sourceVersion = sourceDoc as ProductVersion + const targetSpace = await ctx.spaceExporter.getOrCreateTargetSpace( + sourceVersion.space, + ctx.sourceHierarchy, + ctx.sourceLowLevel + ) + const targetProductId = targetSpace as Ref + + const cached = cache.get(targetProductId) + if (cached !== undefined) { + ctx.context.info(`Mapping source ProductVersion ${sourceVersion._id} onto cached target 1.0.0 ${cached}`) + return cached + } + + const existing = await ctx.targetClient.findOne(productsPlugin.class.ProductVersion, { + space: targetProductId, + major: 1, + minor: 0, + patch: 0 + }) + + let targetVersionId: Ref + + if (existing !== undefined) { + targetVersionId = existing._id + ctx.context.info( + `Reusing existing 1.0.0 ProductVersion ${targetVersionId} in product ${targetProductId} ` + + `(source version ${sourceVersion._id})` + ) + } else { + targetVersionId = generateId() + const data: Data = { + name: '1.0.0', + readonly: false, + major: 1, + minor: 0, + patch: 0, + description: '', + state: ProductVersionState.Active, + parent: productsPlugin.ids.NoParentVersion + } + await ctx.targetClient.createDoc(productsPlugin.class.ProductVersion, targetProductId, data, targetVersionId) + ctx.context.info( + `Created 1.0.0 ProductVersion ${targetVersionId} in product ${targetProductId} ` + + `(consolidating source version ${sourceVersion._id})` + ) + } + + cache.set(targetProductId, targetVersionId) + return targetVersionId + } + } +} diff --git a/services/export/pod-export/src/server.ts b/services/export/pod-export/src/server.ts index 7761a1e97e..0016569bbf 100644 --- a/services/export/pod-export/src/server.ts +++ b/services/export/pod-export/src/server.ts @@ -78,6 +78,7 @@ import envConfig from './config' import { ApiError } from './error' import { ExportFormat, WorkspaceExporter } from './exporter' import { CrossWorkspaceExporter, type ExportOptions, type ExportResult } from './workspace' +import { createProductVersionHandler } from './handlers/product-version-handler' const extractCookieToken = (cookie?: string): string | null => { if (cookie === undefined || cookie === null) { @@ -541,7 +542,8 @@ export function createServer ( relations, fieldMappers, skipDeletedObsolete: skipDeletedObsolete ?? true, - exportOnlyEffective: exportOnlyEffective ?? false + exportOnlyEffective: exportOnlyEffective ?? false, + customHandlers: [createProductVersionHandler()] } const exportResult: ExportResult = await exporter.export(options) diff --git a/services/export/pod-export/src/workspace/document-exporter.ts b/services/export/pod-export/src/workspace/document-exporter.ts index 7d7799be2c..a73979a2f1 100644 --- a/services/export/pod-export/src/workspace/document-exporter.ts +++ b/services/export/pod-export/src/workspace/document-exporter.ts @@ -32,7 +32,7 @@ import { type AttachmentExporter } from './attachment-exporter' import { type DataMapper } from './data-mapper' import { type RelationExporter } from './relation-exporter' import { type SpaceExporter } from './space-exporter' -import { type ExportState, type RelationDefinition } from './types' +import { type CustomExportHandler, type ExportState, type RelationDefinition } from './types' /** * Handles document export logic @@ -40,6 +40,7 @@ import { type ExportState, type RelationDefinition } from './types' export class DocumentExporter { private relationExporter: RelationExporter | undefined private dataMapper: DataMapper + private customHandlers: CustomExportHandler[] = [] constructor ( private readonly context: MeasureContext, @@ -60,6 +61,10 @@ export class DocumentExporter { this.dataMapper = dataMapper } + setCustomHandlers (handlers: CustomExportHandler[]): void { + this.customHandlers = handlers + } + /** * Export a single document */ @@ -80,6 +85,13 @@ export class DocumentExporter { return false } + // Class-specific custom handlers take precedence over the default flow. + const customTargetId = await this.runCustomHandlers(doc, sourceHierarchy, sourceLowLevel) + if (customTargetId !== undefined) { + this.state.idMapping.set(doc._id, customTargetId) + return true + } + if (conflictStrategy === 'skip') { const existingDoc = existingDocsMap.get(doc._id) if (existingDoc !== undefined) { @@ -166,6 +178,51 @@ export class DocumentExporter { } } + /** + * Run any registered custom handlers whose class matches `doc._class`. + * Returns the target id from the first handler that resolves the doc. + * Returns `undefined` when no handler matches or all handlers fall through. + */ + private async runCustomHandlers ( + doc: Doc, + sourceHierarchy: Hierarchy, + sourceLowLevel: LowLevelStorage + ): Promise | undefined> { + if (this.customHandlers.length === 0) { + return undefined + } + + const targetHierarchy = this.targetClient.getHierarchy() + + for (const handler of this.customHandlers) { + if (!targetHierarchy.isDerived(doc._class, handler.class)) { + continue + } + + try { + const targetId = await handler.resolve(doc, { + context: this.context, + targetClient: this.targetClient, + state: this.state, + spaceExporter: this.spaceExporter, + sourceHierarchy, + sourceLowLevel + }) + if (targetId !== undefined) { + return targetId + } + } catch (err: any) { + this.context.error(`Custom export handler for ${handler.class} failed on ${doc._id}:`, { + error: err instanceof Error ? err.message : String(err), + docId: doc._id + }) + throw err + } + } + + return undefined + } + private async exportSpaceRelations ( doc: Doc, space: Ref, diff --git a/services/export/pod-export/src/workspace/types.ts b/services/export/pod-export/src/workspace/types.ts index a2d5205689..369b6bde29 100644 --- a/services/export/pod-export/src/workspace/types.ts +++ b/services/export/pod-export/src/workspace/types.ts @@ -17,15 +17,46 @@ import { type Class, type Doc, type DocumentQuery, + type Hierarchy, + type LowLevelStorage, type MeasureContext, type Ref, type Space, + type TxOperations, type WorkspaceIds } from '@hcengineering/core' import { type Pipeline } from '@hcengineering/server-core' export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise +export interface CustomExportHandlerContext { + context: MeasureContext + targetClient: TxOperations + state: ExportState + spaceExporter: { + getOrCreateTargetSpace: ( + sourceSpaceId: Ref, + sourceHierarchy: Hierarchy, + sourceLowLevel: LowLevelStorage + ) => Promise> + } + sourceHierarchy: Hierarchy + sourceLowLevel: LowLevelStorage +} + +/** + * Hook for class-specific export behavior. + * + * When the exporter encounters a document derived from `class`, it calls + * `resolve` instead of the default duplicate-and-remap flow. The handler is + * responsible for finding or creating the appropriate target document and + * returning its id. + */ +export interface CustomExportHandler { + class: Ref> + resolve: (sourceDoc: T, ctx: CustomExportHandlerContext) => Promise | undefined> +} + export interface RelationDefinition { /** When set, this relation applies only to documents of this class (or its subclasses). */ sourceClass?: Ref> @@ -53,6 +84,9 @@ export interface ExportOptions { skipDeletedObsolete?: boolean // Whether to export only documents with effective status exportOnlyEffective?: boolean + // Class-specific export handlers, applied before the default flow. Useful + // for collapsing or deduplicating documents (e.g. ProductVersion). + customHandlers?: CustomExportHandler[] } export interface ExportResult { diff --git a/services/export/pod-export/src/workspace/workspace-exporter.ts b/services/export/pod-export/src/workspace/workspace-exporter.ts index 918f8911cf..62eed3501c 100644 --- a/services/export/pod-export/src/workspace/workspace-exporter.ts +++ b/services/export/pod-export/src/workspace/workspace-exporter.ts @@ -127,7 +127,8 @@ export class CrossWorkspaceExporter { relations = [], fieldMappers = {}, skipDeletedObsolete = true, - exportOnlyEffective = false + exportOnlyEffective = false, + customHandlers = [] } = options // Store field mappers @@ -142,6 +143,8 @@ export class CrossWorkspaceExporter { ) // Update document exporter with new data mapper this.documentExporter.setDataMapper(this.dataMapper) + // Register custom export handlers for this run + this.documentExporter.setCustomHandlers(customHandlers) // Pre-fetch current account's employee ID if available if (this.currentAccount !== undefined) {