@posthog/core 1.27.9 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/logs/index.d.ts +94 -4
  4. package/dist/logs/index.d.ts.map +1 -1
  5. package/dist/logs/index.js +147 -4
  6. package/dist/logs/index.mjs +148 -5
  7. package/dist/logs/logs-utils.d.ts +2 -1
  8. package/dist/logs/logs-utils.d.ts.map +1 -1
  9. package/dist/logs/types.d.ts +140 -8
  10. package/dist/logs/types.d.ts.map +1 -1
  11. package/dist/posthog-core-stateless.d.ts +34 -0
  12. package/dist/posthog-core-stateless.d.ts.map +1 -1
  13. package/dist/posthog-core-stateless.js +39 -0
  14. package/dist/posthog-core-stateless.mjs +39 -0
  15. package/dist/surveys/events.d.ts +22 -0
  16. package/dist/surveys/events.d.ts.map +1 -0
  17. package/dist/surveys/events.js +95 -0
  18. package/dist/surveys/events.mjs +43 -0
  19. package/dist/surveys/index.d.ts +4 -0
  20. package/dist/surveys/index.d.ts.map +1 -0
  21. package/dist/surveys/index.js +83 -0
  22. package/dist/surveys/index.mjs +4 -0
  23. package/dist/surveys/translations.d.ts +38 -0
  24. package/dist/surveys/translations.d.ts.map +1 -0
  25. package/dist/surveys/translations.js +207 -0
  26. package/dist/surveys/translations.mjs +158 -0
  27. package/dist/testing/test-utils.d.ts.map +1 -1
  28. package/dist/testing/test-utils.js +1 -0
  29. package/dist/testing/test-utils.mjs +1 -0
  30. package/dist/types.d.ts +31 -2
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/utils/logger.d.ts +1 -1
  33. package/dist/utils/logger.d.ts.map +1 -1
  34. package/dist/utils/logger.js +3 -0
  35. package/dist/utils/logger.mjs +3 -0
  36. package/package.json +26 -2
  37. package/src/index.ts +12 -1
  38. package/src/logs/index.spec.ts +891 -17
  39. package/src/logs/index.ts +337 -13
  40. package/src/logs/logs-utils.spec.ts +2 -1
  41. package/src/logs/logs-utils.ts +1 -1
  42. package/src/logs/types.ts +150 -25
  43. package/src/posthog-core-stateless.ts +64 -0
  44. package/src/surveys/events.spec.ts +52 -0
  45. package/src/surveys/events.ts +80 -0
  46. package/src/surveys/index.ts +18 -0
  47. package/src/surveys/translations.spec.ts +205 -0
  48. package/src/surveys/translations.ts +244 -0
  49. package/src/testing/test-utils.ts +1 -0
  50. package/src/types.ts +38 -2
  51. package/src/utils/logger.ts +6 -2
@@ -1,3 +1,4 @@
1
+ import type { OtlpLogsPayload } from '@posthog/types'
1
2
  import { SimpleEventEmitter } from './eventemitter'
2
3
  import { getFeatureFlagValue, normalizeFlagsResponse } from './featureFlagUtils'
3
4
  import { gzipCompress, isGzipSupported } from './gzip'
@@ -94,6 +95,23 @@ function isPostHogFetchContentTooLargeError(err: unknown): err is PostHogFetchHt
94
95
  return typeof err === 'object' && err instanceof PostHogFetchHttpError && err.status === 413
95
96
  }
96
97
 
