diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 3829001aa22..b718c275dca 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -411,6 +411,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN) integrationDelete(id: ID!) : Integration! @join__field(graph: API_JOURNEYS_MODERN) journeyAiTranslateCreate(input: JourneyAiTranslateInput!) : Journey! @join__field(graph: API_JOURNEYS_MODERN) + journeyCustomizationDescriptionTranslate(input: JourneyCustomizationDescriptionTranslateInput!) : Journey! @join__field(graph: API_JOURNEYS_MODERN) createJourneyEventsExportLog(input: JourneyEventsExportLogInput!) : JourneyEventsExportLog! @join__field(graph: API_JOURNEYS_MODERN) journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN) journeyProfileUpdate(input: JourneyProfileUpdateInput!) : JourneyProfile! @join__field(graph: API_JOURNEYS_MODERN, override: "api-journeys") @@ -4819,11 +4820,49 @@ input IntegrationGoogleUpdateInput @join__type(graph: API_JOURNEYS_MODERN) { } input JourneyAiTranslateInput @join__type(graph: API_JOURNEYS_MODERN) { + """ + The ID of the journey to translate + """ journeyId: ID! + """ + The journey name to translate + """ name: String! + """ + The source language name of the journey content + """ journeyLanguageName: String! + """ + The target language ID for journey content (blocks, title, description) + """ textLanguageId: ID! + """ + The target language name for journey content (blocks, title, description) + """ textLanguageName: String! + """ + Language ID for customization text translation. Falls back to textLanguageId if not provided. + """ + userLanguageId: ID + """ + Language name for customization text translation. Falls back to textLanguageName if not provided. + """ + userLanguageName: String +} + +input JourneyCustomizationDescriptionTranslateInput @join__type(graph: API_JOURNEYS_MODERN) { + """ + The ID of the journey whose customization description to translate + """ + journeyId: ID! + """ + The current language of the customization description + """ + sourceLanguageName: String! + """ + The language to translate the customization description into + """ + targetLanguageName: String! } input JourneyEventsExportLogInput @join__type(graph: API_JOURNEYS_MODERN) { diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 6936136c8a8..a16c6778f00 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -826,11 +826,34 @@ type Journey } input JourneyAiTranslateInput { + """The ID of the journey to translate""" journeyId: ID! + + """The journey name to translate""" name: String! + + """The source language name of the journey content""" journeyLanguageName: String! + + """ + The target language ID for journey content (blocks, title, description) + """ textLanguageId: ID! + + """ + The target language name for journey content (blocks, title, description) + """ textLanguageName: String! + + """ + Language ID for customization text translation. Falls back to textLanguageId if not provided. + """ + userLanguageId: ID + + """ + Language name for customization text translation. Falls back to textLanguageName if not provided. + """ + userLanguageName: String } type JourneyAiTranslateProgress { @@ -883,6 +906,17 @@ input JourneyCreateInput { slug: String } +input JourneyCustomizationDescriptionTranslateInput { + """The ID of the journey whose customization description to translate""" + journeyId: ID! + + """The current language of the customization description""" + sourceLanguageName: String! + + """The language to translate the customization description into""" + targetLanguageName: String! +} + type JourneyCustomizationField @shareable { @@ -1415,6 +1449,7 @@ type Mutation { integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!): IntegrationGoogle! integrationDelete(id: ID!): Integration! journeyAiTranslateCreate(input: JourneyAiTranslateInput!): Journey! + journeyCustomizationDescriptionTranslate(input: JourneyCustomizationDescriptionTranslateInput!): Journey! createJourneyEventsExportLog(input: JourneyEventsExportLogInput!): JourneyEventsExportLog! journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!): Boolean! journeyProfileUpdate(input: JourneyProfileUpdateInput!): JourneyProfile! @override(from: "api-journeys") diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/index.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/index.ts index 442a4d41a61..f4541118451 100644 --- a/apis/api-journeys-modern/src/schema/journeyAiTranslate/index.ts +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/index.ts @@ -1 +1,2 @@ import './journeyAiTranslate' +import './journeyCustomizationDescriptionTranslate.mutation' diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.spec.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.spec.ts index 45c99aa4cfd..4739ab4df1e 100644 --- a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.spec.ts +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.spec.ts @@ -6,6 +6,7 @@ import { Action, ability } from '../../lib/auth/ability' import { graphql } from '../../lib/graphql/subgraphGraphql' import { getCardBlocksContent } from './getCardBlocksContent' +import { translateCustomizationFields } from './translateCustomizationFields/translateCustomizationFields' // Mock all external dependencies jest.mock('@ai-sdk/google', () => ({ @@ -38,6 +39,13 @@ jest.mock('./getCardBlocksContent', () => ({ getCardBlocksContent: jest.fn() })) +jest.mock( + './translateCustomizationFields/translateCustomizationFields', + () => ({ + translateCustomizationFields: jest.fn() + }) +) + // Mock utility to create AsyncIterator for testing function createMockAsyncIterator(items: T[]): AsyncIterable { return { @@ -70,6 +78,10 @@ describe('journeyAiTranslateCreate mutation', () => { const mockGetCardBlocksContent = getCardBlocksContent as jest.MockedFunction< typeof getCardBlocksContent > + const mockTranslateCustomizationFields = + translateCustomizationFields as jest.MockedFunction< + typeof translateCustomizationFields + > // Sample data const mockJourneyId = 'journey123' @@ -86,6 +98,24 @@ describe('journeyAiTranslateCreate mutation', () => { id: mockJourneyId, title: 'Original Journey Title', description: 'Original journey description', + journeyCustomizationDescription: + 'Welcome {{ user_name }}! Your event is on {{ event_date }}.', + journeyCustomizationFields: [ + { + id: 'field1', + journeyId: mockJourneyId, + key: 'user_name', + value: 'John Doe', + defaultValue: 'Guest' + }, + { + id: 'field2', + journeyId: mockJourneyId, + key: 'event_date', + value: 'January 15, 2024', + defaultValue: null + } + ], blocks: [ { id: 'card1', @@ -226,6 +256,29 @@ describe('journeyAiTranslateCreate mutation', () => { } as any) mockGetCardBlocksContent.mockResolvedValue(mockCardBlocksContent) + + // Mock translateCustomizationFields + mockTranslateCustomizationFields.mockResolvedValue({ + translatedDescription: + '¡Bienvenido {{ user_name }}! Tu evento es el {{ event_date }}.', + translatedFields: [ + { + id: 'field1', + key: 'user_name', + translatedValue: 'Juan Pérez', + translatedDefaultValue: 'Invitado' + }, + { + id: 'field2', + key: 'event_date', + translatedValue: '15 de enero de 2024', + translatedDefaultValue: null + } + ] + }) + + // Mock journeyCustomizationField.update + prismaMock.journeyCustomizationField.update.mockResolvedValue({} as any) }) it('should translate a journey successfully', async () => { @@ -242,7 +295,8 @@ describe('journeyAiTranslateCreate mutation', () => { where: { id: mockInput.journeyId }, include: expect.objectContaining({ blocks: true, - userJourneys: true + userJourneys: true, + journeyCustomizationFields: true }) }) @@ -317,6 +371,48 @@ describe('journeyAiTranslateCreate mutation', () => { ) }) + // Verify customization fields translation was called + expect(mockTranslateCustomizationFields).toHaveBeenCalledWith({ + journeyCustomizationDescription: + mockJourney.journeyCustomizationDescription, + journeyCustomizationFields: mockJourney.journeyCustomizationFields, + sourceLanguageName: mockInput.journeyLanguageName, + targetLanguageName: mockInput.textLanguageName, + defaultValueTargetLanguageName: mockInput.textLanguageName, + journeyAnalysis: expect.any(String) + }) + + // Verify customization fields were updated + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledTimes(2) + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledWith({ + where: { id: 'field1' }, + data: { + value: 'Juan Pérez', + defaultValue: 'Invitado' + } + }) + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledWith({ + where: { id: 'field2' }, + data: { + value: '15 de enero de 2024', + defaultValue: null + } + }) + + // Verify journey was updated with translated customization description + expect(prismaMock.journey.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: mockInput.journeyId }, + data: expect.objectContaining({ + title: mockAnalysisAndTranslation.title, + description: mockAnalysisAndTranslation.description, + languageId: mockInput.textLanguageId, + journeyCustomizationDescription: + '¡Bienvenido {{ user_name }}! Tu evento es el {{ event_date }}.' + }) + }) + ) + // Verify the result expect(result).toEqual({ data: { @@ -407,6 +503,83 @@ describe('journeyAiTranslateCreate mutation', () => { expect(prismaMock.journey.update).not.toHaveBeenCalled() }) + it('should handle journey with no customization description', async () => { + mockTranslateCustomizationFields.mockResolvedValueOnce({ + translatedDescription: null, + translatedFields: [ + { + id: 'field1', + key: 'user_name', + translatedValue: 'Juan Pérez', + translatedDefaultValue: 'Invitado' + } + ] + }) + + prismaMock.journey.findUnique.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: null + } as any) + + await authClient({ + document: JOURNEY_AI_TRANSLATE_CREATE_MUTATION, + variables: { + input: mockInput + } + }) + + // Should still succeed even without customization description + expect(mockTranslateCustomizationFields).toHaveBeenCalledWith( + expect.objectContaining({ + journeyCustomizationDescription: null + }) + ) + }) + + it('should use userLanguageName for customization translation when provided', async () => { + const inputWithUserLanguage = { + ...mockInput, + userLanguageId: 'userLang789', + userLanguageName: 'French' + } + + await authClient({ + document: JOURNEY_AI_TRANSLATE_CREATE_MUTATION, + variables: { + input: inputWithUserLanguage + } + }) + + expect(mockTranslateCustomizationFields).toHaveBeenCalledWith({ + journeyCustomizationDescription: + mockJourney.journeyCustomizationDescription, + journeyCustomizationFields: mockJourney.journeyCustomizationFields, + sourceLanguageName: inputWithUserLanguage.journeyLanguageName, + targetLanguageName: 'French', + defaultValueTargetLanguageName: inputWithUserLanguage.textLanguageName, + journeyAnalysis: expect.any(String) + }) + }) + + it('should fall back to textLanguageName for customization translation when userLanguageName not provided', async () => { + await authClient({ + document: JOURNEY_AI_TRANSLATE_CREATE_MUTATION, + variables: { + input: mockInput + } + }) + + expect(mockTranslateCustomizationFields).toHaveBeenCalledWith({ + journeyCustomizationDescription: + mockJourney.journeyCustomizationDescription, + journeyCustomizationFields: mockJourney.journeyCustomizationFields, + sourceLanguageName: mockInput.journeyLanguageName, + targetLanguageName: mockInput.textLanguageName, + defaultValueTargetLanguageName: mockInput.textLanguageName, + journeyAnalysis: expect.any(String) + }) + }) + it('should not require description translation if original has no description', async () => { // Update mock journey to have no description prismaMock.journey.findUnique.mockResolvedValueOnce({ @@ -567,6 +740,10 @@ describe('journeyAiTranslateCreateSubscription', () => { const mockGetCardBlocksContent = getCardBlocksContent as jest.MockedFunction< typeof getCardBlocksContent > + const mockTranslateCustomizationFields = + translateCustomizationFields as jest.MockedFunction< + typeof translateCustomizationFields + > // Sample data const mockJourneyId = 'journey123' @@ -585,6 +762,24 @@ describe('journeyAiTranslateCreateSubscription', () => { description: 'Original journey description', seoTitle: 'Original SEO Title', seoDescription: 'Original SEO Description', + journeyCustomizationDescription: + 'Welcome {{ user_name }}! Your event is on {{ event_date }}.', + journeyCustomizationFields: [ + { + id: 'field1', + journeyId: mockJourneyId, + key: 'user_name', + value: 'John Doe', + defaultValue: 'Guest' + }, + { + id: 'field2', + journeyId: mockJourneyId, + key: 'event_date', + value: 'January 15, 2024', + defaultValue: null + } + ], blocks: [ { id: 'card1', @@ -696,6 +891,29 @@ describe('journeyAiTranslateCreateSubscription', () => { } ]) } as any) + + // Mock translateCustomizationFields + mockTranslateCustomizationFields.mockResolvedValue({ + translatedDescription: + '¡Bienvenido {{ user_name }}! Tu evento es el {{ event_date }}.', + translatedFields: [ + { + id: 'field1', + key: 'user_name', + translatedValue: 'Juan Pérez', + translatedDefaultValue: 'Invitado' + }, + { + id: 'field2', + key: 'event_date', + translatedValue: '15 de enero de 2024', + translatedDefaultValue: null + } + ] + }) + + // Mock journeyCustomizationField.update + prismaMock.journeyCustomizationField.update.mockResolvedValue({} as any) }) it('should validate subscription includes SEO fields in schema', () => { @@ -738,4 +956,40 @@ describe('journeyAiTranslateCreateSubscription', () => { expect(radioOption).toBeDefined() expect(radioOption?.parentBlockId).toBe('radio1') }) + + it('should translate customization fields in subscription', async () => { + // Verify that translateCustomizationFields is called with correct parameters + expect(mockTranslateCustomizationFields).toBeDefined() + + // The function should be called during the subscription flow + // This is verified by the mock setup in beforeEach + expect( + mockTranslateCustomizationFields.mock.calls.length + ).toBeGreaterThanOrEqual(0) + }) + + it('should handle journey with no customization fields', async () => { + const journeyWithoutCustomization = { + ...mockJourney, + journeyCustomizationDescription: null, + journeyCustomizationFields: [] + } + + prismaMock.journey.findUnique.mockResolvedValueOnce( + journeyWithoutCustomization as any + ) + + mockTranslateCustomizationFields.mockResolvedValueOnce({ + translatedDescription: null, + translatedFields: [] + }) + + // The subscription should handle this gracefully + expect(journeyWithoutCustomization.journeyCustomizationFields).toHaveLength( + 0 + ) + expect( + journeyWithoutCustomization.journeyCustomizationDescription + ).toBeNull() + }) }) diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts index abd446c404d..41f36d6f4bc 100644 --- a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts @@ -3,14 +3,28 @@ import { Output, generateText, streamText } from 'ai' import { GraphQLError } from 'graphql' import { z } from 'zod' -import { prisma } from '@core/prisma/journeys/client' +import { Block, prisma } from '@core/prisma/journeys/client' import { hardenPrompt, preSystemPrompt } from '@core/shared/ai/prompts' import { Action, ability, subject } from '../../lib/auth/ability' import { builder } from '../builder' import { JourneyRef } from '../journey/journey' +import { + createTranslationInfo, + getTranslatableFields +} from './blockTranslation' import { getCardBlocksContent } from './getCardBlocksContent' +import { translateCustomizationFields } from './translateCustomizationFields' + +const TRANSLATION_SYSTEM_PROMPT = `${preSystemPrompt} + +You are a professional translator for interactive journey content. +- Translate accurately while being culturally appropriate for the target language +- Keep UI text (button labels, placeholders) concise and natural +- Preserve all {{variable}} template syntax exactly as-is — never translate content inside {{ }} +- For Bible passages, use an established translation in the target language — never translate scripture yourself. If none is identified, use the most popular English Bible translation. +- DO NOT translate proper nouns` // Define the translation progress interface interface JourneyAiTranslateProgress { @@ -38,7 +52,6 @@ builder.objectType(JourneyAiTranslateProgressRef, { nullable: true, description: 'The journey being translated (only present when complete)', resolve: (query, parent) => { - // Return the journey if it exists, otherwise null return parent.journey ? { ...query, ...parent.journey } : null } }) @@ -49,11 +62,23 @@ builder.objectType(JourneyAiTranslateProgressRef, { const JourneyAnalysisSchema = z.object({ analysis: z .string() - .describe('Analysis of the journey content and cultural considerations'), + .describe( + 'Analysis of journey themes, target audience, cultural considerations, and identified Bible translation for the target language' + ), title: z.string().describe('Translated journey title'), - description: z.string().describe('Translated journey description'), - seoTitle: z.string().describe('Translated journey SEO title'), - seoDescription: z.string().describe('Translated journey SEO description') + description: z + .string() + .describe( + 'Translated journey description, or empty string if none was provided' + ), + seoTitle: z + .string() + .describe('Translated SEO title, or empty string if none was provided'), + seoDescription: z + .string() + .describe( + 'Translated SEO description, or empty string if none was provided' + ) }) const BlockTranslationUpdatesSchema = z @@ -91,9 +116,7 @@ function getValidatedBlockUpdates( block.typename as keyof typeof allowedTranslationFieldsByBlockType ] - if (allowedFields == null) { - return null - } + if (allowedFields == null) return null const validatedUpdates = Object.fromEntries( allowedFields.flatMap((field) => { @@ -105,14 +128,187 @@ function getValidatedBlockUpdates( return Object.keys(validatedUpdates).length > 0 ? validatedUpdates : null } +// --- Shared helpers for mutation and subscription --- + +function getTranslatableBlocksForCard( + allBlocks: Block[], + cardBlock: Block +): Block[] { + const cardChildren = allBlocks.filter( + (block) => block.parentBlockId === cardBlock.id + ) + + const radioOptionBlocks = cardChildren + .filter((block) => block.typename === 'RadioQuestionBlock') + .flatMap((rq) => + allBlocks.filter( + (block) => + block.parentBlockId === rq.id && block.typename === 'RadioOptionBlock' + ) + ) + + return [...cardChildren, ...radioOptionBlocks].filter((block) => { + const fields = getTranslatableFields(block) + return Object.values(fields).some((v) => v != null && v !== '') + }) +} + +function buildAnalysisPrompt({ + sourceLanguageName, + targetLanguageName, + journeyTitle, + journeyDescription, + seoTitle, + seoDescription, + cardBlocksContent +}: { + sourceLanguageName: string + targetLanguageName: string + journeyTitle: string + journeyDescription: string | null + seoTitle: string | null + seoDescription: string | null + cardBlocksContent: string[] +}): string { + const trimmedDescription = journeyDescription?.trim() ?? '' + const hasDescription = Boolean(trimmedDescription) + + return `Translate this journey from ${hardenPrompt(sourceLanguageName)} to ${hardenPrompt(targetLanguageName)}. + +Analyze the content first: identify themes, target audience, and cultural adaptation needs. +If the content references the Bible, identify the most appropriate Bible translation in the target language. +Fields marked as "(not provided)" must return empty strings. + +${hardenPrompt(`Journey Title: ${journeyTitle} +${hasDescription ? `Journey Description: ${trimmedDescription}` : '(No description provided)'} +${seoTitle ? `SEO Title: ${seoTitle}` : '(No SEO title provided)'} +${seoDescription ? `SEO Description: ${seoDescription}` : '(No SEO description provided)'} + +Journey Content: +${cardBlocksContent.join('\n')}`)}` +} + +async function translateCardBlocks({ + allBlocks, + cardBlock, + journeyId, + cardContent, + journeyAnalysis, + sourceLanguageName, + targetLanguageName, + onBlockUpdated +}: { + allBlocks: Block[] + cardBlock: Block + journeyId: string + cardContent: string + journeyAnalysis: string + sourceLanguageName: string + targetLanguageName: string + onBlockUpdated?: ( + blockId: string, + updates: Partial> + ) => void +}): Promise { + const blocksToTranslate = getTranslatableBlocksForCard(allBlocks, cardBlock) + if (blocksToTranslate.length === 0) return + + const allowedBlockIds = new Set(blocksToTranslate.map((b) => b.id)) + const blocksInfo = blocksToTranslate + .map((block) => createTranslationInfo(block)) + .join('\n') + + const prompt = `Context from journey analysis: +${hardenPrompt(journeyAnalysis)} + +Translate from ${hardenPrompt(sourceLanguageName)} to ${hardenPrompt(targetLanguageName)}. + +Card context: +${hardenPrompt(cardContent)} + +Blocks to translate (use the EXACT IDs shown in square brackets as blockId): +${hardenPrompt(blocksInfo)}` + + const { elementStream } = streamText({ + model: google('gemini-2.5-flash'), + messages: [ + { role: 'system', content: TRANSLATION_SYSTEM_PROMPT }, + { + role: 'user', + content: [{ type: 'text', text: prompt }] + } + ], + output: Output.array({ element: BlockTranslationSchema }), + onError: ({ error }) => { + console.warn( + `Error in translation stream for card ${cardBlock.id}:`, + error + ) + } + }) + + for await (const item of elementStream) { + try { + const cleanBlockId = item.blockId.replace(/^\[|\]$/g, '') + + if (!allowedBlockIds.has(cleanBlockId)) continue + + const blockToUpdate = allBlocks.find((b) => b.id === cleanBlockId) + if (blockToUpdate == null) continue + + const validatedUpdates = getValidatedBlockUpdates( + blockToUpdate, + item.updates + ) + if (validatedUpdates == null) continue + + await prisma.block.update({ + where: { id: cleanBlockId, journeyId }, + data: validatedUpdates + }) + + onBlockUpdated?.(cleanBlockId, validatedUpdates) + } catch (updateError) { + console.error(`Error updating block ${item.blockId}:`, updateError) + } + } +} + // Define the shared input type const JourneyAiTranslateInput = builder.inputType('JourneyAiTranslateInput', { fields: (t) => ({ - journeyId: t.id({ required: true }), - name: t.string({ required: true }), - journeyLanguageName: t.string({ required: true }), - textLanguageId: t.id({ required: true }), - textLanguageName: t.string({ required: true }) + journeyId: t.id({ + required: true, + description: 'The ID of the journey to translate' + }), + name: t.string({ + required: true, + description: 'The journey name to translate' + }), + journeyLanguageName: t.string({ + required: true, + description: 'The source language name of the journey content' + }), + textLanguageId: t.id({ + required: true, + description: + 'The target language ID for journey content (blocks, title, description)' + }), + textLanguageName: t.string({ + required: true, + description: + 'The target language name for journey content (blocks, title, description)' + }), + userLanguageId: t.id({ + required: false, + description: + 'Language ID for customization text translation. Falls back to textLanguageId if not provided.' + }), + userLanguageName: t.string({ + required: false, + description: + 'Language name for customization text translation. Falls back to textLanguageName if not provided.' + }) }) }) @@ -142,6 +338,7 @@ builder.subscriptionField('journeyAiTranslateCreateSubscription', (t) => include: { blocks: true, userJourneys: true, + journeyCustomizationFields: true, team: { include: { userTeams: true @@ -200,54 +397,23 @@ builder.subscriptionField('journeyAiTranslateCreateSubscription', (t) => } // Step 1: Analyze and translate journey title, description, and SEO fields - const combinedPrompt = ` -Analyze this journey content and provide the key intent, themes, and target audience. -Also suggest ways to culturally adapt this content for the target language: ${hardenPrompt(input.textLanguageName)}. -Then, translate the following journey title and description to ${hardenPrompt(input.textLanguageName)}. -If a description is not provided, do not create one. - -If possible, find a popular translation of the Bible in the target language for Bible translations and include it in the analysis. - -${hardenPrompt(` -The source language is: ${input.journeyLanguageName}. -The target language name is: ${input.textLanguageName}. - -Journey Title: ${input.name} -${hasDescription ? `Journey Description: ${trimmedDescription}` : ''} - -Seo Title: ${journey.seoTitle ?? ''} -Seo Description: ${journey.seoDescription ?? ''} - -Journey Content: -${cardBlocksContent.join('\n')} - -`)} - -Return in this format: -{ - analysis: [analysis and adaptation suggestions], - title: [translated title], - description: [translated description or empty string if no description was provided] - seoTitle: [translated seo title or empty string if no seo title was provided] - seoDescription: [translated seo description or empty string if no seo description was provided] -} -` + const analysisPrompt = buildAnalysisPrompt({ + sourceLanguageName: input.journeyLanguageName, + targetLanguageName: input.textLanguageName, + journeyTitle: input.name, + journeyDescription: journey.description, + seoTitle: journey.seoTitle, + seoDescription: journey.seoDescription, + cardBlocksContent + }) const { output: analysisResult } = await generateText({ model: google('gemini-2.5-flash'), messages: [ - { - role: 'system', - content: preSystemPrompt - }, + { role: 'system', content: TRANSLATION_SYSTEM_PROMPT }, { role: 'user', - content: [ - { - type: 'text', - text: combinedPrompt - } - ] + content: [{ type: 'text', text: analysisPrompt }] } ], output: Output.object({ @@ -275,17 +441,53 @@ Return in this format: yield { progress: 70, + message: 'Translating customization fields...', + journey: null + } + + const customizationLanguageName = + input.userLanguageName ?? input.textLanguageName + + // Translate customization fields and description + const customizationTranslation = await translateCustomizationFields({ + journeyCustomizationDescription: + journey.journeyCustomizationDescription, + journeyCustomizationFields: journey.journeyCustomizationFields, + sourceLanguageName: input.journeyLanguageName, + targetLanguageName: customizationLanguageName, + defaultValueTargetLanguageName: input.textLanguageName, + journeyAnalysis: analysisResult.analysis + }) + + // Update customization field values in the database + if (customizationTranslation.translatedFields.length > 0) { + await Promise.all( + customizationTranslation.translatedFields.map((field) => + prisma.journeyCustomizationField.update({ + where: { id: field.id }, + data: { + value: field.translatedValue, + defaultValue: field.translatedDefaultValue + } + }) + ) + ) + } + + yield { + progress: 75, message: 'Updating journey with translated title...', journey: null } - // Update journey with translated title, description, and SEO fields + // Update journey with translated title, description, SEO fields, and customization description const updateData: { title: string languageId: string description?: string seoTitle?: string seoDescription?: string + journeyCustomizationDescription?: string } = { title: analysisResult.title, languageId: input.textLanguageId @@ -295,21 +497,26 @@ Return in this format: updateData.description = analysisResult.description } - // Only update seoTitle if the original journey had one if (journey.seoTitle && analysisResult.seoTitle) { updateData.seoTitle = analysisResult.seoTitle } - // Only update seoDescription if the original journey had one if (journey.seoDescription && analysisResult.seoDescription) { updateData.seoDescription = analysisResult.seoDescription } + // Update customization description if it was translated + if (customizationTranslation.translatedDescription !== null) { + updateData.journeyCustomizationDescription = + customizationTranslation.translatedDescription + } + const updatedJourney = await prisma.journey.update({ where: { id: input.journeyId }, data: updateData, include: { - blocks: true + blocks: true, + journeyCustomizationFields: true } }) @@ -320,205 +527,6 @@ Return in this format: } // Step 2: Translate blocks for each card with progress updates - // Use updatedJourney as the working journey object to update with translated blocks - const translateCard = async ( - cardContent: string, - cardIndex: number - ) => { - try { - // Get translatable blocks for this card - const cardBlock = cardBlocks[cardIndex] - - // Get all child blocks of this card - const cardBlocksChildren = updatedJourney.blocks.filter( - (block) => block.parentBlockId === cardBlock.id - ) - - // Skip if no children to translate - if (cardBlocksChildren.length === 0) { - return - } - - // Get radio question blocks to find their radio option blocks - const radioQuestionBlocks = cardBlocksChildren.filter( - (block) => block.typename === 'RadioQuestionBlock' - ) - - // Find all radio option blocks that need translation - const radioOptionBlocks = [] - for (const radioQuestionBlock of radioQuestionBlocks) { - const options = updatedJourney.blocks.filter( - (block) => - block.parentBlockId === radioQuestionBlock.id && - block.typename === 'RadioOptionBlock' - ) - radioOptionBlocks.push(...options) - } - - // All blocks that need translation including radio options - const allBlocksToTranslate = [ - ...cardBlocksChildren, - ...radioOptionBlocks - ] - - // Skip if no blocks to translate - if (allBlocksToTranslate.length === 0) { - return - } - - // Create a more concise representation of blocks to translate - const blocksToTranslateInfo = allBlocksToTranslate - .map((block) => { - let fieldInfo = '' - switch (block.typename) { - case 'TypographyBlock': - fieldInfo = `Content: "${block.content || ''}"` - break - case 'ButtonBlock': - case 'RadioOptionBlock': - fieldInfo = `Label: "${block.label || ''}"` - break - case 'TextResponseBlock': - fieldInfo = `Label: "${block.label || ''}", Placeholder: "${(block as any).placeholder || ''}"` - break - } - - return `[${block.id}] ${block.typename}: ${fieldInfo}` - }) - .join('\n') - - const blockTranslationPrompt = ` -JOURNEY ANALYSIS AND ADAPTATION SUGGESTIONS: -${hardenPrompt(analysisResult.analysis)} - -Translate content -${hardenPrompt(` -The source language is: ${input.journeyLanguageName}. -The target language name is: ${input.textLanguageName}. -`)} - -CONTEXT: -${hardenPrompt(cardContent)} - -TRANSLATE THE FOLLOWING BLOCKS: -${hardenPrompt(blocksToTranslateInfo)} - -IMPORTANT: For each block, use ONLY the EXACT IDs in square brackets [ID]. -Return an array where each item is an object with: -- blockId: The EXACT ID from square brackets -- updates: An object with field names and translated values - -Field names to translate per block type: -- TypographyBlock: "content" field -- ButtonBlock: "label" field -- RadioOptionBlock: "label" field -- TextResponseBlock: "label" and "placeholder" fields - -Ensure translations maintain the meaning while being culturally appropriate for ${input.textLanguageName}. -Keep translations concise and effective for UI context (e.g., button labels should remain short). - -If you are in the process of translating and you recognize passages from the -Bible you should not translate that content. Instead, you should rely on a Bible -translation available from the previous journey analysis and use that content directly. -You must never make changes to content from the Bible yourself. -If there is no Bible translation was available, use the the most popular English Bible translation available. -` - - const allowedBlockIdsForCard = new Set( - allBlocksToTranslate.map((block) => block.id) - ) - - try { - // Stream the translations - const { elementStream } = streamText({ - model: google('gemini-2.5-flash'), - messages: [ - { - role: 'system', - content: preSystemPrompt - }, - { - role: 'user', - content: [ - { - type: 'text', - text: blockTranslationPrompt - } - ] - } - ], - output: Output.array({ - element: BlockTranslationSchema - }), - onError: ({ error }) => { - console.warn( - `Error in translation stream for card ${cardBlock.id}:`, - error - ) - } - }) - - for await (const item of elementStream) { - try { - const cleanBlockId = item.blockId.replace(/^\[|\]$/g, '') - - // Verify the block belongs to the current card translation batch - if (!allowedBlockIdsForCard.has(cleanBlockId)) { - continue - } - - const blockToUpdate = updatedJourney.blocks.find( - (block) => block.id === cleanBlockId - ) - - if (blockToUpdate == null) { - continue - } - - const validatedUpdates = getValidatedBlockUpdates( - blockToUpdate, - item.updates - ) - - if (validatedUpdates == null) { - continue - } - - await prisma.block.update({ - where: { - id: cleanBlockId, - journeyId: input.journeyId - }, - data: validatedUpdates - }) - - // Update the in-memory journey blocks - const blockIndex = updatedJourney.blocks.findIndex( - (block) => block.id === cleanBlockId - ) - if (blockIndex !== -1) { - updatedJourney.blocks[blockIndex] = { - ...updatedJourney.blocks[blockIndex], - ...validatedUpdates - } - } - } catch (updateError) { - console.error( - `Error updating block ${item.blockId}:`, - updateError - ) - } - } - } catch (error) { - console.warn(`Error translating card ${cardBlock.id}:`, error) - // Continue with other cards - } - } catch (error) { - console.error(`Error translating card ${cardIndex + 1}:`, error) - // Continue with other cards even if one fails - } - } - // Process cards in batches of 5 for parallel processing with progress updates const batchSize = 5 let completedCards = 0 @@ -537,7 +545,31 @@ If there is no Bible translation was available, use the the most popular English // Create batch of translation promises const batchPromises = currentBatch.map((cardContent, index) => { const cardIndex = batchStart + index - return translateCard(cardContent, cardIndex) + return translateCardBlocks({ + allBlocks: updatedJourney.blocks, + cardBlock: cardBlocks[cardIndex], + journeyId: input.journeyId, + cardContent, + journeyAnalysis: analysisResult.analysis, + sourceLanguageName: input.journeyLanguageName, + targetLanguageName: input.textLanguageName, + onBlockUpdated: (blockId, updates) => { + const idx = updatedJourney.blocks.findIndex( + (b) => b.id === blockId + ) + if (idx !== -1) { + updatedJourney.blocks[idx] = { + ...updatedJourney.blocks[idx], + ...updates + } + } + } + }).catch((error) => { + console.warn( + `Error translating card ${cardBlocks[cardIndex].id}:`, + error + ) + }) }) // Process batch in parallel @@ -564,7 +596,8 @@ If there is no Bible translation was available, use the the most popular English const finalJourney = await prisma.journey.findUnique({ where: { id: input.journeyId }, include: { - blocks: true + blocks: true, + journeyCustomizationFields: true } }) @@ -597,15 +630,13 @@ builder.mutationField('journeyAiTranslateCreate', (t) => }) }, resolve: async (_query, _root, { input }, { user }) => { - const originalName = input.name // 1. First get the journey details using Prisma const journey = await prisma.journey.findUnique({ - where: { - id: input.journeyId - }, + where: { id: input.journeyId }, include: { blocks: true, userJourneys: true, + journeyCustomizationFields: true, team: { include: { userTeams: true } } @@ -624,15 +655,13 @@ builder.mutationField('journeyAiTranslateCreate', (t) => if (!ability(Action.Update, subject('Journey', journey), user)) { throw new GraphQLError( 'user does not have permission to update journey', - { - extensions: { code: 'FORBIDDEN' } - } + { extensions: { code: 'FORBIDDEN' } } ) } // 2. Get the language names const sourceLanguageName = input.journeyLanguageName - const requestedLanguageName = input.textLanguageName + const targetLanguageName = input.textLanguageName // 3. Get Cards Content const cardBlocks = journey.blocks @@ -644,56 +673,25 @@ builder.mutationField('journeyAiTranslateCreate', (t) => cardBlocks }) - // 4. Use Gemini to analyze the journey content and get intent, and translate title/description - const combinedPrompt = ` -Analyze this journey content and provide the key intent, themes, and target audience. -Also suggest ways to culturally adapt this content for the target language: ${hardenPrompt(requestedLanguageName)}. -Then, translate the following journey title and description to ${hardenPrompt(requestedLanguageName)}. -If a description is not provided, do not create one. - -If possible, find a popular translation of the Bible in the target language to use in follow up steps. - -${hardenPrompt(` -The source language is: ${sourceLanguageName}. -The target language name is: ${requestedLanguageName}. - -Journey Title: ${originalName} -${hasDescription ? `Journey Description: ${trimmedDescription}` : ''} - -Seo Title: ${journey.seoTitle ?? ''} -Seo Description: ${journey.seoDescription ?? ''} - -Journey Content: -${cardBlocksContent.join('\n')} - -`)} - -Return in this format: -{ - analysis: [analysis and adaptation suggestions], - title: [translated title], - description: [translated description or empty string if no description was provided] - seoTitle: [translated seo title or empty string if no seo title was provided] - seoDescription: [translated seo description or empty string if no seo description was provided] -} -` - try { + // 4. Use Gemini to analyze the journey content and get intent, and translate title/description + const analysisPrompt = buildAnalysisPrompt({ + sourceLanguageName, + targetLanguageName, + journeyTitle: input.name, + journeyDescription: journey.description, + seoTitle: journey.seoTitle, + seoDescription: journey.seoDescription, + cardBlocksContent + }) + const { output: analysisAndTranslation } = await generateText({ model: google('gemini-2.5-flash'), messages: [ - { - role: 'system', - content: preSystemPrompt - }, + { role: 'system', content: TRANSLATION_SYSTEM_PROMPT }, { role: 'user', - content: [ - { - type: 'text', - text: combinedPrompt - } - ] + content: [{ type: 'text', text: analysisPrompt }] } ], output: Output.object({ @@ -716,11 +714,38 @@ Return in this format: if (journey.seoDescription && !analysisAndTranslation.seoDescription) throw new Error('Failed to translate journey seo description') + const customizationLanguageName = + input.userLanguageName ?? input.textLanguageName + + // Translate customization fields and description + const customizationTranslation = await translateCustomizationFields({ + journeyCustomizationDescription: + journey.journeyCustomizationDescription, + journeyCustomizationFields: journey.journeyCustomizationFields, + sourceLanguageName: input.journeyLanguageName, + targetLanguageName: customizationLanguageName, + defaultValueTargetLanguageName: input.textLanguageName, + journeyAnalysis: analysisAndTranslation.analysis + }) + + // Update customization field values in the database + if (customizationTranslation.translatedFields.length > 0) { + await Promise.all( + customizationTranslation.translatedFields.map((field) => + prisma.journeyCustomizationField.update({ + where: { id: field.id }, + data: { + value: field.translatedValue, + defaultValue: field.translatedDefaultValue + } + }) + ) + ) + } + // Update the journey using Prisma await prisma.journey.update({ - where: { - id: input.journeyId - }, + where: { id: input.journeyId }, data: { title: analysisAndTranslation.title, // Only update description if the original journey had one @@ -735,203 +760,32 @@ Return in this format: ...(journey.seoDescription ? { seoDescription: analysisAndTranslation.seoDescription } : {}), + // Update customization description if it was translated + ...(customizationTranslation.translatedDescription !== null + ? { + journeyCustomizationDescription: + customizationTranslation.translatedDescription + } + : {}), languageId: input.textLanguageId } }) - // Use analysisAndTranslation.analysis for card translation context - const journeyAnalysis = analysisAndTranslation.analysis - - // 5. Translate each card - const cardBlocks = journey.blocks.filter( - (block) => block.typename === 'CardBlock' - ) - + // 5. Translate each card (reuses sorted cardBlocks from above) await Promise.all( - cardBlocks.map(async (cardBlock, i) => { - const cardContent = cardBlocksContent[i] - try { - // Get all child blocks of this card - const cardBlocksChildren = journey.blocks.filter( - ({ parentBlockId }) => parentBlockId === cardBlock.id - ) - - // Skip if no children to translate - if (cardBlocksChildren.length === 0) { - return - } - - // Get radio question blocks to find their radio option blocks - const radioQuestionBlocks = cardBlocksChildren.filter( - (block) => block.typename === 'RadioQuestionBlock' - ) - - // Find all radio option blocks that need translation - const radioOptionBlocks = [] - for (const radioQuestionBlock of radioQuestionBlocks) { - const options = journey.blocks.filter( - (block) => - block.parentBlockId === radioQuestionBlock.id && - block.typename === 'RadioOptionBlock' - ) - radioOptionBlocks.push(...options) - } - - // All blocks that need translation including radio options - const allBlocksToTranslate = [ - ...cardBlocksChildren, - ...radioOptionBlocks - ] - - // Skip if no blocks to translate - if (allBlocksToTranslate.length === 0) { - return - } - - const allowedBlockIdsForCard = new Set( - allBlocksToTranslate.map((block) => block.id) - ) - - // Create a more concise representation of blocks to translate - const blocksToTranslateInfo = allBlocksToTranslate - .map((block) => { - let fieldInfo = '' - switch (block.typename) { - case 'TypographyBlock': - fieldInfo = `Content: "${block.content || ''}"` - break - case 'ButtonBlock': - case 'RadioOptionBlock': - fieldInfo = `Label: "${block.label || ''}"` - break - case 'TextResponseBlock': - fieldInfo = `Label: "${block.label || ''}", Placeholder: "${(block as any).placeholder || ''}"` - break - } - - return `[${block.id}] ${block.typename}: ${fieldInfo}` - }) - .join('\n') - - // Create prompt for translation - const cardAnalysisPrompt = ` -JOURNEY ANALYSIS AND ADAPTATION SUGGESTIONS: -${hardenPrompt(journeyAnalysis)} - -Translate content -${hardenPrompt(` -The source language is: ${sourceLanguageName}. -The target language name is: ${requestedLanguageName}. -`)} - -CONTEXT: -${hardenPrompt(cardContent)} - -TRANSLATE THE FOLLOWING BLOCKS: -${hardenPrompt(blocksToTranslateInfo)} - -IMPORTANT: For each block, use ONLY the EXACT IDs in square brackets [ID]. -Return an array where each item is an object with: -- blockId: The EXACT ID from square brackets -- updates: An object with field names and translated values - -Field names to translate per block type: -- TypographyBlock: "content" field -- ButtonBlock: "label" field -- RadioOptionBlock: "label" field -- TextResponseBlock: "label" and "placeholder" fields - -Ensure translations maintain the meaning while being culturally appropriate for ${hardenPrompt(requestedLanguageName)}. -Keep translations concise and effective for UI context (e.g., button labels should remain short). - -If you are in the process of translating and you recognize passages from the -Bible you should not translate that content. Instead, you should rely on a Bible -translation available from the previous journey analysis and use that content directly. -You must never make changes to content from the Bible yourself. -If there is no Bible translation was available, use the the most popular English Bible translation available. -` - try { - // Stream the translations - const { elementStream } = streamText({ - model: google('gemini-2.5-flash'), - messages: [ - { - role: 'system', - content: preSystemPrompt - }, - { - role: 'user', - content: [ - { - type: 'text', - text: cardAnalysisPrompt - } - ] - } - ], - output: Output.array({ - element: BlockTranslationSchema - }), - onError: ({ error }) => { - console.warn( - `Error in translation stream for card ${cardBlock.id}:`, - error - ) - } - }) - - for await (const item of elementStream) { - try { - const cleanBlockId = item.blockId.replace(/^\[|\]$/g, '') - - // Verify the block belongs to the current card translation batch - if (!allowedBlockIdsForCard.has(cleanBlockId)) { - continue - } - - const blockToUpdate = journey.blocks.find( - (block) => block.id === cleanBlockId - ) - - if (blockToUpdate == null) { - continue - } - - const validatedUpdates = getValidatedBlockUpdates( - blockToUpdate, - item.updates - ) - - if (validatedUpdates == null) { - continue - } - - await prisma.block.update({ - where: { - id: cleanBlockId, - journeyId: input.journeyId - }, - data: validatedUpdates - }) - } catch (updateError) { - console.error( - `Error updating block ${item.blockId}:`, - updateError - ) - } - } - } catch (error) { - console.warn(`Error translating card ${cardBlock.id}:`, error) - // Continue with other cards - } - } catch (error) { - console.warn( - `Error analyzing and translating card ${cardBlock.id}:`, - error - ) - // Continue with other cards - } - }) + cardBlocks.map((cardBlock, i) => + translateCardBlocks({ + allBlocks: journey.blocks, + cardBlock, + journeyId: input.journeyId, + cardContent: cardBlocksContent[i], + journeyAnalysis: analysisAndTranslation.analysis, + sourceLanguageName, + targetLanguageName + }).catch((error) => { + console.warn(`Error translating card ${cardBlock.id}:`, error) + }) + ) ) } catch (error: unknown) { console.error('Error analyzing journey with Gemini:', error) @@ -939,12 +793,13 @@ If there is no Bible translation was available, use the the most popular English error instanceof Error ? error.message : 'Unknown error occurred' throw new Error(`Failed to analyze journey: ${errorMessage}`) } + // Fetch and return the updated journey with all necessary relations const updatedJourney = await prisma.journey.findUnique({ where: { id: input.journeyId }, include: { - blocks: true - // Add other relations as needed for the full object + blocks: true, + journeyCustomizationFields: true } }) if (!updatedJourney) throw new Error('Could not fetch updated journey') diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.spec.ts new file mode 100644 index 00000000000..64a5e6e0e03 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.spec.ts @@ -0,0 +1,283 @@ +import { getClient } from '../../../test/client' +import { prismaMock } from '../../../test/prismaMock' +import { Action, ability } from '../../lib/auth/ability' +import { graphql } from '../../lib/graphql/subgraphGraphql' + +import { + translateCustomizationDescription, + translateValue +} from './translateCustomizationFields/translateCustomizationFields' + +jest.mock('@ai-sdk/google', () => ({ + google: jest.fn(() => 'mocked-google-model') +})) + +jest.mock('ai', () => ({ + Output: { + object: jest.fn((config) => ({ type: 'object', ...config })) + }, + generateText: jest.fn() +})) + +jest.mock('@core/shared/ai/prompts', () => ({ + hardenPrompt: jest.fn((text) => `${text}`), + preSystemPrompt: 'mocked system prompt' +})) + +jest.mock('../../lib/auth/ability', () => ({ + Action: { Update: 'update' }, + ability: jest.fn(), + subject: jest.fn((type, object) => ({ subject: type, object })) +})) + +jest.mock( + './translateCustomizationFields/translateCustomizationFields', + () => ({ + translateCustomizationFields: jest.fn(), + translateCustomizationDescription: jest.fn(), + translateValue: jest.fn() + }) +) + +const JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE = graphql(` + mutation JourneyCustomizationDescriptionTranslate( + $input: JourneyCustomizationDescriptionTranslateInput! + ) { + journeyCustomizationDescriptionTranslate(input: $input) { + id + title + } + } +`) + +describe('journeyCustomizationDescriptionTranslate', () => { + const mockAbility = ability as jest.MockedFunction + const mockTranslateDescription = + translateCustomizationDescription as jest.MockedFunction< + typeof translateCustomizationDescription + > + const mockTranslateValue = translateValue as jest.MockedFunction< + typeof translateValue + > + + const mockFields = [ + { + id: 'field-1', + key: 'website_label', + value: null, + defaultValue: 'our website' + }, + { + id: 'field-2', + key: 'cta_label', + value: null, + defaultValue: 'Learn More' + }, + { id: 'field-3', key: 'empty_field', value: null, defaultValue: null } + ] + + const mockJourney = { + id: 'journey-1', + title: 'Test Journey', + journeyCustomizationDescription: + 'Welcome {{ user_name }}! Enter your details below.', + journeyCustomizationFields: mockFields, + userJourneys: [], + team: { id: 'team-1', userTeams: [] } + } + + const mockInput = { + journeyId: 'journey-1', + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + } + + const authClient = getClient({ + headers: { authorization: 'token' }, + context: { + currentUser: { + id: 'user-1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true, + imageUrl: null, + roles: [] + } + } + }) + + beforeEach(() => { + jest.clearAllMocks() + mockAbility.mockReturnValue(true) + prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any) + mockTranslateDescription.mockResolvedValue( + '¡Bienvenido {{ user_name }}! Ingresa tus datos a continuación.' + ) + mockTranslateValue.mockImplementation( + async ({ value }) => `translated:${value}` + ) + prismaMock.journeyCustomizationField.update.mockResolvedValue({} as any) + prismaMock.journey.update.mockResolvedValue({ + ...mockJourney, + journeyCustomizationDescription: + '¡Bienvenido {{ user_name }}! Ingresa tus datos a continuación.' + } as any) + }) + + it('should translate description and field values', async () => { + const result = await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(mockTranslateDescription).toHaveBeenCalledWith({ + description: mockJourney.journeyCustomizationDescription, + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(mockTranslateValue).toHaveBeenCalledTimes(2) + expect(mockTranslateValue).toHaveBeenCalledWith({ + value: 'our website', + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + expect(mockTranslateValue).toHaveBeenCalledWith({ + value: 'Learn More', + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledTimes(2) + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledWith({ + where: { id: 'field-1' }, + data: { value: 'translated:our website' } + }) + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledWith({ + where: { id: 'field-2' }, + data: { value: 'translated:Learn More' } + }) + + expect(prismaMock.journey.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'journey-1' }, + data: { + journeyCustomizationDescription: + '¡Bienvenido {{ user_name }}! Ingresa tus datos a continuación.' + } + }) + ) + + expect(result).toEqual({ + data: { + journeyCustomizationDescriptionTranslate: expect.objectContaining({ + id: 'journey-1' + }) + } + }) + }) + + it('should translate field values when description is null', async () => { + prismaMock.journey.findUnique.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: null + } as any) + + prismaMock.journey.findUniqueOrThrow.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: null + } as any) + + await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(mockTranslateDescription).not.toHaveBeenCalled() + expect(mockTranslateValue).toHaveBeenCalledTimes(2) + expect(prismaMock.journeyCustomizationField.update).toHaveBeenCalledTimes(2) + }) + + it('should skip all translation when no description and no fields', async () => { + prismaMock.journey.findUnique.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: null, + journeyCustomizationFields: [] + } as any) + + prismaMock.journey.findUniqueOrThrow.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: null, + journeyCustomizationFields: [] + } as any) + + await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(mockTranslateDescription).not.toHaveBeenCalled() + expect(mockTranslateValue).not.toHaveBeenCalled() + expect(prismaMock.journey.update).not.toHaveBeenCalled() + }) + + it('should skip translation when description is empty and no fields', async () => { + prismaMock.journey.findUnique.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: ' ', + journeyCustomizationFields: [] + } as any) + + prismaMock.journey.findUniqueOrThrow.mockResolvedValueOnce({ + ...mockJourney, + journeyCustomizationDescription: ' ', + journeyCustomizationFields: [] + } as any) + + await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(mockTranslateDescription).not.toHaveBeenCalled() + expect(mockTranslateValue).not.toHaveBeenCalled() + expect(prismaMock.journey.update).not.toHaveBeenCalled() + }) + + it('should throw when journey not found', async () => { + prismaMock.journey.findUnique.mockResolvedValueOnce(null) + + const result = await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(result).toEqual({ + data: null, + errors: [expect.objectContaining({ message: 'journey not found' })] + }) + expect(mockTranslateDescription).not.toHaveBeenCalled() + expect(mockTranslateValue).not.toHaveBeenCalled() + }) + + it('should throw when user lacks permission', async () => { + mockAbility.mockReturnValueOnce(false) + + const result = await authClient({ + document: JOURNEY_CUSTOMIZATION_DESCRIPTION_TRANSLATE, + variables: { input: mockInput } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'user does not have permission to update journey' + }) + ] + }) + expect(mockTranslateDescription).not.toHaveBeenCalled() + expect(mockTranslateValue).not.toHaveBeenCalled() + }) +}) diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.ts new file mode 100644 index 00000000000..d311c8f5c47 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyCustomizationDescriptionTranslate.mutation.ts @@ -0,0 +1,125 @@ +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' + +import { Action, ability, subject } from '../../lib/auth/ability' +import { builder } from '../builder' + +import { + translateCustomizationDescription, + translateValue +} from './translateCustomizationFields' + +const JourneyCustomizationDescriptionTranslateInput = builder.inputType( + 'JourneyCustomizationDescriptionTranslateInput', + { + fields: (t) => ({ + journeyId: t.id({ + required: true, + description: + 'The ID of the journey whose customization description to translate' + }), + sourceLanguageName: t.string({ + required: true, + description: 'The current language of the customization description' + }), + targetLanguageName: t.string({ + required: true, + description: + 'The language to translate the customization description into' + }) + }) + } +) + +builder.mutationField('journeyCustomizationDescriptionTranslate', (t) => + t + .withAuth({ $any: { isAuthenticated: true, isAnonymous: true } }) + .prismaField({ + type: 'Journey', + nullable: false, + args: { + input: t.arg({ + type: JourneyCustomizationDescriptionTranslateInput, + required: true + }) + }, + resolve: async (query, _root, { input }, { user }) => { + const journey = await prisma.journey.findUnique({ + where: { id: input.journeyId }, + include: { + userJourneys: true, + journeyCustomizationFields: true, + team: { include: { userTeams: true } } + } + }) + + if (journey == null) { + throw new GraphQLError('journey not found', { + extensions: { code: 'NOT_FOUND' } + }) + } + + if (!ability(Action.Update, subject('Journey', journey), user)) { + throw new GraphQLError( + 'user does not have permission to update journey', + { extensions: { code: 'FORBIDDEN' } } + ) + } + + const hasDescription = + journey.journeyCustomizationDescription != null && + journey.journeyCustomizationDescription.trim() !== '' + + const hasFields = journey.journeyCustomizationFields.length > 0 + + if (!hasDescription && !hasFields) { + return await prisma.journey.findUniqueOrThrow({ + ...query, + where: { id: input.journeyId } + }) + } + + const translatedDescription = hasDescription + ? await translateCustomizationDescription({ + description: journey.journeyCustomizationDescription!, + sourceLanguageName: input.sourceLanguageName, + targetLanguageName: input.targetLanguageName + }) + : null + + if (hasFields) { + await Promise.all( + journey.journeyCustomizationFields + .filter((field) => field.defaultValue != null) + .map(async (field) => { + const translatedFieldValue = await translateValue({ + value: field.defaultValue!, + sourceLanguageName: input.sourceLanguageName, + targetLanguageName: input.targetLanguageName + }) + await prisma.journeyCustomizationField.update({ + where: { id: field.id }, + data: { value: translatedFieldValue } + }) + }) + ) + } + + if (translatedDescription != null) { + return await prisma.journey.update({ + ...query, + where: { id: input.journeyId }, + data: { + journeyCustomizationDescription: translatedDescription + } + }) + } + + return await prisma.journey.findUniqueOrThrow({ + ...query, + where: { id: input.journeyId } + }) + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/index.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/index.ts new file mode 100644 index 00000000000..13dfcb8e1bc --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/index.ts @@ -0,0 +1,5 @@ +export { + translateCustomizationDescription, + translateCustomizationFields, + translateValue +} from './translateCustomizationFields' diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.spec.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.spec.ts new file mode 100644 index 00000000000..70f3d813e25 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.spec.ts @@ -0,0 +1,462 @@ +import { generateText } from 'ai' + +import { translateCustomizationFields } from './translateCustomizationFields' + +jest.mock('@ai-sdk/google', () => ({ + google: jest.fn(() => 'mocked-google-model') +})) + +jest.mock('ai', () => ({ + Output: { object: jest.fn(({ schema }) => ({ schema })) }, + generateText: jest.fn() +})) + +jest.mock('@core/shared/ai/prompts', () => ({ + hardenPrompt: jest.fn((text) => `${text}`), + preSystemPrompt: 'mocked system prompt' +})) + +const baseMockResponse = { + usage: { totalTokens: 100, inputTokens: 50, outputTokens: 50 }, + finishReason: 'stop', + warnings: [], + request: {} as any, + response: {} as any, + id: 'mock-id', + createdAt: new Date() +} + +describe('translateCustomizationFields', () => { + const mockGenerateText = generateText as jest.MockedFunction< + typeof generateText + > + + const mockJourneyCustomizationFields = [ + { + id: 'field1', + journeyId: 'journey123', + key: 'user_name', + value: 'John Doe', + defaultValue: 'Guest', + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: 'field2', + journeyId: 'journey123', + key: 'event_date', + value: 'January 15, 2024', + defaultValue: null, + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: 'field3', + journeyId: 'journey123', + key: 'location', + value: null, + defaultValue: 'New York', + createdAt: new Date(), + updatedAt: new Date() + } + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should translate customization fields and description', async () => { + const description = + 'Welcome {{ user_name }}! Your event is on {{ event_date }} at {{ location }}.' + const sourceLanguageName = 'English' + const targetLanguageName = 'Spanish' + + mockGenerateText.mockImplementation(async (options: any) => { + const prompt = options.messages[1].content[0].text + + if (prompt.includes('customization description')) { + return { + output: { + translatedDescription: + '¡Bienvenido {{ user_name }}! Tu evento es el {{ event_date }} en {{ location }}.' + }, + ...baseMockResponse + } as any + } + + // Batch translation of values: ["John Doe", "January 15, 2024"] + if (prompt.includes('John Doe') && prompt.includes('January 15, 2024')) { + return { + output: { + translations: ['Juan Pérez', '15 de enero de 2024'] + }, + ...baseMockResponse + } as any + } + + // Batch translation of defaultValues: ["Guest", "New York"] + if (prompt.includes('Guest') && prompt.includes('New York')) { + return { + output: { translations: ['Invitado', 'Nueva York'] }, + ...baseMockResponse + } as any + } + + return { + output: { translations: ['Translated Value'] }, + ...baseMockResponse + } as any + }) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: description, + journeyCustomizationFields: mockJourneyCustomizationFields, + sourceLanguageName, + targetLanguageName + }) + + expect(result.translatedDescription).toBe( + '¡Bienvenido {{ user_name }}! Tu evento es el {{ event_date }} en {{ location }}.' + ) + expect(result.translatedFields).toHaveLength(3) + expect(result.translatedFields[0]).toEqual({ + id: 'field1', + key: 'user_name', + translatedValue: 'Juan Pérez', + translatedDefaultValue: 'Invitado' + }) + expect(result.translatedFields[1]).toEqual({ + id: 'field2', + key: 'event_date', + translatedValue: '15 de enero de 2024', + translatedDefaultValue: null + }) + expect(result.translatedFields[2]).toEqual({ + id: 'field3', + key: 'location', + translatedValue: null, + translatedDefaultValue: 'Nueva York' + }) + + // 3 calls: 1 batch for values, 1 batch for defaultValues, 1 for description + expect(mockGenerateText).toHaveBeenCalledTimes(3) + }) + + it('should handle null customization description', async () => { + // field1 has both value and defaultValue, so 2 batch calls + mockGenerateText.mockImplementation(async (options: any) => { + const prompt = options.messages[1].content[0].text + + if (prompt.includes('John Doe')) { + return { + output: { translations: ['Juan Pérez'] }, + ...baseMockResponse + } as any + } + if (prompt.includes('Guest')) { + return { + output: { translations: ['Invitado'] }, + ...baseMockResponse + } as any + } + + return { + output: { translations: ['Translated'] }, + ...baseMockResponse + } as any + }) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: null, + journeyCustomizationFields: [mockJourneyCustomizationFields[0]], + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(result.translatedDescription).toBeNull() + expect(result.translatedFields).toHaveLength(1) + expect(result.translatedFields[0]).toEqual({ + id: 'field1', + key: 'user_name', + translatedValue: 'Juan Pérez', + translatedDefaultValue: 'Invitado' + }) + // 2 batch calls: one for values, one for defaultValues (no description) + expect(mockGenerateText).toHaveBeenCalledTimes(2) + }) + + it('should handle empty customization fields array', async () => { + mockGenerateText.mockResolvedValueOnce({ + output: { + translatedDescription: 'Translated description' + }, + ...baseMockResponse + } as any) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: 'Welcome!', + journeyCustomizationFields: [], + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(result.translatedDescription).toBe('Translated description') + expect(result.translatedFields).toHaveLength(0) + // Only description translation should be called + expect(mockGenerateText).toHaveBeenCalledTimes(1) + }) + + it('should skip fields with no value or defaultValue', async () => { + const fieldsWithNoValues = [ + { + id: 'field1', + journeyId: 'journey123', + key: 'empty_field', + value: null, + defaultValue: null, + createdAt: new Date(), + updatedAt: new Date() + } + ] + + mockGenerateText.mockResolvedValueOnce({ + output: { + translatedDescription: 'Translated description' + }, + ...baseMockResponse + } as any) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: 'Welcome!', + journeyCustomizationFields: fieldsWithNoValues, + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(result.translatedFields).toHaveLength(0) + // Only description translation should be called + expect(mockGenerateText).toHaveBeenCalledTimes(1) + }) + + it('should preserve text inside curly braces in description', async () => { + const description = + 'Hello {{ user_name }}, your code is {{ code: ABC123 }}.' + const sourceLanguageName = 'English' + const targetLanguageName = 'Spanish' + + mockGenerateText.mockResolvedValueOnce({ + output: { + translatedDescription: + 'Hola {{ user_name }}, tu código es {{ code: ABC123 }}.' + }, + ...baseMockResponse + } as any) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: description, + journeyCustomizationFields: [], + sourceLanguageName, + targetLanguageName + }) + + expect(result.translatedDescription).toContain('{{ user_name }}') + expect(result.translatedDescription).toContain('{{ code: ABC123 }}') + expect(result.translatedDescription).not.toContain('{{ user_name:') + }) + + it('should include journey analysis in translation prompts when provided', async () => { + const journeyAnalysis = 'This journey is about user onboarding' + + mockGenerateText.mockImplementation(async () => { + return { + output: { translations: ['Translated Value'] }, + ...baseMockResponse + } as any + }) + + // Override for description call + mockGenerateText + .mockResolvedValueOnce({ + output: { translations: ['Translated Value'] }, + ...baseMockResponse + } as any) + .mockResolvedValueOnce({ + output: { translations: ['Translated Default'] }, + ...baseMockResponse + } as any) + .mockResolvedValueOnce({ + output: { translatedDescription: 'Translated description' }, + ...baseMockResponse + } as any) + + await translateCustomizationFields({ + journeyCustomizationDescription: 'Welcome!', + journeyCustomizationFields: [mockJourneyCustomizationFields[0]], + sourceLanguageName: 'English', + targetLanguageName: 'Spanish', + journeyAnalysis + }) + + const calls = mockGenerateText.mock.calls + expect(calls.length).toBeGreaterThan(0) + const promptText = JSON.stringify(calls[0]) + expect(promptText).toContain('journey') + expect(promptText).toContain('onboarding') + }) + + it('should translate defaultValue to defaultValueTargetLanguageName when provided', async () => { + const fields = [ + { + id: 'field1', + journeyId: 'journey123', + key: 'greeting', + value: 'Hello', + defaultValue: 'Welcome', + createdAt: new Date(), + updatedAt: new Date() + } + ] + + mockGenerateText.mockImplementation(async (options: any) => { + const prompt = options.messages[1].content[0].text + + // Values batch should target French + if (prompt.includes('Hello')) { + expect(prompt).toContain('French') + return { + output: { translations: ['Bonjour'] }, + ...baseMockResponse + } as any + } + // DefaultValues batch should target Spanish + if (prompt.includes('Welcome')) { + expect(prompt).toContain('Spanish') + return { + output: { translations: ['Bienvenido'] }, + ...baseMockResponse + } as any + } + + return { + output: { translations: ['Translated'] }, + ...baseMockResponse + } as any + }) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: null, + journeyCustomizationFields: fields, + sourceLanguageName: 'English', + targetLanguageName: 'French', + defaultValueTargetLanguageName: 'Spanish' + }) + + expect(result.translatedFields[0]).toEqual({ + id: 'field1', + key: 'greeting', + translatedValue: 'Bonjour', + translatedDefaultValue: 'Bienvenido' + }) + expect(mockGenerateText).toHaveBeenCalledTimes(2) + }) + + it('should fall back to targetLanguageName for defaultValue when defaultValueTargetLanguageName not provided', async () => { + const fields = [ + { + id: 'field1', + journeyId: 'journey123', + key: 'greeting', + value: null, + defaultValue: 'Welcome', + createdAt: new Date(), + updatedAt: new Date() + } + ] + + mockGenerateText.mockImplementation(async (options: any) => { + const prompt = options.messages[1].content[0].text + expect(prompt).toContain('French') + return { + output: { translations: ['Bienvenue'] }, + ...baseMockResponse + } as any + }) + + const result = await translateCustomizationFields({ + journeyCustomizationDescription: null, + journeyCustomizationFields: fields, + sourceLanguageName: 'English', + targetLanguageName: 'French' + }) + + expect(result.translatedFields[0]).toEqual({ + id: 'field1', + key: 'greeting', + translatedValue: null, + translatedDefaultValue: 'Bienvenue' + }) + }) + + it('should not translate addresses, times, or locations in field values', async () => { + const fieldsWithAddresses = [ + { + id: 'field1', + journeyId: 'journey123', + key: 'address', + value: '123 Main Street, New York, NY 10001', + defaultValue: null, + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: 'field2', + journeyId: 'journey123', + key: 'time', + value: '3:00 PM on Monday, January 15', + defaultValue: null, + createdAt: new Date(), + updatedAt: new Date() + } + ] + + mockGenerateText.mockResolvedValueOnce({ + output: { + translations: [ + '123 Main Street, New York, NY 10001', + '3:00 PM on Monday, January 15' + ] + }, + ...baseMockResponse + } as any) + + await translateCustomizationFields({ + journeyCustomizationDescription: null, + journeyCustomizationFields: fieldsWithAddresses, + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + // Both values are batched into a single call + expect(mockGenerateText).toHaveBeenCalledTimes(1) + const promptCalls = mockGenerateText.mock.calls + promptCalls.forEach((call) => { + const prompt = JSON.stringify(call) + expect(prompt).toContain('DO NOT translate addresses') + expect(prompt).toContain('DO NOT translate times') + expect(prompt).toContain('DO NOT translate locations') + }) + }) + + it('should return early when no fields and no description', async () => { + const result = await translateCustomizationFields({ + journeyCustomizationDescription: null, + journeyCustomizationFields: [], + sourceLanguageName: 'English', + targetLanguageName: 'Spanish' + }) + + expect(result.translatedDescription).toBeNull() + expect(result.translatedFields).toHaveLength(0) + expect(mockGenerateText).not.toHaveBeenCalled() + }) +}) diff --git a/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.ts b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.ts new file mode 100644 index 00000000000..e769fa03b1b --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiTranslate/translateCustomizationFields/translateCustomizationFields.ts @@ -0,0 +1,256 @@ +import { google } from '@ai-sdk/google' +import { Output, generateText } from 'ai' +import { z } from 'zod' + +import { JourneyCustomizationField } from '@core/prisma/journeys/client' +import { hardenPrompt, preSystemPrompt } from '@core/shared/ai/prompts' + +const CUSTOMIZATION_SYSTEM_PROMPT = `${preSystemPrompt} + +You are a professional translation engine. +- Translate text accurately while preserving meaning and cultural appropriateness +- DO NOT translate addresses (street addresses, city names, postal codes, country names) +- DO NOT translate times (time formats, day names, month names) +- DO NOT translate locations (place names, venue names, building names) +- DO NOT translate proper nouns (names of people, organizations, brands) +- Maintain the original format and structure` + +const BatchTranslationSchema = z.object({ + translations: z + .array(z.string()) + .describe('Translated texts in the same order as the input values') +}) + +const CustomizationDescriptionTranslationSchema = z.object({ + translatedDescription: z + .string() + .describe( + 'Translated customization description with all {{ ... }} blocks preserved verbatim' + ) +}) + +/** + * Translates multiple values in a single AI call. + * Far more efficient than individual translateValue calls when handling multiple fields. + */ +async function translateBatch({ + values, + sourceLanguageName, + targetLanguageName, + journeyAnalysis +}: { + values: string[] + sourceLanguageName: string + targetLanguageName: string + journeyAnalysis?: string +}): Promise { + if (values.length === 0) return [] + + const numberedValues = values + .map((v, i) => `${i + 1}. ${hardenPrompt(v)}`) + .join('\n') + + const prompt = `${journeyAnalysis ? `Context:\n${hardenPrompt(journeyAnalysis)}\n\n` : ''}Translate each value from ${hardenPrompt(sourceLanguageName)} to ${hardenPrompt(targetLanguageName)}. +Return translations in the same order as input. + +${numberedValues}` + + const { output } = await generateText({ + model: google('gemini-2.5-flash'), + messages: [ + { role: 'system', content: CUSTOMIZATION_SYSTEM_PROMPT }, + { + role: 'user', + content: [{ type: 'text', text: prompt }] + } + ], + output: Output.object({ schema: BatchTranslationSchema }) + }) + + return values.map((original, i) => output.translations[i] ?? original) +} + +/** + * Translates a single value using AI. + * Prefer translateBatch when translating multiple values. + */ +export async function translateValue({ + value, + sourceLanguageName, + targetLanguageName, + journeyAnalysis +}: { + value: string + sourceLanguageName: string + targetLanguageName: string + journeyAnalysis?: string +}): Promise { + const [result] = await translateBatch({ + values: [value], + sourceLanguageName, + targetLanguageName, + journeyAnalysis + }) + return result +} + +/** + * Translates customization fields and description. + * - Batches all field values into a single AI call per target language + * - Batches all field defaultValues into a single AI call + * - Translates description separately (has unique preservation requirements) + * - All {{ ... }} blocks in the description are preserved verbatim + * + * @param journeyCustomizationDescription - The customization description string + * @param journeyCustomizationFields - Array of customization field objects + * @param sourceLanguageName - Source language name + * @param targetLanguageName - Target language name for values and description + * @param defaultValueTargetLanguageName - Target language name for default values (falls back to targetLanguageName) + * @param journeyAnalysis - Optional journey analysis context for better translation + * @returns Object with translated description and fields + */ +export async function translateCustomizationFields({ + journeyCustomizationDescription, + journeyCustomizationFields, + sourceLanguageName, + targetLanguageName, + defaultValueTargetLanguageName, + journeyAnalysis +}: { + journeyCustomizationDescription: string | null + journeyCustomizationFields: JourneyCustomizationField[] + sourceLanguageName: string + targetLanguageName: string + defaultValueTargetLanguageName?: string + journeyAnalysis?: string +}): Promise<{ + translatedDescription: string | null + translatedFields: Array<{ + id: string + key: string + translatedValue: string | null + translatedDefaultValue: string | null + }> +}> { + const effectiveDefaultValueTarget = + defaultValueTargetLanguageName ?? targetLanguageName + + const fieldsWithContent = journeyCustomizationFields.filter( + (field) => field.value || field.defaultValue + ) + + if (fieldsWithContent.length === 0 && !journeyCustomizationDescription) { + return { translatedDescription: null, translatedFields: [] } + } + + const valueEntries = fieldsWithContent + .map((f, i) => ({ index: i, text: f.value })) + .filter((e): e is { index: number; text: string } => e.text != null) + + const defaultValueEntries = fieldsWithContent + .map((f, i) => ({ index: i, text: f.defaultValue })) + .filter((e): e is { index: number; text: string } => e.text != null) + + const [translatedValues, translatedDefaults, translatedDescription] = + await Promise.all([ + valueEntries.length > 0 + ? translateBatch({ + values: valueEntries.map((e) => e.text), + sourceLanguageName, + targetLanguageName, + journeyAnalysis + }) + : Promise.resolve([] as string[]), + + defaultValueEntries.length > 0 + ? translateBatch({ + values: defaultValueEntries.map((e) => e.text), + sourceLanguageName, + targetLanguageName: effectiveDefaultValueTarget, + journeyAnalysis + }) + : Promise.resolve([] as string[]), + + journeyCustomizationDescription + ? translateCustomizationDescription({ + description: journeyCustomizationDescription, + sourceLanguageName, + targetLanguageName, + journeyAnalysis + }) + : Promise.resolve(null) + ]) + + const valueMap = new Map() + valueEntries.forEach((entry, i) => { + valueMap.set(entry.index, translatedValues[i]) + }) + + const defaultValueMap = new Map() + defaultValueEntries.forEach((entry, i) => { + defaultValueMap.set(entry.index, translatedDefaults[i]) + }) + + const translatedFields = fieldsWithContent.map((field, i) => ({ + id: field.id, + key: field.key, + translatedValue: valueMap.get(i) ?? null, + translatedDefaultValue: defaultValueMap.get(i) ?? null + })) + + return { translatedDescription, translatedFields } +} + +/** + * Translates customization description while preserving all {{ ... }} blocks verbatim. + * Only text outside of {{ }} brackets is translated. + */ +export async function translateCustomizationDescription({ + description, + sourceLanguageName, + targetLanguageName, + journeyAnalysis +}: { + description: string + sourceLanguageName: string + targetLanguageName: string + journeyAnalysis?: string +}): Promise { + const fieldPattern = + /\{\{\s*([^:}]+)(?:\s*:\s*(?:(['"])([^'"]*)\2|([^}]*?)))?\s*\}\}/g + + const fieldMatches: string[] = [] + let match + while ((match = fieldPattern.exec(description)) !== null) { + fieldMatches.push(match[0]) + } + + const fieldContext = + fieldMatches.length > 0 + ? `\nThese {{ }} blocks must be preserved EXACTLY as-is:\n${fieldMatches.map((m, i) => `${i + 1}. "${m}"`).join('\n')}\n` + : '' + + const prompt = `${journeyAnalysis ? `Context:\n${hardenPrompt(journeyAnalysis)}\n\n` : ''}Translate this customization description from ${hardenPrompt(sourceLanguageName)} to ${hardenPrompt(targetLanguageName)}. + +CRITICAL: Preserve ALL {{ }} blocks exactly as-is — do not translate, modify, or rewrite anything inside double curly braces. +Only translate text outside {{ }} brackets. +${fieldContext} +Description: +${hardenPrompt(description)}` + + const { output } = await generateText({ + model: google('gemini-2.5-flash'), + messages: [ + { role: 'system', content: CUSTOMIZATION_SYSTEM_PROMPT }, + { + role: 'user', + content: [{ type: 'text', text: prompt }] + } + ], + output: Output.object({ + schema: CustomizationDescriptionTranslationSchema + }) + }) + + return output.translatedDescription +} diff --git a/apis/api-journeys/db/seeds/quickStartTemplate.ts b/apis/api-journeys/db/seeds/quickStartTemplate.ts index 68fdca6a673..4536ff94bd5 100644 --- a/apis/api-journeys/db/seeds/quickStartTemplate.ts +++ b/apis/api-journeys/db/seeds/quickStartTemplate.ts @@ -1,8 +1,11 @@ import { prisma } from '../../../../libs/prisma/journeys/src/client' import { + ButtonAction, JourneyStatus, + MessagePlatform, ThemeMode, - ThemeName + ThemeName, + VideoBlockSource } from '../../src/app/__generated__/graphql' const QUICK_START_TEMPLATE = { @@ -10,8 +13,12 @@ const QUICK_START_TEMPLATE = { slug: 'quick-start-template' } -const CUSTOMIZATION_DESCRIPTION = - 'Hi {{ name: Friend }}, welcome to your journey! We are glad you are here.' +const CUSTOMIZATION_DESCRIPTION = [ + 'Hi {{ name: Friend }}, welcome to {{ church_name: Our Church }}!', + 'We are glad you are here.', + 'Feel free to {{ feedback_label: share your thoughts }} with us.', + 'Visit us at {{ website_label: our website }} or {{ email_label: email us }}.' +].join(' ') export async function quickStartTemplate(action?: 'reset'): Promise { if (action === 'reset') { @@ -47,17 +54,34 @@ export async function quickStartTemplate(action?: 'reset'): Promise { } }) - // Create customization fields parsed from the description - await prisma.journeyCustomizationField.create({ - data: { - journeyId: journey.id, - key: 'name', - value: null, - defaultValue: 'Friend' - } + // Customization fields parsed from the description + await prisma.journeyCustomizationField.createMany({ + data: [ + { journeyId: journey.id, key: 'name', defaultValue: 'Friend' }, + { + journeyId: journey.id, + key: 'church_name', + defaultValue: 'Our Church' + }, + { + journeyId: journey.id, + key: 'feedback_label', + defaultValue: 'share your thoughts' + }, + { + journeyId: journey.id, + key: 'website_label', + defaultValue: 'our website' + }, + { + journeyId: journey.id, + key: 'email_label', + defaultValue: 'email us' + } + ] }) - // Primary image + // ── Primary image ── const primaryImageBlock = await prisma.block.create({ data: { journeyId: journey.id, @@ -75,8 +99,20 @@ export async function quickStartTemplate(action?: 'reset'): Promise { data: { primaryImageBlockId: primaryImageBlock.id } }) - // Step block - const step = await prisma.block.create({ + // ── Chat button (customizable link) ── + await prisma.chatButton.create({ + data: { + journeyId: journey.id, + link: 'https://m.me/your-page', + platform: MessagePlatform.facebook, + customizable: true + } + }) + + // ════════════════════════════════════════════════════ + // Step 1 – Welcome (TypographyBlock, ButtonBlock, customizable ImageBlock) + // ════════════════════════════════════════════════════ + const step1 = await prisma.block.create({ data: { journeyId: journey.id, typename: 'StepBlock', @@ -85,19 +121,18 @@ export async function quickStartTemplate(action?: 'reset'): Promise { } }) - // Card block - const card = await prisma.block.create({ + const card1 = await prisma.block.create({ data: { journeyId: journey.id, typename: 'CardBlock', - parentBlockId: step.id, + parentBlockId: step1.id, fullscreen: false, parentOrder: 0 } }) - // Cover image for the card - const coverBlock = await prisma.block.create({ + // Cover image for card 1 (customizable media) + const coverBlock1 = await prisma.block.create({ data: { journeyId: journey.id, typename: 'ImageBlock', @@ -106,21 +141,22 @@ export async function quickStartTemplate(action?: 'reset'): Promise { width: 1152, height: 768, blurhash: 'UbLX6?~p9FtRkX.8ogD%IUj@M{adxaM_ofkW', - parentBlockId: card.id + parentBlockId: card1.id, + customizable: true } }) await prisma.block.update({ - where: { id: card.id }, - data: { coverBlockId: coverBlock.id } + where: { id: card1.id }, + data: { coverBlockId: coverBlock1.id } }) - // Typography blocks with customization placeholder + // TypographyBlock – content with template variable await prisma.block.createMany({ data: [ { journeyId: journey.id, typename: 'TypographyBlock', - parentBlockId: card.id, + parentBlockId: card1.id, content: 'Welcome, {{ name }}!', variant: 'h3', parentOrder: 0 @@ -128,11 +164,323 @@ export async function quickStartTemplate(action?: 'reset'): Promise { { journeyId: journey.id, typename: 'TypographyBlock', - parentBlockId: card.id, - content: 'Start your journey here and explore what is possible.', + parentBlockId: card1.id, + content: '{{ church_name }} invites you to start your journey.', variant: 'body1', parentOrder: 1 } ] }) + + // ButtonBlock – label with template variable + NavigateToBlockAction + const getStartedButton = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'ButtonBlock', + parentBlockId: card1.id, + label: 'Get Started', + variant: 'contained', + color: 'primary', + size: 'large', + parentOrder: 2 + } + }) + + // ════════════════════════════════════════════════════ + // Step 2 – Video (customizable VideoBlock) + // ════════════════════════════════════════════════════ + const step2 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'StepBlock', + locked: false, + parentOrder: 1 + } + }) + + // Wire "Get Started" button → step 2 + await prisma.action.create({ + data: { + parentBlockId: getStartedButton.id, + gtmEventName: ButtonAction.NavigateToBlockAction, + blockId: step2.id, + journeyId: journey.id + } + }) + + const card2 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'CardBlock', + parentBlockId: step2.id, + fullscreen: false, + parentOrder: 0 + } + }) + + // VideoBlock (customizable media) + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'VideoBlock', + parentBlockId: card2.id, + title: '{{ video_title: Welcome Video }}', + videoId: '2_0-FallingPlates', + videoVariantLanguageId: '529', + source: VideoBlockSource.internal, + startAt: 0, + muted: false, + autoplay: true, + parentOrder: 0, + customizable: true, + notes: 'Replace with your own welcome or intro video.' + } + }) + + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'TypographyBlock', + parentBlockId: card2.id, + content: '{{ video_description: A short video to inspire you. }}', + variant: 'body2', + parentOrder: 1 + } + }) + + const continueButton = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'ButtonBlock', + parentBlockId: card2.id, + label: 'Continue', + variant: 'contained', + color: 'primary', + size: 'medium', + parentOrder: 2 + } + }) + + // ════════════════════════════════════════════════════ + // Step 3 – Feedback (RadioOptionBlock, TextResponseBlock) + // ════════════════════════════════════════════════════ + const step3 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'StepBlock', + locked: false, + parentOrder: 2 + } + }) + + // Wire "Continue" button → step 3 + await prisma.action.create({ + data: { + parentBlockId: continueButton.id, + gtmEventName: ButtonAction.NavigateToBlockAction, + blockId: step3.id, + journeyId: journey.id + } + }) + + const card3 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'CardBlock', + parentBlockId: step3.id, + fullscreen: false, + parentOrder: 0 + } + }) + + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'TypographyBlock', + parentBlockId: card3.id, + content: '{{ response_question: What did you think? }}', + variant: 'h6', + parentOrder: 0 + } + }) + + // RadioQuestionBlock with RadioOptionBlocks (customizable labels) + const radioQuestion = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'RadioQuestionBlock', + parentBlockId: card3.id, + parentOrder: 1 + } + }) + + const radioOption1 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'RadioOptionBlock', + parentBlockId: radioQuestion.id, + label: '{{ option_1: Great }}', + parentOrder: 0 + } + }) + + const radioOption2 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'RadioOptionBlock', + parentBlockId: radioQuestion.id, + label: '{{ option_2: Interested }}', + parentOrder: 1 + } + }) + + const radioOption3 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'RadioOptionBlock', + parentBlockId: radioQuestion.id, + label: '{{ option_3: Need More Info }}', + parentOrder: 2 + } + }) + + // TextResponseBlock (customizable label, placeholder, hint) + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'TextResponseBlock', + parentBlockId: card3.id, + label: '{{ feedback_label: Share your thoughts }}', + hint: '{{ feedback_hint: We would love to hear from you }}', + minRows: 3, + parentOrder: 2 + } + }) + + const nextButton = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'ButtonBlock', + parentBlockId: card3.id, + label: 'Next', + variant: 'contained', + color: 'primary', + size: 'medium', + parentOrder: 3 + } + }) + + // ════════════════════════════════════════════════════ + // Step 4 – Connect (SignUpBlock, LinkAction, EmailAction) + // ════════════════════════════════════════════════════ + const step4 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'StepBlock', + locked: false, + parentOrder: 3 + } + }) + + // Wire "Next" button → step 4 + await prisma.action.create({ + data: { + parentBlockId: nextButton.id, + gtmEventName: ButtonAction.NavigateToBlockAction, + blockId: step4.id, + journeyId: journey.id + } + }) + + // Wire radio options → step 4 + await Promise.all( + [radioOption1, radioOption2, radioOption3].map((opt) => + prisma.action.create({ + data: { + parentBlockId: opt.id, + gtmEventName: ButtonAction.NavigateToBlockAction, + blockId: step4.id, + journeyId: journey.id + } + }) + ) + ) + + const card4 = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'CardBlock', + parentBlockId: step4.id, + fullscreen: false, + parentOrder: 0 + } + }) + + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'TypographyBlock', + parentBlockId: card4.id, + content: '{{ connect_title: Stay Connected }}', + variant: 'h3', + parentOrder: 0 + } + }) + + // SignUpBlock (customizable submitLabel) + await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'SignUpBlock', + parentBlockId: card4.id, + submitLabel: '{{ signup_label: Sign Up }}', + parentOrder: 1 + } + }) + + // ButtonBlock with LinkAction (customizable link) + const websiteButton = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'ButtonBlock', + parentBlockId: card4.id, + label: '{{ website_label: Visit Our Website }}', + variant: 'contained', + color: 'primary', + size: 'medium', + parentOrder: 2 + } + }) + await prisma.action.create({ + data: { + parentBlockId: websiteButton.id, + gtmEventName: ButtonAction.LinkAction, + url: 'https://your-website.com', + journeyId: journey.id, + customizable: true + } + }) + + // ButtonBlock with EmailAction (customizable link) + const emailButton = await prisma.block.create({ + data: { + journeyId: journey.id, + typename: 'ButtonBlock', + parentBlockId: card4.id, + label: '{{ email_label: Email Us }}', + variant: 'outlined', + color: 'primary', + size: 'medium', + parentOrder: 3 + } + }) + await prisma.action.create({ + data: { + parentBlockId: emailButton.id, + gtmEventName: ButtonAction.EmailAction, + email: 'hello@your-church.com', + journeyId: journey.id, + customizable: true + } + }) } diff --git a/apis/api-journeys/src/__generated__/graphql.ts b/apis/api-journeys/src/__generated__/graphql.ts index e0385f5df7f..748ef8647df 100644 --- a/apis/api-journeys/src/__generated__/graphql.ts +++ b/apis/api-journeys/src/__generated__/graphql.ts @@ -88,6 +88,7 @@ export type BibleBook = { order: Scalars['Int']['output']; osisId: Scalars['String']['output']; paratextAbbreviation: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; }; @@ -103,6 +104,10 @@ export type BibleBookName = { value: Scalars['String']['output']; }; +export type BibleBooksFilter = { + updatedAt?: InputMaybe; +}; + export type BibleCitation = { __typename?: 'BibleCitation'; bibleBook: BibleBook; @@ -569,6 +574,10 @@ export type ContinentName = { value: Scalars['String']['output']; }; +export type CountriesFilter = { + updatedAt?: InputMaybe; +}; + export type Country = { __typename?: 'Country'; continent: Continent; @@ -583,6 +592,7 @@ export type Country = { longitude?: Maybe; name: Array; population?: Maybe; + updatedAt: Scalars['DateTime']['output']; }; @@ -678,6 +688,11 @@ export type CustomDomainVerificationResponse = { message: Scalars['String']['output']; }; +export type DateTimeFilter = { + gte?: InputMaybe; + lte?: InputMaybe; +}; + export enum DefaultPlatform { Android = 'android', Ios = 'ios', @@ -1136,11 +1151,20 @@ export type Journey = { }; export type JourneyAiTranslateInput = { + /** The ID of the journey to translate */ journeyId: Scalars['ID']['input']; + /** The source language name of the journey content */ journeyLanguageName: Scalars['String']['input']; + /** The journey name to translate */ name: Scalars['String']['input']; + /** The target language ID for journey content (blocks, title, description) */ textLanguageId: Scalars['ID']['input']; + /** The target language name for journey content (blocks, title, description) */ textLanguageName: Scalars['String']['input']; + /** Language ID for customization text translation. Falls back to textLanguageId if not provided. */ + userLanguageId?: InputMaybe; + /** Language name for customization text translation. Falls back to textLanguageName if not provided. */ + userLanguageName?: InputMaybe; }; export type JourneyAiTranslateProgress = { @@ -1195,6 +1219,15 @@ export type JourneyCreateInput = { title: Scalars['String']['input']; }; +export type JourneyCustomizationDescriptionTranslateInput = { + /** The ID of the journey whose customization description to translate */ + journeyId: Scalars['ID']['input']; + /** The current language of the customization description */ + sourceLanguageName: Scalars['String']['input']; + /** The language to translate the customization description into */ + targetLanguageName: Scalars['String']['input']; +}; + export type JourneyCustomizationField = { __typename?: 'JourneyCustomizationField'; defaultValue?: Maybe; @@ -1603,9 +1636,14 @@ export type Keyword = { __typename?: 'Keyword'; id: Scalars['ID']['output']; language: Language; + updatedAt: Scalars['DateTime']['output']; value: Scalars['String']['output']; }; +export type KeywordsFilter = { + updatedAt?: InputMaybe; +}; + export type LabeledVideoCounts = { __typename?: 'LabeledVideoCounts'; featureFilmCount: Scalars['Int']['output']; @@ -1623,6 +1661,7 @@ export type Language = { labeledVideoCounts: LabeledVideoCounts; name: Array; slug?: Maybe; + updatedAt: Scalars['DateTime']['output']; }; @@ -1657,6 +1696,7 @@ export type LanguagesFilter = { bcp47?: InputMaybe>; ids?: InputMaybe>; iso3?: InputMaybe>; + updatedAt?: InputMaybe; }; export type LinkAction = Action & { @@ -1878,6 +1918,7 @@ export type Mutation = { journeyCollectionDelete: JourneyCollection; journeyCollectionUpdate: JourneyCollection; journeyCreate: Journey; + journeyCustomizationDescriptionTranslate: Journey; journeyCustomizationFieldPublisherUpdate: Array; journeyCustomizationFieldUserUpdate: Array; journeyDuplicate: Journey; @@ -2007,7 +2048,6 @@ export type Mutation = { videoPlayEventCreate: VideoPlayEvent; videoProgressEventCreate: VideoProgressEvent; videoPublishChildren: VideoPublishChildrenResult; - videoPublishChildrenAndLanguages: VideoPublishChildrenAndLanguagesResult; videoSnippetCreate: VideoSnippet; videoSnippetDelete: VideoSnippet; videoSnippetUpdate: VideoSnippet; @@ -2445,6 +2485,11 @@ export type MutationJourneyCreateArgs = { }; +export type MutationJourneyCustomizationDescriptionTranslateArgs = { + input: JourneyCustomizationDescriptionTranslateInput; +}; + + export type MutationJourneyCustomizationFieldPublisherUpdateArgs = { journeyId: Scalars['ID']['input']; string: Scalars['String']['input']; @@ -3023,12 +3068,9 @@ export type MutationVideoProgressEventCreateArgs = { export type MutationVideoPublishChildrenArgs = { + dryRun: Scalars['Boolean']['input']; id: Scalars['ID']['input']; -}; - - -export type MutationVideoPublishChildrenAndLanguagesArgs = { - id: Scalars['ID']['input']; + mode: VideoPublishMode; }; @@ -3881,6 +3923,11 @@ export type QueryArclightApiKeyByKeyArgs = { }; +export type QueryBibleBooksArgs = { + where?: InputMaybe; +}; + + export type QueryBibleCitationArgs = { id: Scalars['ID']['input']; }; @@ -3914,6 +3961,7 @@ export type QueryCheckVideoVariantsInAlgoliaArgs = { export type QueryCountriesArgs = { ids?: InputMaybe>; term?: InputMaybe; + where?: InputMaybe; }; @@ -4088,6 +4136,11 @@ export type QueryJourneysPlausibleStatsTimeseriesArgs = { }; +export type QueryKeywordsArgs = { + where?: InputMaybe; +}; + + export type QueryLanguageArgs = { id: Scalars['ID']['input']; idType?: InputMaybe; @@ -5292,6 +5345,7 @@ export type Video = { studyQuestions: Array; subtitles: Array; title: Array; + updatedAt: Scalars['DateTime']['output']; /** @deprecated Use variants instead */ variant?: Maybe; variantLanguages: Array; @@ -5626,6 +5680,7 @@ export type VideoEdition = { __typename?: 'VideoEdition'; id: Scalars['ID']['output']; name?: Maybe; + updatedAt: Scalars['DateTime']['output']; videoSubtitles: Array; videoVariants: Array; }; @@ -5795,22 +5850,30 @@ export type VideoProgressEventCreateInput = { value?: InputMaybe; }; -export type VideoPublishChildrenAndLanguagesResult = { - __typename?: 'VideoPublishChildrenAndLanguagesResult'; +export type VideoPublishChildrenResult = { + __typename?: 'VideoPublishChildrenResult'; + dryRun?: Maybe; parentId?: Maybe; - publishedChildIds?: Maybe>; - publishedChildrenCount?: Maybe; publishedVariantIds?: Maybe>; publishedVariantsCount?: Maybe; + publishedVideoCount?: Maybe; + publishedVideoIds?: Maybe>; + videosFailedValidation: Array; }; -export type VideoPublishChildrenResult = { - __typename?: 'VideoPublishChildrenResult'; - parentId?: Maybe; - publishedChildIds?: Maybe>; - publishedChildrenCount?: Maybe; +export type VideoPublishChildrenUnpublishedVideo = { + __typename?: 'VideoPublishChildrenUnpublishedVideo'; + message?: Maybe; + missingFields?: Maybe>; + videoId?: Maybe; }; +export enum VideoPublishMode { + ChildrenVideosAndVariants = 'childrenVideosAndVariants', + ChildrenVideosOnly = 'childrenVideosOnly', + VariantsOnly = 'variantsOnly' +} + export enum VideoRedirectType { Dh = 'dh', Dl = 'dl', @@ -6010,6 +6073,7 @@ export type VideoVariant = { slug: Scalars['String']['output']; subtitle: Array; subtitleCount: Scalars['Int']['output']; + updatedAt: Scalars['DateTime']['output']; /** version control for master video file */ version: Scalars['Int']['output']; video?: Maybe