diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt index 7473e45ee5..78d8703cff 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/BusinessEventController.kt @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.component.reporting.BusinessEventPublisher import io.tolgee.dtos.request.BusinessEventReportRequest +import io.tolgee.dtos.request.IdentifyRequest import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.security.SecurityService import io.tolgee.util.Logging @@ -17,7 +18,7 @@ import org.springframework.web.bind.annotation.RestController @RestController @CrossOrigin(origins = ["*"]) -@RequestMapping(value = ["/v2/business-events"]) +@RequestMapping(value = ["/v2/public/business-events"]) @Tag(name = "Business events reporting") class BusinessEventController( private val businessEventPublisher: BusinessEventPublisher, @@ -36,4 +37,15 @@ class BusinessEventController( Sentry.captureException(e) } } + + @PostMapping("/identify") + @Operation(summary = "Identifies user") + fun identify(@RequestBody eventData: IdentifyRequest) { + try { + businessEventPublisher.publish(eventData) + } catch (e: Throwable) { + logger.error("Error storing event", e) + Sentry.captureException(e) + } + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/BusinessEventControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/BusinessEventControllerTest.kt index a5da9ca5b6..888b751d1a 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/BusinessEventControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/BusinessEventControllerTest.kt @@ -38,7 +38,7 @@ class BusinessEventControllerTest : ProjectAuthControllerTest("/v2/projects/") { @ProjectJWTAuthTestMethod fun `it accepts header`() { performPost( - "/v2/business-events/report", + "/v2/public/business-events/report", mapOf( "eventName" to "TEST_EVENT", "organizationId" to testData.userAccountBuilder.defaultOrganizationBuilder.self.id, diff --git a/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventPublisher.kt b/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventPublisher.kt index 3938cedee2..275de57c58 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventPublisher.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventPublisher.kt @@ -6,6 +6,7 @@ import io.tolgee.activity.UtmData import io.tolgee.component.CurrentDateProvider import io.tolgee.constants.Caches import io.tolgee.dtos.request.BusinessEventReportRequest +import io.tolgee.dtos.request.IdentifyRequest import io.tolgee.security.AuthenticationFacade import io.tolgee.util.Logging import io.tolgee.util.logger @@ -33,7 +34,8 @@ class BusinessEventPublisher( projectId = request.projectId, organizationId = request.organizationId, utmData = getUtmData(), - data = request.data + data = request.data, + anonymousUserId = request.anonymousUserId ) ) } @@ -49,6 +51,17 @@ class BusinessEventPublisher( ) } + fun publish(event: IdentifyRequest) { + authenticationFacade.userAccountOrNull?.id?.let { userId -> + applicationEventPublisher.publishEvent( + OnIdentifyEvent( + userAccountId = userId, + anonymousUserId = event.anonymousUserId + ) + ) + } + } + fun publishOnceInTime( event: OnBusinessEventToCaptureEvent, onceIn: Duration, diff --git a/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventReporter.kt b/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventReporter.kt index ba67e44ba9..cf366cc65a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventReporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/reporting/BusinessEventReporter.kt @@ -1,6 +1,7 @@ package io.tolgee.component.reporting import com.posthog.java.PostHog +import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.service.organization.OrganizationService import io.tolgee.service.project.ProjectService import io.tolgee.service.security.UserAccountService @@ -34,9 +35,21 @@ class BusinessEventReporter( selfProxied.captureAsync(data) } + @EventListener + fun identify(data: OnIdentifyEvent) { + val dto = userAccountService.findDto(data.userAccountId) ?: return + postHog?.capture( + data.userAccountId.toString(), + "${'$'}identify", + mapOf( + "${'$'}anon_distinct_id" to data.anonymousUserId, + ) + getSetMapOfUserData(dto) + ) + } + private fun captureWithPostHog(data: OnBusinessEventToCaptureEvent) { - val id = data.userAccountDto?.id ?: data.instanceId - val setEntry = getSetMapForPostHog(data) + val id = data.userAccountDto?.id ?: data.instanceId ?: data.anonymousUserId + val setEntry = getIdentificationMapForPostHog(data) postHog?.capture( id.toString(), data.eventName, @@ -53,14 +66,9 @@ class BusinessEventReporter( * This method returns map with $set property if user information is present * or if instanceId is sent by self-hosted instance. */ - private fun getSetMapForPostHog(data: OnBusinessEventToCaptureEvent): Map> { + private fun getIdentificationMapForPostHog(data: OnBusinessEventToCaptureEvent): Map { val setEntry = data.userAccountDto?.let { userAccountDto -> - mapOf( - "${'$'}set" to mapOf( - "email" to userAccountDto.username, - "name" to userAccountDto.name, - ) - ) + getSetMapOfUserData(userAccountDto) } ?: data.instanceId?.let { mapOf( "${'$'}set" to mapOf( @@ -68,7 +76,24 @@ class BusinessEventReporter( ) ) } ?: emptyMap() - return setEntry + return setEntry + getAnonIdMap(data) + } + + private fun getSetMapOfUserData(userAccountDto: UserAccountDto) = mapOf( + "${'$'}set" to mapOf( + "email" to userAccountDto.username, + "name" to userAccountDto.name, + ), + ) + + fun getAnonIdMap(data: OnBusinessEventToCaptureEvent): Map { + return ( + data.anonymousUserId?.let { + mapOf( + "${'$'}anon_distinct_id" to data.anonymousUserId, + ) + } + ) ?: emptyMap() } private fun fillOtherData(data: OnBusinessEventToCaptureEvent): OnBusinessEventToCaptureEvent { diff --git a/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnBusinessEventToCaptureEvent.kt b/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnBusinessEventToCaptureEvent.kt index 8651f9ecca..35fcc249d7 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnBusinessEventToCaptureEvent.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnBusinessEventToCaptureEvent.kt @@ -15,5 +15,6 @@ data class OnBusinessEventToCaptureEvent( val utmData: Map? = null, val sdkType: String? = null, val sdkVersion: String? = null, - val data: Map? = null + val data: Map? = null, + val anonymousUserId: String? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnIdentifyEvent.kt b/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnIdentifyEvent.kt new file mode 100644 index 0000000000..e3f13ddac5 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/reporting/OnIdentifyEvent.kt @@ -0,0 +1,6 @@ +package io.tolgee.component.reporting + +data class OnIdentifyEvent( + val userAccountId: Long, + val anonymousUserId: String +) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/BusinessEventReportRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/BusinessEventReportRequest.kt index 7618e53771..066b14bb9c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/BusinessEventReportRequest.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/BusinessEventReportRequest.kt @@ -6,6 +6,8 @@ data class BusinessEventReportRequest( @field:NotBlank var eventName: String = "", + var anonymousUserId: String? = null, + var organizationId: Long? = null, var projectId: Long? = null, diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/IdentifyRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/IdentifyRequest.kt new file mode 100644 index 0000000000..0a02b1644f --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/IdentifyRequest.kt @@ -0,0 +1,8 @@ +package io.tolgee.dtos.request + +import javax.validation.constraints.NotBlank + +data class IdentifyRequest( + @NotBlank + var anonymousUserId: String = "" +) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index cad3d60541..41aaa50c6f 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -52,7 +52,7 @@ export const loginViaForm = (username = USERNAME, password = PASSWORD) => { .type(password) .should('have.value', password); cy.gcy('login-button').click(); - waitForGlobalLoading(); + return waitForGlobalLoading(); }; export const visitSignUp = () => cy.visit(HOST + '/sign_up'); @@ -77,3 +77,18 @@ export const signUpAfter = (username: string) => { enableRegistration(); deleteUserSql(username); }; + +export function checkAnonymousIdUnset() { + cy.wrap(localStorage).invoke('getItem', 'anonymousUserId').should('be.null'); +} + +export function checkAnonymousIdSet() { + cy.intercept('POST', '/v2/public/business-events/identify').as('identify'); + cy.wrap(localStorage) + .invoke('getItem', 'anonymousUserId') + .should('have.length', 36); +} + +export function checkAnonymousUserIdentified() { + cy.wait('@identify').its('response.statusCode').should('eq', 200); +} diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index 2fd9e7889c..46e61a3879 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -12,6 +12,9 @@ import { } from '../../common/apiCalls/common'; import { assertMessage, getPopover } from '../../common/shared'; import { + checkAnonymousIdSet, + checkAnonymousIdUnset, + checkAnonymousUserIdentified, loginViaForm, loginWithFakeGithub, loginWithFakeOAuth2, @@ -24,7 +27,13 @@ context('Login', () => { }); it('login', () => { + checkAnonymousIdSet(); + loginViaForm(); + + checkAnonymousIdUnset(); + checkAnonymousUserIdentified(); + cy.gcy('login-button').should('not.exist'); cy.xpath("//*[@aria-controls='user-menu']").should('be.visible'); }); @@ -42,7 +51,12 @@ context('Login', () => { }); it('login with github', () => { + checkAnonymousIdSet(); + loginWithFakeGithub(); + + checkAnonymousIdUnset(); + checkAnonymousUserIdentified(); }); it('login with oauth2', () => { loginWithFakeOAuth2(); @@ -51,9 +65,11 @@ context('Login', () => { it('logout', () => { login(); cy.reload(); + checkAnonymousIdUnset(); cy.xpath("//*[@aria-controls='user-menu']").click(); getPopover().contains('Logout').click(); cy.gcy('login-button').should('be.visible'); + checkAnonymousIdSet(); }); it('resets password', () => { diff --git a/e2e/cypress/e2e/security/signUp.cy.ts b/e2e/cypress/e2e/security/signUp.cy.ts index cdf1be0f93..34fe2c04f9 100644 --- a/e2e/cypress/e2e/security/signUp.cy.ts +++ b/e2e/cypress/e2e/security/signUp.cy.ts @@ -20,6 +20,9 @@ import { } from '../../common/apiCalls/common'; import { assertMessage } from '../../common/shared'; import { + checkAnonymousIdSet, + checkAnonymousIdUnset, + checkAnonymousUserIdentified, fillAndSubmitSignUpForm, loginWithFakeGithub, signUpAfter, @@ -81,6 +84,7 @@ context('Sign up', () => { }); it('Signs up without recaptcha', () => { + checkAnonymousIdSet(); visitSignUp(); cy.intercept('/**/sign_up', (req) => { expect(req.body.recaptchaToken).be.undefined; @@ -106,6 +110,7 @@ context('Sign up', () => { }); it('Signs up', () => { + checkAnonymousIdSet(); cy.intercept('/**/sign_up', (req) => { expect(req.body.recaptchaToken).have.length.greaterThan(10); }).as('signUp'); @@ -124,6 +129,8 @@ context('Sign up', () => { cy.visit(r.verifyEmailLink); assertMessage('Email was verified'); }); + checkAnonymousIdUnset(); + checkAnonymousUserIdentified(); }); it('Signs up without email verification', () => { diff --git a/webapp/src/component/App.tsx b/webapp/src/component/App.tsx index 0bfbb4f333..6a508b0f0c 100644 --- a/webapp/src/component/App.tsx +++ b/webapp/src/component/App.tsx @@ -121,13 +121,11 @@ const Head: FC = () => { ); }; - export class App extends React.Component { componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { errorActions.globalError.dispatch(error as GlobalError); throw error; } - render() { return ( <> diff --git a/webapp/src/component/MandatoryDataProvider.tsx b/webapp/src/component/MandatoryDataProvider.tsx index d58c20dfa1..1f464ae7b4 100644 --- a/webapp/src/component/MandatoryDataProvider.tsx +++ b/webapp/src/component/MandatoryDataProvider.tsx @@ -5,6 +5,7 @@ import * as Sentry from '@sentry/browser'; import { useGlobalLoading } from './GlobalLoading'; import { PostHog } from 'posthog-js'; import { getUtmParams } from 'tg.fixtures/utmCookie'; +import { useIdentify } from 'tg.hooks/useIdentify'; const POSTHOG_INITIALIZED_WINDOW_PROPERTY = 'postHogInitialized'; export const MandatoryDataProvider = (props: any) => { @@ -13,6 +14,8 @@ export const MandatoryDataProvider = (props: any) => { const isLoading = useGlobalContext((v) => v.isLoading); const isFetching = useGlobalContext((v) => v.isFetching); + useIdentify(userData?.id); + useEffect(() => { if (config?.clientSentryDsn) { Sentry.init({ diff --git a/webapp/src/component/security/Login/LoginView.tsx b/webapp/src/component/security/Login/LoginView.tsx index 3037c2a9e2..d46600e938 100644 --- a/webapp/src/component/security/Login/LoginView.tsx +++ b/webapp/src/component/security/Login/LoginView.tsx @@ -12,11 +12,11 @@ import { AppState } from 'tg.store/index'; import { LoginCredentialsForm } from './LoginCredentialsForm'; import { LoginTotpForm } from './LoginTotpForm'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; -import { SPLIT_CONTENT_BREAK_POINT } from '../SplitContent'; +import { SPLIT_CONTENT_BREAK_POINT, SplitContent } from '../SplitContent'; import { TranslatedError } from 'tg.translationTools/TranslatedError'; import { CompactView } from 'tg.component/layout/CompactView'; -import { SplitContent } from '../SplitContent'; import { LoginMoreInfo } from './LoginMoreInfo'; +import { useReportOnce } from 'tg.hooks/useReportEvent'; interface LoginProps {} @@ -34,6 +34,8 @@ export const LoginView: FunctionComponent = (props) => { const isSmall = useMediaQuery(SPLIT_CONTENT_BREAK_POINT); + useReportOnce('LOGIN_PAGE_OPENED'); + const authLoading = useSelector( (state: AppState) => state.global.authLoading ); diff --git a/webapp/src/component/security/SignUp/SignUpView.tsx b/webapp/src/component/security/SignUp/SignUpView.tsx index 3c10c5ab58..032d769a10 100644 --- a/webapp/src/component/security/SignUp/SignUpView.tsx +++ b/webapp/src/component/security/SignUp/SignUpView.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useEffect } from 'react'; +import { FunctionComponent } from 'react'; import { T, useTranslate } from '@tolgee/react'; import { useSelector } from 'react-redux'; import { Redirect } from 'react-router-dom'; @@ -21,7 +21,7 @@ import { GlobalActions } from 'tg.store/global/GlobalActions'; import { useRecaptcha } from './useRecaptcha'; import { useApiMutation } from 'tg.service/http/useQueryApi'; import { SPLIT_CONTENT_BREAK_POINT, SplitContent } from '../SplitContent'; -import { useReportEvent } from 'tg.views/organizations/useReportEvent'; +import { useReportOnce } from 'tg.hooks/useReportEvent'; export type SignUpType = { name: string; @@ -63,11 +63,7 @@ export const SignUpView: FunctionComponent = () => { }, }); - const reportEvent = useReportEvent(); - - useEffect(() => { - reportEvent('SIGN_UP_PAGE_OPENED'); - }, []); + useReportOnce('SIGN_UP_PAGE_OPENED'); const onSubmit = async (data: SignUpType) => { const request = { diff --git a/webapp/src/hooks/useIdentify.ts b/webapp/src/hooks/useIdentify.ts new file mode 100644 index 0000000000..c67695cdf2 --- /dev/null +++ b/webapp/src/hooks/useIdentify.ts @@ -0,0 +1,29 @@ +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { AnonymousIdService } from 'tg.service/AnonymousIdService'; +import { useEffect } from 'react'; + +export const useIdentify = (userId?: number) => { + const mutation = useApiMutation({ + url: '/v2/public/business-events/identify', + method: 'post', + options: { + onSuccess() { + AnonymousIdService.dispose(); + }, + }, + }); + + useEffect(() => { + const anonymousId = AnonymousIdService.get(); + const enabled = !!anonymousId && !!userId; + if (enabled) { + mutation.mutate({ + content: { + 'application/json': { + anonymousUserId: anonymousId!, + }, + }, + }); + } + }, [userId]); +}; diff --git a/webapp/src/hooks/useReportEvent.ts b/webapp/src/hooks/useReportEvent.ts new file mode 100644 index 0000000000..ccade866f9 --- /dev/null +++ b/webapp/src/hooks/useReportEvent.ts @@ -0,0 +1,55 @@ +import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { useApiMutation } from 'tg.service/http/useQueryApi'; +import { useProjectContextOptional } from './useProject'; +import { AnonymousIdService } from 'tg.service/AnonymousIdService'; +import { useEffect } from 'react'; +import { container } from 'tsyringe'; +import { TokenService } from 'tg.service/TokenService'; + +export const useReportEvent = () => { + const reportMutation = useApiMutation({ + url: '/v2/public/business-events/report', + method: 'post', + }); + const isAuthenticated = container.resolve(TokenService).getToken() !== null; + const storedPreferredOrganization = usePreferredOrganization(); + + const preferredOrganization = isAuthenticated + ? storedPreferredOrganization + : undefined; + + const storedProject = useProjectContextOptional()?.project; + const project = isAuthenticated ? storedProject : undefined; + + const organizationId = + project?.organizationOwner?.id || + preferredOrganization?.preferredOrganization?.id; + + return ( + eventName: string, + data: Record | undefined = undefined + ) => { + reportMutation.mutate({ + content: { + 'application/json': { + eventName, + data, + projectId: project?.id, + organizationId: organizationId, + anonymousUserId: AnonymousIdService.get() || undefined, + }, + }, + }); + }; +}; + +export const useReportOnce = ( + eventName: string, + data: Record | undefined = undefined +) => { + const reportEvent = useReportEvent(); + + useEffect(() => { + reportEvent(eventName, data); + }, []); +}; diff --git a/webapp/src/service/AnonymousIdService.ts b/webapp/src/service/AnonymousIdService.ts new file mode 100644 index 0000000000..920d476fca --- /dev/null +++ b/webapp/src/service/AnonymousIdService.ts @@ -0,0 +1,22 @@ +export const ANONYMOUS_ID_LOCAL_STORAGE_KEY = 'anonymousUserId'; + +export const AnonymousIdService = { + get() { + return localStorage.getItem(ANONYMOUS_ID_LOCAL_STORAGE_KEY); + }, + init() { + if (!this.get()) { + this.reset(); + } + }, + reset() { + return localStorage.setItem( + ANONYMOUS_ID_LOCAL_STORAGE_KEY, + // @ts-ignore + crypto.randomUUID() + ); + }, + dispose() { + return localStorage.removeItem(ANONYMOUS_ID_LOCAL_STORAGE_KEY); + }, +}; diff --git a/webapp/src/service/TokenService.ts b/webapp/src/service/TokenService.ts index 1ec77a1556..07583c7a75 100644 --- a/webapp/src/service/TokenService.ts +++ b/webapp/src/service/TokenService.ts @@ -1,4 +1,5 @@ import { singleton } from 'tsyringe'; +import { AnonymousIdService } from './AnonymousIdService'; export const JWT_LOCAL_STORAGE_KEY = 'jwtToken'; export const ADMIN_JWT_LOCAL_STORAGE_KEY = 'adminJwtToken'; @@ -6,10 +7,15 @@ export const ADMIN_JWT_LOCAL_STORAGE_KEY = 'adminJwtToken'; @singleton() export class TokenService { getToken() { - return localStorage.getItem(JWT_LOCAL_STORAGE_KEY); + const token = localStorage.getItem(JWT_LOCAL_STORAGE_KEY); + if (!token) { + AnonymousIdService.init(); + } + return token; } disposeToken() { + AnonymousIdService.reset(); return localStorage.removeItem(JWT_LOCAL_STORAGE_KEY); } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 5eb382566b..3ecc9079b5 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -214,6 +214,9 @@ export interface paths { "/v2/slug/generate-organization": { post: operations["generateOrganizationSlug"]; }; + "/v2/public/telemetry/report": { + post: operations["report"]; + }; "/v2/public/licensing/subscription": { post: operations["getMySubscription"]; }; @@ -232,6 +235,12 @@ export interface paths { "/v2/public/licensing/prepare-set-key": { post: operations["prepareSetLicenseKey"]; }; + "/v2/public/business-events/report": { + post: operations["report_1"]; + }; + "/v2/public/business-events/identify": { + post: operations["identify"]; + }; "/v2/projects": { get: operations["getAll"]; post: operations["createProject"]; @@ -301,9 +310,6 @@ export interface paths { "/v2/ee-license/prepare-set-license-key": { post: operations["prepareSetLicenseKey_1"]; }; - "/v2/business-events/report": { - post: operations["report"]; - }; "/v2/api-keys": { get: operations["allByUser"]; post: operations["create_10"]; @@ -1156,15 +1162,15 @@ export interface components { token: string; /** Format: int64 */ id: number; - description: string; - /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -1299,15 +1305,15 @@ export interface components { id: number; userFullName?: string; projectName: string; - description: string; - username?: string; - /** Format: int64 */ - projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + projectId: number; + /** Format: int64 */ lastUsedAt?: number; - scopes: string[]; + username?: string; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -1319,6 +1325,19 @@ export interface components { name: string; oldSlug?: string; }; + TelemetryReportRequest: { + instanceId: string; + /** Format: int64 */ + projectsCount: number; + /** Format: int64 */ + translationsCount: number; + /** Format: int64 */ + languagesCount: number; + /** Format: int64 */ + distinctLanguagesCount: number; + /** Format: int64 */ + usersCount: number; + }; GetMySubscriptionDto: { licenseKey: string; instanceId: string; @@ -1433,6 +1452,18 @@ export interface components { credits?: components["schemas"]["SumUsageItemModel"]; total: number; }; + BusinessEventReportRequest: { + eventName: string; + anonymousUserId?: string; + /** Format: int64 */ + organizationId?: number; + /** Format: int64 */ + projectId?: number; + data?: { [key: string]: { [key: string]: unknown } }; + }; + IdentifyRequest: { + anonymousUserId: string; + }; CreateProjectDTO: { name: string; languages: components["schemas"]["LanguageDto"][]; @@ -1707,14 +1738,6 @@ export interface components { createdAt: string; location?: string; }; - BusinessEventReportRequest: { - eventName: string; - /** Format: int64 */ - organizationId?: number; - /** Format: int64 */ - projectId?: number; - data?: { [key: string]: { [key: string]: unknown } }; - }; CreateApiKeyDto: { /** Format: int64 */ projectId: number; @@ -1841,17 +1864,17 @@ export interface components { /** Format: int64 */ id: number; basePermissions: components["schemas"]["PermissionModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; + avatar?: components["schemas"]["Avatar"]; + /** @example btforg */ + slug: string; /** * @description The role of currently authorized user. * * Can be null when user has direct access to one of the projects owned by the organization. */ currentUserRole?: "MEMBER" | "OWNER"; - avatar?: components["schemas"]["Avatar"]; - /** @example btforg */ - slug: string; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -1883,8 +1906,8 @@ export interface components { postHogHost?: string; }; DocItem: { - displayName?: string; name: string; + displayName?: string; description?: string; }; PagedModelProjectModel: { @@ -1953,8 +1976,8 @@ export interface components { /** Format: int64 */ id: number; baseTranslation?: string; - namespace?: string; translation?: string; + namespace?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; @@ -1962,8 +1985,8 @@ export interface components { /** Format: int64 */ id: number; baseTranslation?: string; - namespace?: string; translation?: string; + namespace?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -2396,15 +2419,15 @@ export interface components { user: components["schemas"]["SimpleUserAccountModel"]; /** Format: int64 */ id: number; - description: string; - /** Format: int64 */ - createdAt: number; - /** Format: int64 */ - updatedAt: number; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ lastUsedAt?: number; + /** Format: int64 */ + createdAt: number; + /** Format: int64 */ + updatedAt: number; + description: string; }; OrganizationRequestParamsDto: { filterCurrentUserOwner: boolean; @@ -2525,15 +2548,15 @@ export interface components { id: number; userFullName?: string; projectName: string; - description: string; - username?: string; - /** Format: int64 */ - projectId: number; + scopes: string[]; /** Format: int64 */ expiresAt?: number; /** Format: int64 */ + projectId: number; + /** Format: int64 */ lastUsedAt?: number; - scopes: string[]; + username?: string; + description: string; }; PagedModelUserAccountModel: { _embedded?: { @@ -4837,6 +4860,29 @@ export interface operations { }; }; }; + report: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelemetryReportRequest"]; + }; + }; + }; getMySubscription: { responses: { /** OK */ @@ -4987,6 +5033,52 @@ export interface operations { }; }; }; + report_1: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BusinessEventReportRequest"]; + }; + }; + }; + identify: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "*/*": string; + }; + }; + /** Not Found */ + 404: { + content: { + "*/*": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentifyRequest"]; + }; + }; + }; getAll: { parameters: { query: { @@ -5935,29 +6027,6 @@ export interface operations { }; }; }; - report: { - responses: { - /** OK */ - 200: unknown; - /** Bad Request */ - 400: { - content: { - "*/*": string; - }; - }; - /** Not Found */ - 404: { - content: { - "*/*": string; - }; - }; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BusinessEventReportRequest"]; - }; - }; - }; allByUser: { parameters: { query: { diff --git a/webapp/src/views/organizations/billing/Subscriptions/cloud/CloudSubscriptions.tsx b/webapp/src/views/organizations/billing/Subscriptions/cloud/CloudSubscriptions.tsx index a049e94e24..e25b86c769 100644 --- a/webapp/src/views/organizations/billing/Subscriptions/cloud/CloudSubscriptions.tsx +++ b/webapp/src/views/organizations/billing/Subscriptions/cloud/CloudSubscriptions.tsx @@ -10,7 +10,7 @@ import { BillingPeriodType } from './Plans/PeriodSwitch'; import { useOrganizationCreditBalance } from '../../useOrganizationCreditBalance'; import { useEffect, useState } from 'react'; import { planIsPeriodDependant } from './Plans/PlanPrice'; -import { useReportEvent } from '../../../useReportEvent'; +import { useReportEvent } from 'tg.hooks/useReportEvent'; const StyledShopping = styled('div')` display: grid; diff --git a/webapp/src/views/organizations/billing/Subscriptions/selfHostedEe/SelfHostedEeSubscriptions.tsx b/webapp/src/views/organizations/billing/Subscriptions/selfHostedEe/SelfHostedEeSubscriptions.tsx index 8298b0893b..a30018fe58 100644 --- a/webapp/src/views/organizations/billing/Subscriptions/selfHostedEe/SelfHostedEeSubscriptions.tsx +++ b/webapp/src/views/organizations/billing/Subscriptions/selfHostedEe/SelfHostedEeSubscriptions.tsx @@ -14,7 +14,7 @@ import { SelfHostedEeActiveSubscription } from './SelfHostedEeActiveSubscription import { BillingPeriodType } from '../cloud/Plans/PeriodSwitch'; import { useGlobalLoading } from 'tg.component/GlobalLoading'; import { components } from 'tg.service/billingApiSchema.generated'; -import { useReportEvent } from '../../../useReportEvent'; +import { useReportEvent } from 'tg.hooks/useReportEvent'; type SelfHostedEeSubscriptionModel = components['schemas']['SelfHostedEeSubscriptionModel']; diff --git a/webapp/src/views/organizations/members/OrganizationMembersView.tsx b/webapp/src/views/organizations/members/OrganizationMembersView.tsx index 3f14cadd8e..9c8663a49d 100644 --- a/webapp/src/views/organizations/members/OrganizationMembersView.tsx +++ b/webapp/src/views/organizations/members/OrganizationMembersView.tsx @@ -1,5 +1,5 @@ import { FunctionComponent, useEffect, useState } from 'react'; -import { Box, Typography, Button } from '@mui/material'; +import { Box, Button, Typography } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; @@ -12,7 +12,7 @@ import { SimpleList } from 'tg.component/common/list/SimpleList'; import { InviteDialog } from './InviteDialog'; import { InvitationItem } from './InvitationItem'; import { LINKS, PARAMS } from 'tg.constants/links'; -import { useReportEvent } from '../useReportEvent'; +import { useReportEvent } from 'tg.hooks/useReportEvent'; export const OrganizationMembersView: FunctionComponent = () => { const organization = useOrganization(); diff --git a/webapp/src/views/organizations/useReportEvent.ts b/webapp/src/views/organizations/useReportEvent.ts deleted file mode 100644 index e70384e6a8..0000000000 --- a/webapp/src/views/organizations/useReportEvent.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { usePreferredOrganization } from 'tg.globalContext/helpers'; -import { useApiMutation } from 'tg.service/http/useQueryApi'; -import { useProjectContextOptional } from 'tg.hooks/useProject'; - -export const useReportEvent = () => { - const reportMutation = useApiMutation({ - url: '/v2/business-events/report', - method: 'post', - }); - - const project = useProjectContextOptional()?.project; - const preferredOrganization = usePreferredOrganization(); - const organizationId = - project?.organizationOwner?.id || - preferredOrganization?.preferredOrganization?.id; - - return ( - eventName: string, - data: Record | undefined = undefined - ) => { - reportMutation.mutate({ - content: { - 'application/json': { - eventName, - data, - projectId: project?.id, - organizationId: organizationId, - }, - }, - }); - }; -}; diff --git a/webapp/src/views/projects/integrate/IntegrateView.tsx b/webapp/src/views/projects/integrate/IntegrateView.tsx index 4f40d12c5c..48ba26173b 100644 --- a/webapp/src/views/projects/integrate/IntegrateView.tsx +++ b/webapp/src/views/projects/integrate/IntegrateView.tsx @@ -10,7 +10,7 @@ import { MdxProvider } from 'tg.component/MdxProvider'; import { useIntegrateState } from 'tg.views/projects/integrate/useIntegrateState'; import { useProject } from 'tg.hooks/useProject'; import { BaseProjectView } from '../BaseProjectView'; -import { useReportEvent } from '../../organizations/useReportEvent'; +import { useReportEvent } from 'tg.hooks/useReportEvent'; export const API_KEY_PLACEHOLDER = '{{{apiKey}}}'; export const IntegrateView: FunctionComponent = () => { diff --git a/webapp/src/views/projects/members/ProjectMembersView.tsx b/webapp/src/views/projects/members/ProjectMembersView.tsx index d2a701858e..234b3ac3fa 100644 --- a/webapp/src/views/projects/members/ProjectMembersView.tsx +++ b/webapp/src/views/projects/members/ProjectMembersView.tsx @@ -14,7 +14,7 @@ import { InviteDialog } from './component/InviteDialog'; import { InvitationItem } from './component/InvitationItem'; import { BaseProjectView } from '../BaseProjectView'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; -import { useReportEvent } from '../../organizations/useReportEvent'; +import { useReportEvent } from 'tg.hooks/useReportEvent'; export const ProjectMembersView: FunctionComponent = () => { const project = useProject();