98
+ /**
99
+ * Outcome of a logs batch send. Keeps HTTP error classification inside core
100
+ * (single source of truth — same policy events already use in `_flush()`) so
101
+ * PostHogLogs doesn't need to know about specific error types.
102
+ *
103
+ * - ok → records are accepted; drop them from the queue
104
+ * - too-large → 413; caller should halve batch size and retry same records
105
+ * - retry-later → network error; caller keeps records and retries next cycle
106
+ * - fatal → anything else (auth, malformed, etc.); caller drops the
107
+ * batch and surfaces the error
108
+ */
109
+ export type SendLogsBatchOutcome =
110
+ | { kind: 'ok' }
111
+ | { kind: 'too-large' }
112
+ | { kind: 'retry-later'; error: unknown }
113
+ | { kind: 'fatal'; error: unknown }
114
+
97
115
  export enum QuotaLimitedFeature {
98
116
  FeatureFlags = 'feature_flags',
99
117
  Recordings = 'recordings',
@@ -1179,6 +1197,52 @@ export abstract class PostHogCoreStateless {
1179
1197
  this._events.emit('flush', sentMessages)
1180
1198
  }
1181
1199
 
1200
+ /**
1201
+ * Sends a pre-built OTLP logs payload to `/i/v1/logs`. Returns a tagged
1202
+ * outcome instead of throwing so PostHogLogs doesn't have to know about the
1203
+ * core's error class hierarchy. Error classification lives here (single
1204
+ * source of truth, same policy the events `_flush()` uses for its own
1205
+ * 413 / network / fatal handling).
1206
+ *
1207
+ * 413 is passed through as `too-large` (not auto-retried) so the caller can
1208
+ * shrink `maxBatchRecordsPerPost` and retry the same records.
1209
+ */
1210
+ async _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome> {
1211
+ const serialized = JSON.stringify(payload)
1212
+ const url = `${this.host}/i/v1/logs?token=${encodeURIComponent(this.apiKey)}`
1213
+
1214
+ const gzippedPayload = !this.disableCompression ? await gzipCompress(serialized, this.isDebug) : null
1215
+ const fetchOptions: PostHogFetchOptions = {
1216
+ method: 'POST',
1217
+ headers: {
1218
+ ...this.getCustomHeaders(),
1219
+ 'Content-Type': 'application/json',
1220
+ ...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
1221
+ },
1222
+ body: gzippedPayload || serialized,
1223
+ }
1224
+
1225
+ try {
1226
+ await this.fetchWithRetry(url, fetchOptions, {
1227
+ retryCheck: (err) => {
1228
+ if (isPostHogFetchContentTooLargeError(err)) {
1229
+ return false
1230
+ }
1231
+ return isPostHogFetchError(err)
1232
+ },
1233
+ })
1234
+ return { kind: 'ok' }
1235
+ } catch (err) {
1236
+ if (isPostHogFetchContentTooLargeError(err)) {
1237
+ return { kind: 'too-large' }
1238
+ }
1239
+ if (err instanceof PostHogFetchNetworkError) {
1240
+ return { kind: 'retry-later', error: err }
1241
+ }
1242
+ return { kind: 'fatal', error: err }
1243
+ }
1244
+ }
1245
+
1182
1246
  private async fetchWithRetry(
1183
1247
  url: string,
1184
1248
  options: PostHogFetchOptions,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ buildSurveyResponseProperties,
4
+ getSurveyInteractionProperty,
5
+ getSurveyResponseKey,
6
+ getSurveyResponseValue,
7
+ surveyHasResponses,
8
+ } from './events'
9
+
10
+ describe('survey event helpers', () => {
11
+ const survey = {
12
+ id: 'survey-1',
13
+ current_iteration: 2,
14
+ questions: [
15
+ { id: 'q1', question: 'Rate us', originalQuestionIndex: 0 },
16
+ { id: 'q2', question: 'Anything else?', originalQuestionIndex: 1 },
17
+ ],
18
+ }
19
+
20
+ it('builds response properties with current and legacy response keys', () => {
21
+ const responses = {
22
+ [getSurveyResponseKey('q1')]: 5,
23
+ [getSurveyResponseKey('q2')]: ['fast', 'clear'],
24
+ }
25
+
26
+ expect(buildSurveyResponseProperties(responses, survey)).toEqual({
27
+ $survey_questions: [
28
+ { id: 'q1', question: 'Rate us', response: 5 },
29
+ { id: 'q2', question: 'Anything else?', response: ['fast', 'clear'] },
30
+ ],
31
+ $survey_response_q1: 5,
32
+ $survey_response_q2: ['fast', 'clear'],
33
+ $survey_response: 5,
34
+ $survey_response_1: ['fast', 'clear'],
35
+ })
36
+ })
37
+
38
+ it('copies array response values before returning them', () => {
39
+ const responses = { [getSurveyResponseKey('q1')]: ['a'] }
40
+
41
+ const response = getSurveyResponseValue(responses, 'q1')
42
+
43
+ expect(response).toEqual(['a'])
44
+ expect(response).not.toBe(responses.$survey_response_q1)
45
+ })
46
+
47
+ it('detects non-nullish responses and builds interaction property names', () => {
48
+ expect(surveyHasResponses({ $survey_response_q1: null })).toBe(false)
49
+ expect(surveyHasResponses({ $survey_response_q1: 0 })).toBe(true)
50
+ expect(getSurveyInteractionProperty(survey, 'responded')).toBe('$survey_responded/survey-1/2')
51
+ })
52
+ })
@@ -0,0 +1,80 @@
1
+ import { SurveyResponses, SurveyResponseValue } from '../types'
2
+ import { isArray, isNullish, isUndefined } from '../utils'
3
+
4
+ export const SURVEY_LANGUAGE_PROPERTY = '$survey_language'
5
+
6
+ export function getSurveyResponseKey(questionId: string): string {
7
+ return `$survey_response_${questionId}`
8
+ }
9
+
10
+ export function getSurveyOldResponseKey(originalQuestionIndex: number): string {
11
+ return originalQuestionIndex === 0 ? '$survey_response' : `$survey_response_${originalQuestionIndex}`
12
+ }
13
+
14
+ export function getSurveyResponseValue(
15
+ responses: SurveyResponses,
16
+ questionId?: string
17
+ ): SurveyResponseValue | undefined {
18
+ if (!questionId) {
19
+ return null
20
+ }
21
+ const response = responses[getSurveyResponseKey(questionId)]
22
+ if (isArray(response)) {
23
+ return [...response]
24
+ }
25
+ return response
26
+ }
27
+
28
+ export function buildSurveyResponseProperties(
29
+ responses: SurveyResponses = {},
30
+ survey: SurveyForResponses
31
+ ): Record<string, unknown> {
32
+ const oldFormatResponses: SurveyResponses = {}
33
+ survey.questions.forEach((question: SurveyQuestionForResponses) => {
34
+ if (isUndefined(question.originalQuestionIndex)) {
35
+ return
36
+ }
37
+ const oldResponseKey = getSurveyOldResponseKey(question.originalQuestionIndex)
38
+ const response = getSurveyResponseValue(responses, question.id)
39
+ if (!isUndefined(response)) {
40
+ oldFormatResponses[oldResponseKey] = response
41
+ }
42
+ })
43
+
44
+ return {
45
+ $survey_questions: survey.questions.map((question: SurveyQuestionForResponses) => ({
46
+ id: question.id,
47
+ question: question.question,
48
+ response: getSurveyResponseValue(responses, question.id),
49
+ })),
50
+ ...responses,
51
+ ...oldFormatResponses,
52
+ }
53
+ }
54
+
55
+ export function surveyHasResponses(responses: SurveyResponses = {}): boolean {
56
+ return Object.values(responses).some((response) => !isNullish(response))
57
+ }
58
+
59
+ export function getSurveyInteractionProperty(survey: SurveyWithIteration, action: string): string {
60
+ let surveyProperty = `$survey_${action}/${survey.id}`
61
+ if (survey.current_iteration && survey.current_iteration > 0) {
62
+ surveyProperty = `$survey_${action}/${survey.id}/${survey.current_iteration}`
63
+ }
64
+
65
+ return surveyProperty
66
+ }
67
+ type SurveyQuestionForResponses = {
68
+ id?: string
69
+ question: string
70
+ originalQuestionIndex?: number
71
+ }
72
+
73
+ type SurveyForResponses = {
74
+ questions: SurveyQuestionForResponses[]
75
+ }
76
+
77
+ type SurveyWithIteration = {
78
+ id: string
79
+ current_iteration?: number | null
80
+ }
@@ -0,0 +1,18 @@
1
+ export { getValidationError, getLengthFromRules, getRequirementsHint } from './validation'
2
+ export {
3
+ buildSurveyResponseProperties,
4
+ getSurveyInteractionProperty,
5
+ getSurveyOldResponseKey,
6
+ getSurveyResponseKey,
7
+ getSurveyResponseValue,
8
+ SURVEY_LANGUAGE_PROPERTY,
9
+ surveyHasResponses,
10
+ } from './events'
11
+ export {
12
+ applySurveyTranslation,
13
+ detectSurveyLanguage,
14
+ findBestTranslationMatch,
15
+ getBaseLanguage,
16
+ getLanguageFromStoredPersonProperties,
17
+ normalizeLanguageCode,
18
+ } from './translations'
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ applySurveyTranslation,
4
+ detectSurveyLanguage,
5
+ findBestTranslationMatch,
6
+ getLanguageFromStoredPersonProperties,
7
+ } from './translations'
8
+ import { Survey, SurveyQuestionType, SurveyType } from '../types'
9
+
10
+ const createBaseSurvey = (): Survey => ({
11
+ id: 'test-survey',
12
+ name: 'Test Survey',
13
+ description: 'Test Description',
14
+ type: SurveyType.Popover,
15
+ questions: [
16
+ {
17
+ type: SurveyQuestionType.Open,
18
+ question: 'What do you think?',
19
+ id: 'q1',
20
+ originalQuestionIndex: 0,
21
+ },
22
+ ],
23
+ appearance: {
24
+ thankYouMessageHeader: 'Thank you!',
25
+ thankYouMessageDescription: 'We appreciate your feedback',
26
+ thankYouMessageCloseButtonText: 'Close',
27
+ },
28
+ })
29
+
30
+ describe('survey translations', () => {
31
+ describe('detectSurveyLanguage', () => {
32
+ it.each([
33
+ {
34
+ name: 'prioritizes override language',
35
+ input: {
36
+ overrideLanguage: 'de',
37
+ storedPersonProperties: { language: 'es' },
38
+ locale: 'fr',
39
+ },
40
+ expected: 'de',
41
+ },
42
+ {
43
+ name: 'uses person language when override is missing',
44
+ input: {
45
+ storedPersonProperties: { language: 'es' },
46
+ locale: 'fr',
47
+ },
48
+ expected: 'es',
49
+ },
50
+ {
51
+ name: 'falls back to locale',
52
+ input: {
53
+ storedPersonProperties: { some_other_property: 'value' },
54
+ locale: 'fr-CA',
55
+ },
56
+ expected: 'fr-CA',
57
+ },
58
+ {
59
+ name: 'returns null when no sources exist',
60
+ input: {
61
+ storedPersonProperties: { some_other_property: 'value' },
62
+ },
63
+ expected: null,
64
+ },
65
+ {
66
+ name: 'trims override language',
67
+ input: {
68
+ overrideLanguage: ' es ',
69
+ },
70
+ expected: 'es',
71
+ },
72
+ ])('$name', ({ input, expected }) => {
73
+ expect(detectSurveyLanguage(input)).toBe(expected)
74
+ })
75
+
76
+ it('reads language from stored person properties', () => {
77
+ expect(getLanguageFromStoredPersonProperties({ language: 'it' })).toBe('it')
78
+ expect(getLanguageFromStoredPersonProperties({ language: ' ' })).toBeNull()
79
+ expect(getLanguageFromStoredPersonProperties({})).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('findBestTranslationMatch', () => {
84
+ it('supports exact and base-language matches', () => {
85
+ expect(findBestTranslationMatch({ fr: {}, 'fr-CA': {} }, 'FR-ca')).toBe('fr-CA')
86
+ expect(findBestTranslationMatch({ fr: {} }, 'fr-CA')).toBe('fr')
87
+ expect(findBestTranslationMatch({ es: {} }, 'de')).toBeNull()
88
+ })
89
+ })
90
+
91
+ describe('applySurveyTranslation', () => {
92
+ it('returns original survey when no translations exist', () => {
93
+ const survey = createBaseSurvey()
94
+ const result = applySurveyTranslation(survey, 'fr')
95
+
96
+ expect(result.survey).toEqual(survey)
97
+ expect(result.matchedKey).toBeNull()
98
+ })
99
+
100
+ it('applies survey-level translations', () => {
101
+ const survey = createBaseSurvey()
102
+ survey.translations = {
103
+ fr: {
104
+ name: 'Enquete Test',
105
+ thankYouMessageHeader: 'Merci',
106
+ thankYouMessageDescription: 'Merci pour votre retour',
107
+ thankYouMessageCloseButtonText: 'Fermer',
108
+ },
109
+ }
110
+
111
+ const result = applySurveyTranslation(survey, 'fr')
112
+
113
+ expect(result.survey.name).toBe('Enquete Test')
114
+ expect(result.survey.appearance?.thankYouMessageHeader).toBe('Merci')
115
+ expect(result.survey.appearance?.thankYouMessageDescription).toBe('Merci pour votre retour')
116
+ expect(result.survey.appearance?.thankYouMessageCloseButtonText).toBe('Fermer')
117
+ expect(result.matchedKey).toBe('fr')
118
+ })
119
+
120
+ it('applies question-level translations', () => {
121
+ const survey = createBaseSurvey()
122
+ survey.questions = [
123
+ {
124
+ type: SurveyQuestionType.Rating,
125
+ question: 'How was it?',
126
+ id: 'q1',
127
+ originalQuestionIndex: 0,
128
+ display: 'number',
129
+ scale: 5,
130
+ lowerBoundLabel: 'Bad',
131
+ upperBoundLabel: 'Great',
132
+ translations: {
133
+ pt: {
134
+ question: 'Como foi?',
135
+ lowerBoundLabel: 'Ruim',
136
+ upperBoundLabel: 'Otimo',
137
+ },
138
+ },
139
+ },
140
+ {
141
+ type: SurveyQuestionType.MultipleChoice,
142
+ question: 'Pick one',
143
+ id: 'q2',
144
+ originalQuestionIndex: 1,
145
+ choices: ['One', 'Other'],
146
+ hasOpenChoice: true,
147
+ translations: {
148
+ pt: {
149
+ choices: ['Um', 'Outro'],
150
+ },
151
+ },
152
+ },
153
+ ]
154
+
155
+ const result = applySurveyTranslation(survey, 'pt-BR')
156
+
157
+ expect(result.survey.questions[0].question).toBe('Como foi?')
158
+ expect('lowerBoundLabel' in result.survey.questions[0] && result.survey.questions[0].lowerBoundLabel).toBe('Ruim')
159
+ expect('upperBoundLabel' in result.survey.questions[0] && result.survey.questions[0].upperBoundLabel).toBe(
160
+ 'Otimo'
161
+ )
162
+ expect('choices' in result.survey.questions[1] && result.survey.questions[1].choices).toEqual(['Um', 'Outro'])
163
+ expect(result.matchedKey).toBe('pt')
164
+ })
165
+
166
+ it('uses question-level match when there is no survey-level translation', () => {
167
+ const survey = createBaseSurvey()
168
+ survey.questions[0].translations = {
169
+ de: {
170
+ question: 'Was denkst du?',
171
+ },
172
+ }
173
+
174
+ const result = applySurveyTranslation(survey, 'de')
175
+
176
+ expect(result.survey.questions[0].question).toBe('Was denkst du?')
177
+ expect(result.matchedKey).toBe('de')
178
+ })
179
+
180
+ it('preserves custom survey and question fields for shared consumers', () => {
181
+ const survey = {
182
+ name: 'Custom Survey',
183
+ customSurveyField: true,
184
+ questions: [
185
+ {
186
+ question: 'Pick one',
187
+ customQuestionField: 'native-renderer',
188
+ translations: {
189
+ fr: {
190
+ question: 'Choisissez une option',
191
+ },
192
+ },
193
+ },
194
+ ],
195
+ }
196
+
197
+ const result = applySurveyTranslation(survey, 'fr')
198
+
199
+ expect(result.survey.customSurveyField).toBe(true)
200
+ expect(result.survey.questions[0].question).toBe('Choisissez une option')
201
+ expect(result.survey.questions[0].customQuestionField).toBe('native-renderer')
202
+ expect(result.matchedKey).toBe('fr')
203
+ })
204
+ })
205
+ })
@@ -0,0 +1,244 @@
1
+ import { SurveyQuestionTranslation, SurveyTranslation } from '../types'
2
+ import { createLogger, isArray, isUndefined } from '../utils'
3
+
4
+ const logger = createLogger('[SurveyTranslations]')
5
+
6
+ export type DetectSurveyLanguageOptions = {
7
+ overrideLanguage?: unknown
8
+ storedPersonProperties?: unknown
9
+ locale?: unknown
10
+ }
11
+
12
+ function getTrimmedLanguage(value: unknown): string | null {
13
+ return typeof value === 'string' && value.trim() ? value.trim() : null
14
+ }
15
+
16
+ export function getLanguageFromStoredPersonProperties(storedPersonProperties: unknown): string | null {
17
+ if (
18
+ !storedPersonProperties ||
19
+ typeof storedPersonProperties !== 'object' ||
20
+ !('language' in storedPersonProperties)
21
+ ) {
22
+ return null
23
+ }
24
+
25
+ return getTrimmedLanguage(storedPersonProperties.language)
26
+ }
27
+
28
+ export function detectSurveyLanguage({
29
+ overrideLanguage,
30
+ storedPersonProperties,
31
+ locale,
32
+ }: DetectSurveyLanguageOptions): string | null {
33
+ const explicitLanguage = getTrimmedLanguage(overrideLanguage)
34
+ if (explicitLanguage) {
35
+ logger.info(`Using override display language: ${explicitLanguage}`)
36
+ return explicitLanguage
37
+ }
38
+
39
+ const personLanguage = getLanguageFromStoredPersonProperties(storedPersonProperties)
40
+ if (personLanguage) {
41
+ logger.info(`Using person property language: ${personLanguage}`)
42
+ return personLanguage
43
+ }
44
+
45
+ const detectedLocale = getTrimmedLanguage(locale)
46
+ if (detectedLocale) {
47
+ logger.info(`Using detected locale: ${detectedLocale}`)
48
+ return detectedLocale
49
+ }
50
+
51
+ logger.info('No user language detected')
52
+ return null
53
+ }
54
+
55
+ export function normalizeLanguageCode(languageCode: string): string {
56
+ return languageCode.toLowerCase()
57
+ }
58
+
59
+ export function getBaseLanguage(languageCode: string): string {
60
+ return languageCode.split('-')[0]
61
+ }
62
+
63
+ export function findBestTranslationMatch(
64
+ translations: Record<string, unknown> | undefined,
65
+ targetLanguage: string
66
+ ): string | null {
67
+ if (!translations || !targetLanguage) {
68
+ return null
69
+ }
70
+
71
+ const normalizedTarget = normalizeLanguageCode(targetLanguage)
72
+
73
+ const exactMatch = Object.keys(translations).find((key) => normalizeLanguageCode(key) === normalizedTarget)
74
+ if (exactMatch) {
75
+ logger.debug(`Found exact translation match: ${exactMatch}`)
76
+ return exactMatch
77
+ }
78
+
79
+ if (normalizedTarget.includes('-')) {
80
+ const baseLanguage = getBaseLanguage(normalizedTarget)
81
+ const baseMatch = Object.keys(translations).find((key) => normalizeLanguageCode(key) === baseLanguage)
82
+ if (baseMatch) {
83
+ logger.debug(`Found base language translation match: ${baseMatch} (from ${targetLanguage})`)
84
+ return baseMatch
85
+ }
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ function isTranslatedChoices(
92
+ questionTranslation: SurveyQuestionTranslation
93
+ ): questionTranslation is SurveyQuestionTranslation & { choices: string[] } {
94
+ return isArray(questionTranslation.choices)
95
+ }
96
+
97
+ function hasThankYouTranslation(translation: SurveyTranslation): boolean {
98
+ return (
99
+ !isUndefined(translation.thankYouMessageHeader) ||
100
+ !isUndefined(translation.thankYouMessageDescription) ||
101
+ !isUndefined(translation.thankYouMessageCloseButtonText)
102
+ )
103
+ }
104
+
105
+ type TranslatableSurveyAppearance = {
106
+ thankYouMessageHeader?: string
107
+ thankYouMessageDescription?: string | null
108
+ thankYouMessageCloseButtonText?: string
109
+ }
110
+
111
+ type TranslatableSurveyQuestion = {
112
+ question: string
113
+ description?: string | null
114
+ buttonText?: string
115
+ link?: string | null
116
+ lowerBoundLabel?: string
117
+ upperBoundLabel?: string
118
+ choices?: string[]
119
+ translations?: Record<string, SurveyQuestionTranslation>
120
+ }
121
+
122
+ type TranslatableSurvey<TQuestion extends TranslatableSurveyQuestion = TranslatableSurveyQuestion> = {
123
+ name: string
124
+ translations?: Record<string, SurveyTranslation>
125
+ appearance?: TranslatableSurveyAppearance | null
126
+ questions: TQuestion[]
127
+ }
128
+
129
+ function mergeQuestionTranslation<TQuestion extends TranslatableSurveyQuestion>(
130
+ question: TQuestion,
131
+ targetLanguage: string
132
+ ): { question: TQuestion; matchedKey: string | null; hasChanges: boolean } {
133
+ const translationKey = findBestTranslationMatch(question.translations, targetLanguage)
134
+ if (!translationKey) {
135
+ return { question, matchedKey: null, hasChanges: false }
136
+ }
137
+
138
+ const questionTranslation = question.translations?.[translationKey]
139
+ if (!questionTranslation) {
140
+ return { question, matchedKey: null, hasChanges: false }
141
+ }
142
+
143
+ const translated: TQuestion = { ...question }
144
+ let hasChanges = false
145
+
146
+ if (!isUndefined(questionTranslation.question)) {
147
+ translated.question = questionTranslation.question
148
+ hasChanges = true
149
+ }
150
+ if (!isUndefined(questionTranslation.description)) {
151
+ translated.description = questionTranslation.description
152
+ hasChanges = true
153
+ }
154
+ if (!isUndefined(questionTranslation.buttonText)) {
155
+ translated.buttonText = questionTranslation.buttonText
156
+ hasChanges = true
157
+ }
158
+
159
+ if ('link' in translated && !isUndefined(questionTranslation.link)) {
160
+ translated.link = questionTranslation.link
161
+ hasChanges = true
162
+ }
163
+
164
+ if ('lowerBoundLabel' in translated && !isUndefined(questionTranslation.lowerBoundLabel)) {
165
+ translated.lowerBoundLabel = questionTranslation.lowerBoundLabel
166
+ hasChanges = true
167
+ }
168
+ if ('upperBoundLabel' in translated && !isUndefined(questionTranslation.upperBoundLabel)) {
169
+ translated.upperBoundLabel = questionTranslation.upperBoundLabel
170
+ hasChanges = true
171
+ }
172
+
173
+ if ('choices' in translated && isTranslatedChoices(questionTranslation)) {
174
+ translated.choices = questionTranslation.choices
175
+ hasChanges = true
176
+ }
177
+
178
+ return {
179
+ question: hasChanges ? translated : question,
180
+ matchedKey: hasChanges ? translationKey : null,
181
+ hasChanges,
182
+ }
183
+ }
184
+
185
+ export function applySurveyTranslation<
186
+ TQuestion extends TranslatableSurveyQuestion,
187
+ TSurvey extends TranslatableSurvey<TQuestion>,
188
+ >(survey: TSurvey, targetLanguage: string): { survey: TSurvey; matchedKey: string | null } {
189
+ const translationKey = findBestTranslationMatch(survey.translations, targetLanguage)
190
+
191
+ const translated: TSurvey = { ...survey }
192
+ let hasTranslation = false
193
+
194
+ if (translationKey) {
195
+ const translation = survey.translations?.[translationKey]
196
+ if (translation) {
197
+ logger.info(`Applying survey-level translation for language: ${translationKey}`)
198
+
199
+ if (!isUndefined(translation.name)) {
200
+ translated.name = translation.name
201
+ hasTranslation = true
202
+ }
203
+
204
+ if (translated.appearance) {
205
+ translated.appearance = { ...translated.appearance }
206
+
207
+ if (!isUndefined(translation.thankYouMessageHeader)) {
208
+ translated.appearance.thankYouMessageHeader = translation.thankYouMessageHeader
209
+ hasTranslation = true
210
+ }
211
+ if (!isUndefined(translation.thankYouMessageDescription)) {
212
+ translated.appearance.thankYouMessageDescription = translation.thankYouMessageDescription
213
+ hasTranslation = true
214
+ }
215
+ if (!isUndefined(translation.thankYouMessageCloseButtonText)) {
216
+ translated.appearance.thankYouMessageCloseButtonText = translation.thankYouMessageCloseButtonText
217
+ hasTranslation = true
218
+ }
219
+ } else if (hasThankYouTranslation(translation)) {
220
+ hasTranslation = true
221
+ }
222
+ }
223
+ }
224
+
225
+ const translatedResults = survey.questions.map((question) => mergeQuestionTranslation(question, targetLanguage))
226
+ const translatedQuestions = translatedResults.map((result) => result.question)
227
+ const anyQuestionTranslated = translatedResults.some((result) => result.hasChanges)
228
+
229
+ let questionMatchedKey: string | null = null
230
+ if (!translationKey) {
231
+ questionMatchedKey = translatedResults.find((result) => result.matchedKey)?.matchedKey || null
232
+ }
233
+
234
+ if (anyQuestionTranslated) {
235
+ translated.questions = translatedQuestions
236
+ hasTranslation = true
237
+ logger.info(`Applied question-level translations for language: ${targetLanguage}`)
238
+ }
239
+
240
+ return {
241
+ survey: translated,
242
+ matchedKey: hasTranslation ? translationKey || questionMatchedKey : null,
243
+ }
244
+ }
@@ -36,6 +36,7 @@ export const delay = (ms: number): Promise<void> => {
36
36
 
37
37
  export const createMockLogger = (): Logger => {
38
38
  return {
39
+ debug: jest.fn((...args) => console.debug(...args)),
39
40
  info: jest.fn((...args) => console.log(...args)),
40
41
  warn: jest.fn((...args) => console.warn(...args)),
41
42
  error: jest.fn((...args) => console.error(...args)),