diff --git a/packages/analytics-service/src/bog-analytics.service.ts b/packages/analytics-service/src/bog-analytics.service.ts index e6474d76..c5a4b117 100644 --- a/packages/analytics-service/src/bog-analytics.service.ts +++ b/packages/analytics-service/src/bog-analytics.service.ts @@ -1,5 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { AnalyticsLogger as BogAnalyticsLogger } from 'bog-analytics'; +import { + AnalyticsViewer, + AnalyticsLogger as BogAnalyticsLogger, +} from 'bog-analytics'; @Injectable() export class BogAnalyticsService extends BogAnalyticsLogger {} + +@Injectable() +export class AnalyticsViewerService extends AnalyticsViewer {} diff --git a/packages/analytics-service/src/modules/analytics/analytics.controller.ts b/packages/analytics-service/src/modules/analytics/analytics.controller.ts index bc6d996f..4298b883 100644 --- a/packages/analytics-service/src/modules/analytics/analytics.controller.ts +++ b/packages/analytics-service/src/modules/analytics/analytics.controller.ts @@ -32,4 +32,64 @@ export class AnalyticsController ): Promise { return await this.analyticsService.logCustomEvent(request); } + + async getCustomEventTypes( + request: AnalyticsProto.CustomEventTypeRequest, + ): Promise { + return await this.analyticsService.getCustomEventTypes(request); + } + + async getCustomGraphTypesById( + request: AnalyticsProto.CustomGraphTypeRequest, + ): Promise { + return await this.analyticsService.getCustomGraphTypesById(request); + } + + async getClickEventsPaginated( + request: AnalyticsProto.GetClickEventsRequest, + ): Promise { + return await this.analyticsService.getClickEventsPaginated(request); + } + + async getAllClickEvents( + request: AnalyticsProto.GetAllClickEventsRequest, + ): Promise { + return await this.analyticsService.getAllClickEvents(request); + } + + async getVisitEventsPaginated( + request: AnalyticsProto.GetVisitEventsRequest, + ): Promise { + return await this.analyticsService.getVisitEventsPaginated(request); + } + + async getAllVisitEvents( + request: AnalyticsProto.GetAllVisitEventsRequest, + ): Promise { + return await this.analyticsService.getAllVisitEvents(request); + } + + async getInputEventsPaginated( + request: AnalyticsProto.GetInputEventsRequest, + ): Promise { + return await this.analyticsService.getInputEventsPaginated(request); + } + + async getAllInputEvents( + request: AnalyticsProto.GetAllInputEventsRequest, + ): Promise { + return await this.analyticsService.getAllInputEvents(request); + } + + async getCustomEventsPaginated( + request: AnalyticsProto.GetCustomEventsRequest, + ): Promise { + return await this.analyticsService.getCustomEventsPaginated(request); + } + + async getAllCustomEvents( + request: AnalyticsProto.GetAllCustomEventsRequest, + ): Promise { + return await this.analyticsService.getAllCustomEvents(request); + } } diff --git a/packages/analytics-service/src/modules/analytics/analytics.module.ts b/packages/analytics-service/src/modules/analytics/analytics.module.ts index cf82a5a3..bd2a2196 100644 --- a/packages/analytics-service/src/modules/analytics/analytics.module.ts +++ b/packages/analytics-service/src/modules/analytics/analytics.module.ts @@ -1,46 +1,27 @@ import { Module } from '@nestjs/common'; import { ClientsModule, Transport } from '@nestjs/microservices'; -import { AnalyticsProto, AnalyticsProtoFile } from 'juno-proto'; +import { AnalyticsConfigProto, AnalyticsConfigProtoFile } from 'juno-proto'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; import { AnalyticsConfigModule } from '../analytics_config/analytics_config.module'; -const { - ANALYTICS_SERVICE_NAME, - JUNO_ANALYTICS_SERVICE_ANALYTICS_PACKAGE_NAME, -} = AnalyticsProto; - @Module({ imports: [ ClientsModule.register([ { - name: ANALYTICS_SERVICE_NAME, + name: AnalyticsConfigProto.ANALYTICS_CONFIG_DB_SERVICE_NAME, transport: Transport.GRPC, options: { - package: JUNO_ANALYTICS_SERVICE_ANALYTICS_PACKAGE_NAME, - protoPath: AnalyticsProtoFile, + url: process.env.DB_SERVICE_ADDR, + package: + AnalyticsConfigProto.JUNO_ANALYTICS_SERVICE_ANALYTICS_CONFIG_PACKAGE_NAME, + protoPath: AnalyticsConfigProtoFile, }, }, ]), AnalyticsConfigModule, ], controllers: [AnalyticsController], - providers: [ - AnalyticsService, - { - provide: 'BOG_ANALYTICS', - useFactory: async () => { - // bog-analytics is absolutely cursed and outdated, have to do some shenanigans - // to get around ESM import - const loadModule = eval('(specifier) => import(specifier)'); - const { AnalyticsLogger, EventEnvironment } = - await loadModule('bog-analytics'); - - return new AnalyticsLogger({ - environment: EventEnvironment.DEVELOPMENT, - }); - }, - }, - ], + providers: [AnalyticsService], }) export class AnalyticsModule {} diff --git a/packages/analytics-service/src/modules/analytics/analytics.service.ts b/packages/analytics-service/src/modules/analytics/analytics.service.ts index 0e0e425d..f6b37864 100644 --- a/packages/analytics-service/src/modules/analytics/analytics.service.ts +++ b/packages/analytics-service/src/modules/analytics/analytics.service.ts @@ -1,37 +1,106 @@ import { status } from '@grpc/grpc-js'; -import { Inject, Injectable } from '@nestjs/common'; -import { RpcException } from '@nestjs/microservices'; -import { AnalyticsProto } from 'juno-proto'; -import { AnalyticsConfigService } from '../analytics_config/analytics_config.service'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ClientGrpc, RpcException } from '@nestjs/microservices'; +import { AnalyticsConfigProto, AnalyticsProto } from 'juno-proto'; +import { lastValueFrom } from 'rxjs'; +import { ANALYTICS_CONFIG_DB_SERVICE_NAME } from 'juno-proto/dist/gen/analytics_config'; @Injectable() -export class AnalyticsService { +export class AnalyticsService implements OnModuleInit { + private analyticsService: AnalyticsConfigProto.AnalyticsConfigDbServiceClient; + + private AnalyticsLogger: any; + private AnalyticsViewer: any; + private EventEnvironment: any; + constructor( - @Inject('BOG_ANALYTICS') private readonly bogAnalytics: any, - private readonly analyticsConfigService: AnalyticsConfigService, + @Inject(ANALYTICS_CONFIG_DB_SERVICE_NAME) + private analyticsClient: ClientGrpc, ) {} - async authenticateAnalytics(projectId: number, environment: string) { - try { - const key = await this.analyticsConfigService.getAnalyticsKey( - projectId, - environment, + async onModuleInit() { + this.analyticsService = + this.analyticsClient.getService( + ANALYTICS_CONFIG_DB_SERVICE_NAME, ); - this.bogAnalytics.authenticate(key); + + // bog-analytics is absolutely cursed and outdated, have to do some shenanigans + // to get around ESM import + const loadModule = eval('(specifier) => import(specifier)'); + const bogAnalytics = await loadModule('bog-analytics'); + + this.AnalyticsLogger = bogAnalytics.AnalyticsLogger; + this.AnalyticsViewer = bogAnalytics.AnalyticsViewer; + this.EventEnvironment = bogAnalytics.EventEnvironment; + } + + // bog-analytics currently doesn't allow stateless requests with passing in a key, + // so we have to compromise by re-instantiating the object each time + async getAnalyticsViewer( + analyticsEnvironment: any, + configId: number, + junoEnvironment: string, + ) { + const config = await lastValueFrom( + this.analyticsService.readAnalyticsConfig({ + id: configId, + environment: junoEnvironment, + }), + ); + + const viewer = new this.AnalyticsViewer({ + apiBaseUrl: process.env.BOG_ANALYTICS_BASE_URL, + environment: analyticsEnvironment, + }); + + try { + viewer.authenticate(config.serverAnalyticsKey); + } catch (e) { + throw new RpcException({ + code: status.UNAUTHENTICATED, + message: 'Invalid API key', + }); + } + + return viewer; + } + + async getAnalyticsLogger( + analyticsEnvironment: any, + configId: number, + junoEnvironment: string, + ) { + const config = await lastValueFrom( + this.analyticsService.readAnalyticsConfig({ + id: configId, + environment: junoEnvironment, + }), + ); + + const viewer = new this.AnalyticsLogger({ + apiBaseUrl: process.env.BOG_ANALYTICS_BASE_URL, + environment: analyticsEnvironment, + }); + + try { + viewer.authenticate(config.clientAnalyticsKey); } catch (e) { throw new RpcException({ code: status.UNAUTHENTICATED, - message: 'Invalid API key or analytics config not found', + message: 'Invalid API key', }); } + + return viewer; } async logClickEvent( event: AnalyticsProto.ClickEventRequest, ): Promise { - await this.authenticateAnalytics( - Number(event.projectId), - event.environment, + const logger = await this.getAnalyticsLogger( + this.EventEnvironment.DEVELOPMENT, + event.configId, + event.configEnvironment, ); if ( @@ -47,7 +116,7 @@ export class AnalyticsService { }); } - const response = await this.bogAnalytics.logClickEvent(event); + const response = await logger.logClickEvent(event); return { id: response._id, category: response.category, @@ -72,8 +141,10 @@ export class AnalyticsService { async logInputEvent( event: AnalyticsProto.InputEventRequest, ): Promise { - await this.authenticateAnalytics( - Number(event.projectId), + // TODO: we need to allow bog-analytics specific environments + const logger = await this.getAnalyticsLogger( + this.EventEnvironment.DEVELOPMENT, + event.configId, event.environment, ); @@ -93,7 +164,7 @@ export class AnalyticsService { }); } - const response = await this.bogAnalytics.logInputEvent(event); + const response = await logger.logInputEvent(event); return { id: response._id, category: response.category, @@ -119,8 +190,10 @@ export class AnalyticsService { async logVisitEvent( event: AnalyticsProto.VisitEventRequest, ): Promise { - await this.authenticateAnalytics( - Number(event.projectId), + // TODO: we need to allow environments + const logger = await this.getAnalyticsLogger( + this.EventEnvironment.DEVELOPMENT, + event.configId, event.environment, ); @@ -137,7 +210,7 @@ export class AnalyticsService { }); } - const response = await this.bogAnalytics.logVisitEvent(event); + const response = await logger.logVisitEvent(event); return { id: response._id, category: response.category, @@ -162,8 +235,10 @@ export class AnalyticsService { async logCustomEvent( event: AnalyticsProto.CustomEventRequest, ): Promise { - await this.authenticateAnalytics( - Number(event.projectId), + // TODO: we need to allow environments + const logger = await this.getAnalyticsLogger( + this.EventEnvironment.DEVELOPMENT, + event.configId, event.environment, ); @@ -183,7 +258,7 @@ export class AnalyticsService { } const { category, subcategory, properties } = event; - const response = await this.bogAnalytics.logCustomEvent( + const response = await logger.logCustomEvent( category, subcategory, properties, @@ -206,4 +281,558 @@ export class AnalyticsService { ), }; } + + async getCustomEventTypes( + request: AnalyticsProto.CustomEventTypeRequest, + ): Promise { + // TODO: environments + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid CustomEventTypeRequest: projectName is required', + }); + } + + const response = await viewer.getCustomEventTypes(request.projectName); + + if (!response || response.length === 0) { + return { + id: '', + category: '', + subcategory: '', + properties: [], + projectId: '', + }; + } + + // TODO: This should not return only the first type + // Return the first custom event type for now + const eventType = response[0]; + return { + id: eventType._id || '', + category: eventType.category, + subcategory: eventType.subcategory, + properties: eventType.properties, + projectId: eventType.projectId, + }; + } + + async getCustomGraphTypesById( + request: AnalyticsProto.CustomGraphTypeRequest, + ): Promise { + // TODO: environments + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid CustomGraphTypeRequest: projectName is required', + }); + } + + if (!request.eventTypeId || request.eventTypeId.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid CustomGraphTypeRequest: eventTypeId is required', + }); + } + + const response = await viewer.getCustomGraphTypesById( + request.projectName, + request.eventTypeId, + ); + + if (!response) { + return { graphs: [] }; + } + + return { + graphs: response.map((graph) => ({ + id: graph._id, + eventTypeId: graph.eventTypeId, + projectId: graph.projectId, + graphTitle: graph.graphTitle, + xProperty: graph.xProperty, + yProperty: graph.yProperty, + graphType: graph.graphType, + caption: graph.caption || '', + })), + }; + } + + async getClickEventsPaginated( + request: AnalyticsProto.GetClickEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.environment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetClickEventsRequest: projectName is required', + }); + } + + const queryParams = { + projectName: request.projectName, + afterId: request.afterId || undefined, + environment: request.environment as any, + limit: request.limit || undefined, + afterTime: request.afterTime || undefined, + }; + + const response = await viewer.getClickEventsPaginated(queryParams); + + if (!response) { + return { events: [], afterId: '' }; + } + + return { + events: response.events.map((event) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + objectId: event.eventProperties.objectId, + userId: event.eventProperties.userId, + }, + })), + afterId: response.afterId, + }; + } + + async getAllClickEvents( + request: AnalyticsProto.GetAllClickEventsRequest, + ): Promise { + console.log('requesting new viewer '); + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllClickEventsRequest: projectName is required', + }); + } + + const afterTime = request.afterTime + ? new Date(request.afterTime) + : undefined; + const limit = request.limit || undefined; + + const response = await viewer.getAllClickEvents( + request.projectName, + afterTime, + limit, + ); + + console.log(response); + + if (!response) { + console.log('null,'); + return { events: [] }; + } + + return { + events: response.map((event: any) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + objectId: event.eventProperties.objectId, + userId: event.eventProperties.userId, + }, + })), + }; + } + + async getVisitEventsPaginated( + request: AnalyticsProto.GetVisitEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetVisitEventsRequest: projectName is required', + }); + } + + const queryParams = { + projectName: request.projectName, + afterId: request.afterId || undefined, + environment: request.environment as any, + limit: request.limit || undefined, + afterTime: request.afterTime || undefined, + }; + + const response = await viewer.getVisitEventsPaginated(queryParams); + + if (!response) { + return { events: [], afterId: '' }; + } + + return { + events: response.events.map((event: any) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + pageUrl: event.eventProperties.pageUrl, + userId: event.eventProperties.userId, + }, + })), + afterId: response.afterId, + }; + } + + async getAllVisitEvents( + request: AnalyticsProto.GetAllVisitEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllVisitEventsRequest: projectName is required', + }); + } + + const afterTime = request.afterTime + ? new Date(request.afterTime) + : undefined; + const limit = request.limit || undefined; + + const response = await viewer.getAllVisitEvents( + request.projectName, + afterTime, + limit, + ); + + if (!response) { + return { events: [] }; + } + + return { + events: response.map((event: any) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + pageUrl: event.eventProperties.pageUrl, + userId: event.eventProperties.userId, + }, + })), + }; + } + + async getInputEventsPaginated( + request: AnalyticsProto.GetInputEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetInputEventsRequest: projectName is required', + }); + } + + const queryParams = { + projectName: request.projectName, + afterId: request.afterId || undefined, + environment: request.environment as any, + limit: request.limit || undefined, + afterTime: request.afterTime || undefined, + }; + + const response = await viewer.getInputEventsPaginated(queryParams); + + if (!response) { + return { events: [], afterId: '' }; + } + + return { + events: response.events.map((event: any) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + objectId: event.eventProperties.objectId, + userId: event.eventProperties.userId, + textValue: event.eventProperties.textValue, + }, + })), + afterId: response.afterId, + }; + } + + async getAllInputEvents( + request: AnalyticsProto.GetAllInputEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllInputEventsRequest: projectName is required', + }); + } + + const afterTime = request.afterTime + ? new Date(request.afterTime) + : undefined; + const limit = request.limit || undefined; + + const response = await viewer.getAllInputEvents( + request.projectName, + afterTime, + limit, + ); + + if (!response) { + return { events: [] }; + } + + return { + events: response.map((event: any) => ({ + id: event._id, + category: event.category, + subcategory: event.subcategory, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + eventProperties: { + objectId: event.eventProperties.objectId, + userId: event.eventProperties.userId, + textValue: event.eventProperties.textValue, + }, + })), + }; + } + + async getCustomEventsPaginated( + request: AnalyticsProto.GetCustomEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetCustomEventsRequest: projectName is required', + }); + } + + if (!request.category || request.category.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetCustomEventsRequest: category is required', + }); + } + + if (!request.subcategory || request.subcategory.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetCustomEventsRequest: subcategory is required', + }); + } + + const queryParams = { + projectName: request.projectName, + afterId: request.afterId || undefined, + environment: request.environment as any, + limit: request.limit || undefined, + afterTime: request.afterTime || undefined, + category: request.category, + subcategory: request.subcategory, + }; + + const response = await viewer.getCustomEventsPaginated(queryParams); + + if (!response) { + return { events: [], afterId: '' }; + } + + return { + events: response.events.map((event: any) => ({ + id: event._id, + eventTypeId: event.eventTypeId, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + properties: Object.fromEntries( + Object.entries(event.properties).map(([k, v]) => [k, String(v)]), + ), + })), + afterId: response.afterId, + }; + } + + async getAllCustomEvents( + request: AnalyticsProto.GetAllCustomEventsRequest, + ): Promise { + const viewer = await this.getAnalyticsViewer( + this.EventEnvironment.DEVELOPMENT, + request.configId, + request.configEnvironment, + ); + + if (!request.projectName || request.projectName.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllCustomEventsRequest: projectName is required', + }); + } + + if (!request.category || request.category.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllCustomEventsRequest: category is required', + }); + } + + if (!request.subcategory || request.subcategory.length === 0) { + throw new RpcException({ + code: status.INVALID_ARGUMENT, + message: 'Invalid GetAllCustomEventsRequest: subcategory is required', + }); + } + + const afterTime = request.afterTime + ? new Date(request.afterTime) + : undefined; + const limit = request.limit || undefined; + + const response = await viewer.getAllCustomEvents( + request.projectName, + request.category, + request.subcategory, + afterTime, + limit, + ); + + if (!response) { + return { events: [] }; + } + + return { + events: response.map((event: any) => ({ + id: event._id, + eventTypeId: event.eventTypeId, + projectId: event.projectId, + environment: event.environment, + createdAt: + event.createdAt instanceof Date + ? event.createdAt.toISOString() + : event.createdAt, + updatedAt: + event.updatedAt instanceof Date + ? event.updatedAt.toISOString() + : event.updatedAt, + properties: Object.fromEntries( + Object.entries(event.properties).map(([k, v]) => [k, String(v)]), + ), + })), + }; + } } diff --git a/packages/analytics-service/src/modules/analytics_config/analytics_config.service.ts b/packages/analytics-service/src/modules/analytics_config/analytics_config.service.ts index fc520879..10d02660 100644 --- a/packages/analytics-service/src/modules/analytics_config/analytics_config.service.ts +++ b/packages/analytics-service/src/modules/analytics_config/analytics_config.service.ts @@ -21,16 +21,18 @@ export class AnalyticsConfigService implements OnModuleInit { ); } - async getAnalyticsKey( + // TODO: This method isn't used by anything; should either be used in replacement of other code + // or removed entirely + async getAnalyticsKeys( projectId: number, environment: string, - ): Promise { + ): Promise<[string, string]> { const config = await lastValueFrom( this.analyticsConfigDbService.readAnalyticsConfig({ id: projectId, environment, }), ); - return config.analyticsKey; + return [config.serverAnalyticsKey, config.clientAnalyticsKey]; } } diff --git a/packages/analytics-service/test/analytics.e2e-spec.ts b/packages/analytics-service/test/analytics.e2e-spec.ts index ff88a6ca..fd1fe777 100644 --- a/packages/analytics-service/test/analytics.e2e-spec.ts +++ b/packages/analytics-service/test/analytics.e2e-spec.ts @@ -4,6 +4,7 @@ import { AppModule } from '../src/app.module'; import * as ProtoLoader from '@grpc/proto-loader'; import * as GRPC from '@grpc/grpc-js'; import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { AnalyticsViewerService } from 'src/bog-analytics.service'; import { ResetProtoFile, AnalyticsProto, @@ -20,11 +21,11 @@ const mockBogAnalyticsService = { authenticate: jest.fn(), logClickEvent: jest.fn().mockImplementation((event) => Promise.resolve({ - _id: 'id', - category: 'category', - subcategory: 'subcategory', - projectId: 'project-id', - environment: 'test', + _id: 'mock-click-event-id', + category: 'Interaction', + subcategory: 'Click', + projectId: 'test-project-id', + environment: 'development', createdAt: new Date(), updatedAt: new Date(), eventProperties: { objectId: event.objectId, userId: event.userId }, @@ -32,11 +33,11 @@ const mockBogAnalyticsService = { ), logInputEvent: jest.fn().mockImplementation((event) => Promise.resolve({ - _id: 'id', - category: 'category', - subcategory: 'subcategory', - projectId: 'project-id', - environment: 'test', + _id: 'mock-input-event-id', + category: 'Interaction', + subcategory: 'Input', + projectId: 'test-project-id', + environment: 'development', createdAt: new Date(), updatedAt: new Date(), eventProperties: { @@ -48,11 +49,11 @@ const mockBogAnalyticsService = { ), logVisitEvent: jest.fn().mockImplementation((event) => Promise.resolve({ - _id: 'id', - category: 'category', - subcategory: 'subcategory', - projectId: 'project-id', - environment: 'test', + _id: 'mock-visit-event-id', + category: 'Activity', + subcategory: 'Visit', + projectId: 'test-project-id', + environment: 'development', createdAt: new Date(), updatedAt: new Date(), eventProperties: { pageUrl: event.pageUrl, userId: event.userId }, @@ -62,10 +63,10 @@ const mockBogAnalyticsService = { .fn() .mockImplementation((category, subcategory, properties) => Promise.resolve({ - _id: 'id', - eventTypeId: 'event-type-id', - projectId: 'project-id', - environment: 'test', + _id: 'mock-custom-event-id', + eventTypeId: 'custom-event-type-id', + projectId: 'test-project-id', + environment: 'development', createdAt: new Date(), updatedAt: new Date(), properties: properties, @@ -73,6 +74,238 @@ const mockBogAnalyticsService = { ), }; +const mockAnalyticsViewerService = { + authenticate: jest.fn(), + + // Custom Event Types + getCustomEventTypes: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'custom-event-type-1', + category: 'user-action', + subcategory: 'form-submission', + properties: ['formType', 'formId', 'userId'], + projectId: 'test-project-id', + environment: 'development', + }, + { + _id: 'custom-event-type-2', + category: 'user-action', + subcategory: 'button-click', + properties: ['buttonId', 'buttonText', 'userId'], + projectId: 'test-project-id', + environment: 'development', + }, + ]), + ), + + // Custom Graph Types + getCustomGraphTypesbyId: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'graph-1', + eventTypeId: 'custom-event-type-1', + projectId: 'test-project-id', + graphTitle: 'Form Submissions Over Time', + xProperty: 'createdAt', + yProperty: 'count', + graphType: 'line', + caption: 'Track form submission trends', + }, + { + _id: 'graph-2', + eventTypeId: 'custom-event-type-1', + projectId: 'test-project-id', + graphTitle: 'Form Types Distribution', + xProperty: 'formType', + yProperty: 'count', + graphType: 'pie', + caption: 'Distribution of form types', + }, + ]), + ), + + // Click Events + getClickEventsPaginated: jest.fn().mockImplementation(() => + Promise.resolve({ + events: [ + { + _id: 'click-1', + category: 'Interaction', + subcategory: 'Click', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { objectId: 'button-1', userId: 'user-1' }, + }, + { + _id: 'click-2', + category: 'Interaction', + subcategory: 'Click', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { objectId: 'button-2', userId: 'user-2' }, + }, + ], + afterId: 'click-2', + }), + ), + + getAllClickEvents: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'click-1', + category: 'Interaction', + subcategory: 'Click', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { objectId: 'button-1', userId: 'user-1' }, + }, + { + _id: 'click-2', + category: 'Interaction', + subcategory: 'Click', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { objectId: 'button-2', userId: 'user-2' }, + }, + ]), + ), + + // Visit Events + getVisitEventsPaginated: jest.fn().mockImplementation(() => + Promise.resolve({ + events: [ + { + _id: 'visit-1', + category: 'Activity', + subcategory: 'Visit', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { pageUrl: '/home', userId: 'user-1' }, + }, + { + _id: 'visit-2', + category: 'Activity', + subcategory: 'Visit', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { pageUrl: '/about', userId: 'user-2' }, + }, + ], + afterId: 'visit-2', + }), + ), + + getAllVisitEvents: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'visit-1', + category: 'Activity', + subcategory: 'Visit', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { pageUrl: '/home', userId: 'user-1' }, + }, + ]), + ), + + // Input Events + getInputEventsPaginated: jest.fn().mockImplementation(() => + Promise.resolve({ + events: [ + { + _id: 'input-1', + category: 'Interaction', + subcategory: 'Input', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { + objectId: 'search-field', + userId: 'user-1', + textValue: 'analytics query', + }, + }, + ], + afterId: 'input-1', + }), + ), + + getAllInputEvents: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'input-1', + category: 'Interaction', + subcategory: 'Input', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + eventProperties: { + objectId: 'search-field', + userId: 'user-1', + textValue: 'analytics query', + }, + }, + ]), + ), + + // Custom Events + getCustomEventsPaginated: jest.fn().mockImplementation(() => + Promise.resolve({ + events: [ + { + _id: 'custom-1', + eventTypeId: 'custom-event-type-1', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + properties: { + formType: 'contact', + formId: 'contact-form-1', + userId: 'user-1', + }, + }, + ], + afterId: 'custom-1', + }), + ), + + getAllCustomEvents: jest.fn().mockImplementation(() => + Promise.resolve([ + { + _id: 'custom-1', + eventTypeId: 'custom-event-type-1', + projectId: 'test-project-id', + environment: 'development', + createdAt: new Date(), + updatedAt: new Date(), + properties: { + formType: 'contact', + formId: 'contact-form-1', + userId: 'user-1', + }, + }, + ]), + ), +}; + jest.setTimeout(10000); async function initApp() { @@ -81,6 +314,8 @@ async function initApp() { }) .overrideProvider('BOG_ANALYTICS') .useValue(mockBogAnalyticsService) + .overrideProvider(AnalyticsViewerService) + .useValue(mockAnalyticsViewerService) .compile(); const app = moduleFixture.createNestMicroservice({ @@ -131,7 +366,8 @@ beforeAll(async () => { { projectId: 0, environment: 'test', - analyticsKey: 'mock-api-key-123', + serverAnalyticsKey: 'mock-api-key-123', + clientAnalyticsKey: 'mock-api-key-123', }, (err, response) => { if (err) reject(err); @@ -361,3 +597,569 @@ describe('Analytics Service Authenticate Domain Tests', () => { } }); }); + +describe('Analytics Service Viewer Tests', () => { + let analyticsClient: any; + + beforeEach(async () => { + const proto = ProtoLoader.loadSync([AnalyticsProtoFile]) as any; + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + analyticsClient = + new protoGRPC.juno.analytics_service.analytics.AnalyticsService( + process.env.ANALYTICS_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + }); + + describe('Custom Event Types', () => { + it('should get custom event types with valid api key', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + } as AnalyticsProto.CustomEventTypeRequest; + + const response: AnalyticsProto.CustomEventTypeResponse = + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventTypes(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.id).toBe('custom-event-type-1'); + expect(response.category).toBe('user-action'); + expect(response.subcategory).toBe('form-submission'); + expect(response.properties).toContain('formType'); + expect(response.properties).toContain('userId'); + expect(response.projectId).toBe('test-project-id'); + }); + + it('should fail to get custom event types with invalid api key', async () => { + const request = { + apiKey: 'invalid-api-key', + projectName: 'test-project', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventTypes(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + + it('should fail to get custom event types with empty project name', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: '', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventTypes(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + }); + + describe('Custom Graph Types', () => { + it('should get custom graph types by id with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + eventTypeId: 'custom-event-type-1', + } as AnalyticsProto.CustomGraphTypeRequest; + + const response: AnalyticsProto.CustomGraphTypeResponse = + await new Promise((resolve, reject) => { + analyticsClient.getCustomGraphTypesById(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.graphs).toHaveLength(2); + expect(response.graphs[0].id).toBe('graph-1'); + expect(response.graphs[0].graphTitle).toBe('Form Submissions Over Time'); + expect(response.graphs[0].graphType).toBe('line'); + expect(response.graphs[1].graphType).toBe('pie'); + }); + + it('should fail to get custom graph types with invalid api key', async () => { + const request = { + apiKey: 'invalid-api-key', + projectName: 'test-project', + eventTypeId: 'custom-event-type-1', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getCustomGraphTypesById(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + }); + + describe('Click Events Retrieval', () => { + it('should get paginated click events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } as AnalyticsProto.GetClickEventsRequest; + + const response: AnalyticsProto.GetClickEventsResponse = await new Promise( + (resolve, reject) => { + analyticsClient.getClickEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }, + ); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(2); + expect(response.events[0].id).toBe('click-1'); + expect(response.events[0].eventProperties.objectId).toBe('button-1'); + expect(response.events[0].eventProperties.userId).toBe('user-1'); + expect(response.afterId).toBe('click-2'); + }); + + it('should get all click events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterTime: '', + limit: 0, + } as AnalyticsProto.GetAllClickEventsRequest; + + const response: AnalyticsProto.GetAllClickEventsResponse = + await new Promise((resolve, reject) => { + analyticsClient.getAllClickEvents(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(2); + expect(response.events[0].eventProperties.objectId).toBe('button-1'); + }); + + it('should fail to get click events with invalid api key', async () => { + const request = { + apiKey: 'invalid-api-key', + projectName: 'test-project', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getClickEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + }); + + describe('Visit Events Retrieval', () => { + it('should get paginated visit events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } as AnalyticsProto.GetVisitEventsRequest; + + const response: AnalyticsProto.GetVisitEventsResponse = await new Promise( + (resolve, reject) => { + analyticsClient.getVisitEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }, + ); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(2); + expect(response.events[0].eventProperties.pageUrl).toBe('/home'); + expect(response.afterId).toBe('visit-2'); + }); + + it('should get all visit events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterTime: '', + limit: 0, + } as AnalyticsProto.GetAllVisitEventsRequest; + + const response: AnalyticsProto.GetAllVisitEventsResponse = + await new Promise((resolve, reject) => { + analyticsClient.getAllVisitEvents(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(1); + expect(response.events[0].eventProperties.pageUrl).toBe('/home'); + }); + }); + + describe('Input Events Retrieval', () => { + it('should get paginated input events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } as AnalyticsProto.GetInputEventsRequest; + + const response: AnalyticsProto.GetInputEventsResponse = await new Promise( + (resolve, reject) => { + analyticsClient.getInputEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }, + ); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(1); + expect(response.events[0].eventProperties.textValue).toBe( + 'analytics query', + ); + expect(response.afterId).toBe('input-1'); + }); + + it('should get all input events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + afterTime: '', + limit: 0, + } as AnalyticsProto.GetAllInputEventsRequest; + + const response: AnalyticsProto.GetAllInputEventsResponse = + await new Promise((resolve, reject) => { + analyticsClient.getAllInputEvents(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(1); + expect(response.events[0].eventProperties.textValue).toBe( + 'analytics query', + ); + }); + }); + + describe('Custom Events Retrieval', () => { + it('should get paginated custom events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + category: 'user-action', + subcategory: 'form-submission', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } as AnalyticsProto.GetCustomEventsRequest; + + const response: AnalyticsProto.GetCustomEventsResponse = + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(1); + expect(response.events[0].properties.formType).toBe('contact'); + expect(response.events[0].properties.formId).toBe('contact-form-1'); + expect(response.afterId).toBe('custom-1'); + }); + + it('should get all custom events with valid parameters', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + category: 'user-action', + subcategory: 'form-submission', + afterTime: '', + limit: 0, + } as AnalyticsProto.GetAllCustomEventsRequest; + + const response: AnalyticsProto.GetAllCustomEventsResponse = + await new Promise((resolve, reject) => { + analyticsClient.getAllCustomEvents(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + + expect(response).toBeDefined(); + expect(response.events).toHaveLength(1); + expect(response.events[0].properties.formType).toBe('contact'); + }); + + it('should fail to get custom events without required category', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + category: '', + subcategory: 'form-submission', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + + it('should fail to get custom events without required subcategory', async () => { + const request = { + apiKey: 'mock-api-key-123', + projectName: 'test-project', + category: 'user-action', + subcategory: '', + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient.getCustomEventsPaginated(request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail('Expected error was not thrown'); + } catch (err) { + expect(err).toBeDefined(); + } + }); + }); + + describe('Authentication and Validation Tests', () => { + it('should handle authentication errors consistently across all methods', async () => { + const invalidApiKey = 'invalid-key'; + const methods = [ + 'getCustomEventTypes', + 'getCustomGraphTypesById', + 'getClickEventsPaginated', + 'getAllClickEvents', + 'getVisitEventsPaginated', + 'getAllVisitEvents', + 'getInputEventsPaginated', + 'getAllInputEvents', + 'getCustomEventsPaginated', + 'getAllCustomEvents', + ]; + + for (const method of methods) { + const request = { + apiKey: invalidApiKey, + projectName: 'test-project', + ...(method.includes('Custom') && !method.includes('Type') + ? { + category: 'user-action', + subcategory: 'form-submission', + } + : {}), + ...(method.includes('GraphTypes') + ? { + eventTypeId: 'custom-event-type-1', + } + : {}), + ...(method.includes('Paginated') + ? { + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } + : {}), + ...(method.includes('All') && !method.includes('Custom') + ? { + afterTime: '', + limit: 0, + } + : {}), + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient[method](request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail(`Expected error was not thrown for method: ${method}`); + } catch (err) { + expect(err).toBeDefined(); + } + } + }); + + it('should validate required project name across all methods', async () => { + const methods = [ + 'getCustomEventTypes', + 'getCustomGraphTypesById', + 'getClickEventsPaginated', + 'getAllClickEvents', + 'getVisitEventsPaginated', + 'getAllVisitEvents', + 'getInputEventsPaginated', + 'getAllInputEvents', + 'getCustomEventsPaginated', + 'getAllCustomEvents', + ]; + + for (const method of methods) { + const request = { + apiKey: 'mock-api-key-123', + projectName: '', // Empty project name + ...(method.includes('Custom') && !method.includes('Type') + ? { + category: 'user-action', + subcategory: 'form-submission', + } + : {}), + ...(method.includes('GraphTypes') + ? { + eventTypeId: 'custom-event-type-1', + } + : {}), + ...(method.includes('Paginated') + ? { + afterId: '', + environment: 'development', + limit: 10, + afterTime: '', + } + : {}), + }; + + try { + await new Promise((resolve, reject) => { + analyticsClient[method](request, (err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }); + }); + fail(`Expected error was not thrown for method: ${method}`); + } catch (err) { + expect(err).toBeDefined(); + } + } + }); + }); +}); diff --git a/packages/analytics-service/test/analytics_config.e2e-spec.ts b/packages/analytics-service/test/analytics_config.e2e-spec.ts index 2b9d45cf..f2ec9e19 100644 --- a/packages/analytics-service/test/analytics_config.e2e-spec.ts +++ b/packages/analytics-service/test/analytics_config.e2e-spec.ts @@ -12,7 +12,7 @@ describe('AnalyticsConfigService (e2e)', () => { { provide: AnalyticsConfigService, useValue: { - getAnalyticsKey: jest.fn().mockImplementation(async () => { + getAnalyticsKeys: jest.fn().mockImplementation(async () => { return 'test-key'; }), }, @@ -41,14 +41,14 @@ describe('AnalyticsConfigService (e2e)', () => { expect(service).toBeDefined(); }); - describe('getAnalyticsKey', () => { + describe('getAnalyticsKeys', () => { it('should retrieve analytics key successfully', async () => { const projectId = 1; const environment = 'test'; - const result = await service.getAnalyticsKey(projectId, environment); + const result = await service.getAnalyticsKeys(projectId, environment); expect(result).toBe('test-key'); - expect(service.getAnalyticsKey).toHaveBeenCalledWith( + expect(service.getAnalyticsKeys).toHaveBeenCalledWith( projectId, environment, ); diff --git a/packages/api-gateway/src/app.module.ts b/packages/api-gateway/src/app.module.ts index ee69dbf2..d475cce1 100644 --- a/packages/api-gateway/src/app.module.ts +++ b/packages/api-gateway/src/app.module.ts @@ -9,6 +9,7 @@ import { FileDownloadModule } from './modules/file_download/file_download.module import { FileUploadModule } from './modules/file_upload/file_upload.module'; import { FileBucketModule } from './modules/file_bucket/file_bucket.module'; import { FileConfigModule } from './modules/file_config/file_config.module'; +import { AnalyticsModule } from './modules/analytics/analytics.module'; import { AnalyticsConfigModule } from './modules/analytics_config/analytics_config.module'; @Module({ @@ -23,6 +24,7 @@ import { AnalyticsConfigModule } from './modules/analytics_config/analytics_conf FileUploadModule, FileBucketModule, FileConfigModule, + AnalyticsModule, AnalyticsConfigModule, ], }) diff --git a/packages/api-gateway/src/main.ts b/packages/api-gateway/src/main.ts index 53bde433..0d02c376 100644 --- a/packages/api-gateway/src/main.ts +++ b/packages/api-gateway/src/main.ts @@ -20,6 +20,9 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ transform: true, + transformOptions: { + enableImplicitConversion: true, + }, }), ); diff --git a/packages/api-gateway/src/models/analytics.dto.ts b/packages/api-gateway/src/models/analytics.dto.ts new file mode 100644 index 00000000..4db07d2d --- /dev/null +++ b/packages/api-gateway/src/models/analytics.dto.ts @@ -0,0 +1,586 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsString, + IsOptional, + IsNumber, + IsObject, +} from 'class-validator'; +import { AnalyticsProto } from 'juno-proto'; + +// Request DTOs for logging events +export class LogClickEventRequest { + @ApiProperty({ + type: 'string', + description: 'The ID of the object that was clicked', + example: 'button_submit_form', + }) + @IsNotEmpty() + @IsString() + objectId: string; + + @ApiProperty({ + type: 'string', + description: 'The ID of the user who performed the click', + example: 'user_12345', + }) + @IsNotEmpty() + @IsString() + userId: string; +} + +export class LogVisitEventRequest { + @ApiProperty({ + type: 'string', + description: 'The URL of the page that was visited', + example: 'https://example.com/dashboard', + }) + @IsNotEmpty() + @IsString() + pageUrl: string; + + @ApiProperty({ + type: 'string', + description: 'The ID of the user who visited the page', + example: 'user_12345', + }) + @IsNotEmpty() + @IsString() + userId: string; +} + +export class LogInputEventRequest { + @ApiProperty({ + type: 'string', + description: 'The ID of the input object', + example: 'input_search_field', + }) + @IsNotEmpty() + @IsString() + objectId: string; + + @ApiProperty({ + type: 'string', + description: 'The ID of the user who interacted with the input', + example: 'user_12345', + }) + @IsNotEmpty() + @IsString() + userId: string; + + @ApiProperty({ + type: 'string', + description: 'The text value entered in the input', + example: 'search query', + }) + @IsNotEmpty() + @IsString() + textValue: string; +} + +export class LogCustomEventRequest { + @ApiProperty({ + type: 'string', + description: 'The category of the custom event', + example: 'user_action', + }) + @IsNotEmpty() + @IsString() + category: string; + + @ApiProperty({ + type: 'string', + description: 'The subcategory of the custom event', + example: 'form_submission', + }) + @IsNotEmpty() + @IsString() + subcategory: string; + + @ApiProperty({ + type: 'object', + description: 'Custom properties for the event', + example: { formType: 'contact', source: 'homepage' }, + }) + @IsObject() + properties: { [key: string]: string }; +} + +// Query DTOs for retrieving events +export class GetCustomEventTypesQuery { + @ApiProperty({ + type: 'string', + description: 'The name of the project', + example: 'my-project', + }) + @IsNotEmpty() + @IsString() + projectName: string; +} + +export class GetCustomGraphTypesQuery { + @ApiProperty({ + type: 'string', + description: 'The name of the project', + example: 'my-project', + }) + @IsNotEmpty() + @IsString() + projectName: string; + + @ApiProperty({ + type: 'string', + description: 'The ID of the event type', + example: 'event_type_123', + }) + @IsNotEmpty() + @IsString() + eventTypeId: string; +} + +export class GetEventsQuery { + @ApiProperty({ + type: 'string', + description: 'The name of the project', + example: 'my-project', + }) + @IsNotEmpty() + @IsString() + projectName: string; + + @ApiPropertyOptional({ + type: 'string', + description: 'Pagination cursor - ID to start after', + example: 'event_12345', + }) + @IsOptional() + @IsString() + afterId?: string; + + @ApiPropertyOptional({ + type: 'string', + description: 'Environment filter', + example: 'production', + }) + @IsOptional() + @IsString() + environment?: string; + + @ApiPropertyOptional({ + type: 'number', + description: 'Maximum number of events to return', + example: 50, + }) + @IsOptional() + @IsNumber() + limit?: number; + + @ApiPropertyOptional({ + type: 'string', + description: 'ISO timestamp to filter events after', + example: '2023-01-01T00:00:00Z', + }) + @IsOptional() + @IsString() + afterTime?: string; +} + +export class GetCustomEventsQuery extends GetEventsQuery { + @ApiProperty({ + type: 'string', + description: 'The category of custom events to retrieve', + example: 'user_action', + }) + @IsNotEmpty() + @IsString() + category: string; + + @ApiProperty({ + type: 'string', + description: 'The subcategory of custom events to retrieve', + example: 'form_submission', + }) + @IsNotEmpty() + @IsString() + subcategory: string; +} + +export class GetAllEventsQuery { + @ApiProperty({ + type: 'string', + description: 'The name of the project', + example: 'my-project', + }) + @IsNotEmpty() + @IsString() + projectName: string; + + @ApiPropertyOptional({ + type: 'string', + description: 'ISO timestamp to filter events after', + example: '2023-01-01T00:00:00Z', + }) + @IsOptional() + @IsString() + afterTime?: string; + + @ApiPropertyOptional({ + type: 'number', + description: 'Maximum number of events to return', + example: 100, + }) + @IsOptional() + @IsNumber() + limit?: number; +} + +export class GetAllCustomEventsQuery extends GetAllEventsQuery { + @ApiProperty({ + type: 'string', + description: 'The category of custom events to retrieve', + example: 'user_action', + }) + @IsNotEmpty() + @IsString() + category: string; + + @ApiProperty({ + type: 'string', + description: 'The subcategory of custom events to retrieve', + example: 'form_submission', + }) + @IsNotEmpty() + @IsString() + subcategory: string; +} + +// Response DTOs +export class ClickEventProperties { + @ApiProperty({ type: 'string', description: 'Object ID' }) + objectId: string; + + @ApiProperty({ type: 'string', description: 'User ID' }) + userId: string; +} + +export class VisitEventProperties { + @ApiProperty({ type: 'string', description: 'Page URL' }) + pageUrl: string; + + @ApiProperty({ type: 'string', description: 'User ID' }) + userId: string; +} + +export class InputEventProperties { + @ApiProperty({ type: 'string', description: 'Object ID' }) + objectId: string; + + @ApiProperty({ type: 'string', description: 'User ID' }) + userId: string; + + @ApiProperty({ type: 'string', description: 'Text value' }) + textValue: string; +} + +export class ClickEventResponse { + @ApiProperty({ type: 'string', description: 'Event ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Event category' }) + category: string; + + @ApiProperty({ type: 'string', description: 'Event subcategory' }) + subcategory: string; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ type: 'string', description: 'Environment' }) + environment: string; + + @ApiProperty({ type: 'string', description: 'Created timestamp' }) + createdAt: string; + + @ApiProperty({ type: 'string', description: 'Updated timestamp' }) + updatedAt: string; + + @ApiProperty({ type: ClickEventProperties, description: 'Event properties' }) + eventProperties: ClickEventProperties; + + constructor(response: AnalyticsProto.ClickEventResponse) { + this.id = response.id; + this.category = response.category; + this.subcategory = response.subcategory; + this.projectId = response.projectId; + this.environment = response.environment; + this.createdAt = response.createdAt; + this.updatedAt = response.updatedAt; + this.eventProperties = response.eventProperties!; + } +} + +export class VisitEventResponse { + @ApiProperty({ type: 'string', description: 'Event ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Event category' }) + category: string; + + @ApiProperty({ type: 'string', description: 'Event subcategory' }) + subcategory: string; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ type: 'string', description: 'Environment' }) + environment: string; + + @ApiProperty({ type: 'string', description: 'Created timestamp' }) + createdAt: string; + + @ApiProperty({ type: 'string', description: 'Updated timestamp' }) + updatedAt: string; + + @ApiProperty({ type: VisitEventProperties, description: 'Event properties' }) + eventProperties: VisitEventProperties; + + constructor(response: AnalyticsProto.VisitEventResponse) { + this.id = response.id; + this.category = response.category; + this.subcategory = response.subcategory; + this.projectId = response.projectId; + this.environment = response.environment; + this.createdAt = response.createdAt; + this.updatedAt = response.updatedAt; + this.eventProperties = response.eventProperties!; + } +} + +export class InputEventResponse { + @ApiProperty({ type: 'string', description: 'Event ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Event category' }) + category: string; + + @ApiProperty({ type: 'string', description: 'Event subcategory' }) + subcategory: string; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ type: 'string', description: 'Environment' }) + environment: string; + + @ApiProperty({ type: 'string', description: 'Created timestamp' }) + createdAt: string; + + @ApiProperty({ type: 'string', description: 'Updated timestamp' }) + updatedAt: string; + + @ApiProperty({ type: InputEventProperties, description: 'Event properties' }) + eventProperties: InputEventProperties; + + constructor(response: AnalyticsProto.InputEventResponse) { + this.id = response.id; + this.category = response.category; + this.subcategory = response.subcategory; + this.projectId = response.projectId; + this.environment = response.environment; + this.createdAt = response.createdAt; + this.updatedAt = response.updatedAt; + this.eventProperties = response.eventProperties!; + } +} + +export class CustomEventResponse { + @ApiProperty({ type: 'string', description: 'Event ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Event type ID' }) + eventTypeId: string; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ type: 'string', description: 'Environment' }) + environment: string; + + @ApiProperty({ type: 'string', description: 'Created timestamp' }) + createdAt: string; + + @ApiProperty({ type: 'string', description: 'Updated timestamp' }) + updatedAt: string; + + @ApiProperty({ type: 'object', description: 'Custom properties' }) + properties: { [key: string]: string }; + + constructor(response: AnalyticsProto.CustomEventResponse) { + this.id = response.id; + this.eventTypeId = response.eventTypeId; + this.projectId = response.projectId; + this.environment = response.environment; + this.createdAt = response.createdAt; + this.updatedAt = response.updatedAt; + this.properties = response.properties; + } +} + +export class CustomEventTypeResponse { + @ApiProperty({ type: 'string', description: 'Event type ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Category' }) + category: string; + + @ApiProperty({ type: 'string', description: 'Subcategory' }) + subcategory: string; + + @ApiProperty({ type: [String], description: 'Properties' }) + properties: string[]; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + constructor(response: AnalyticsProto.CustomEventTypeResponse) { + this.id = response.id; + this.category = response.category; + this.subcategory = response.subcategory; + this.properties = response.properties; + this.projectId = response.projectId; + } +} + +export class CustomGraphType { + @ApiProperty({ type: 'string', description: 'Graph ID' }) + id: string; + + @ApiProperty({ type: 'string', description: 'Event type ID' }) + eventTypeId: string; + + @ApiProperty({ type: 'string', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ type: 'string', description: 'Graph title' }) + graphTitle: string; + + @ApiProperty({ type: 'string', description: 'X-axis property' }) + xProperty: string; + + @ApiProperty({ type: 'string', description: 'Y-axis property' }) + yProperty: string; + + @ApiProperty({ type: 'string', description: 'Graph type' }) + graphType: string; + + @ApiProperty({ type: 'string', description: 'Caption' }) + caption: string; +} + +export class CustomGraphTypeResponse { + @ApiProperty({ type: [CustomGraphType], description: 'Graph types' }) + graphs: CustomGraphType[]; + + constructor(response: AnalyticsProto.CustomGraphTypeResponse) { + this.graphs = response.graphs; + } +} + +export class GetClickEventsResponse { + @ApiProperty({ type: [ClickEventResponse], description: 'Click events' }) + events: ClickEventResponse[]; + + @ApiProperty({ type: 'string', description: 'Pagination cursor' }) + afterId: string; + + constructor(response: AnalyticsProto.GetClickEventsResponse) { + this.events = response.events.map((event) => new ClickEventResponse(event)); + this.afterId = response.afterId; + } +} + +export class GetVisitEventsResponse { + @ApiProperty({ type: [VisitEventResponse], description: 'Visit events' }) + events: VisitEventResponse[]; + + @ApiProperty({ type: 'string', description: 'Pagination cursor' }) + afterId: string; + + constructor(response: AnalyticsProto.GetVisitEventsResponse) { + this.events = response.events.map((event) => new VisitEventResponse(event)); + this.afterId = response.afterId; + } +} + +export class GetInputEventsResponse { + @ApiProperty({ type: [InputEventResponse], description: 'Input events' }) + events: InputEventResponse[]; + + @ApiProperty({ type: 'string', description: 'Pagination cursor' }) + afterId: string; + + constructor(response: AnalyticsProto.GetInputEventsResponse) { + this.events = response.events.map((event) => new InputEventResponse(event)); + this.afterId = response.afterId; + } +} + +export class GetCustomEventsResponse { + @ApiProperty({ type: [CustomEventResponse], description: 'Custom events' }) + events: CustomEventResponse[]; + + @ApiProperty({ type: 'string', description: 'Pagination cursor' }) + afterId: string; + + constructor(response: AnalyticsProto.GetCustomEventsResponse) { + this.events = response.events.map( + (event) => new CustomEventResponse(event), + ); + this.afterId = response.afterId; + } +} + +export class GetAllClickEventsResponse { + @ApiProperty({ type: [ClickEventResponse], description: 'All click events' }) + events: ClickEventResponse[]; + + constructor(response: AnalyticsProto.GetAllClickEventsResponse) { + if (!response || !response.events) { + return; + } + + this.events = response.events.map((event) => new ClickEventResponse(event)); + } +} + +export class GetAllVisitEventsResponse { + @ApiProperty({ type: [VisitEventResponse], description: 'All visit events' }) + events: VisitEventResponse[]; + + constructor(response: AnalyticsProto.GetAllVisitEventsResponse) { + this.events = response.events.map((event) => new VisitEventResponse(event)); + } +} + +export class GetAllInputEventsResponse { + @ApiProperty({ type: [InputEventResponse], description: 'All input events' }) + events: InputEventResponse[]; + + constructor(response: AnalyticsProto.GetAllInputEventsResponse) { + this.events = response.events.map((event) => new InputEventResponse(event)); + } +} + +export class GetAllCustomEventsResponse { + @ApiProperty({ + type: [CustomEventResponse], + description: 'All custom events', + }) + events: CustomEventResponse[]; + + constructor(response: AnalyticsProto.GetAllCustomEventsResponse) { + this.events = response.events.map( + (event) => new CustomEventResponse(event), + ); + } +} diff --git a/packages/api-gateway/src/models/analytics_config.dto.ts b/packages/api-gateway/src/models/analytics_config.dto.ts index cc87cf07..101ac1a9 100644 --- a/packages/api-gateway/src/models/analytics_config.dto.ts +++ b/packages/api-gateway/src/models/analytics_config.dto.ts @@ -3,24 +3,33 @@ import { Transform } from 'class-transformer'; export class CreateAnalyticsConfigModel { @IsString() - analyticsKey: string; + serverAnalyticsKey: string; + + @IsString() + clientAnalyticsKey: string; } export class UpdateAnalyticsConfigModel { @IsOptional() @IsString() - analyticsKey?: string; + serverAnalyticsKey?: string; + + @IsOptional() + @IsString() + clientAnalyticsKey?: string; } export class AnalyticsConfigResponse { @Transform(({ value }) => Number(value)) id: number; environment: string; - analyticsKey: string; + serverAnalyticsKey: string; + clientAnalyticsKey: string; constructor(config: any) { this.id = config.id; this.environment = config.environment; - this.analyticsKey = config.analyticsKey; + this.serverAnalyticsKey = config.serverAnalyticsKey; + this.clientAnalyticsKey = config.clientAnalyticsKey; } } diff --git a/packages/api-gateway/src/modules/analytics/analytics.controller.ts b/packages/api-gateway/src/modules/analytics/analytics.controller.ts new file mode 100644 index 00000000..2cccf046 --- /dev/null +++ b/packages/api-gateway/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,627 @@ +import { + Body, + Controller, + Get, + Query, + Inject, + OnModuleInit, + Post, +} from '@nestjs/common'; +import { ClientGrpc } from '@nestjs/microservices'; +import { validateOrReject } from 'class-validator'; +import { AnalyticsProto, AuthCommonProto } from 'juno-proto'; +import { lastValueFrom } from 'rxjs'; + +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { ApiKey } from 'src/decorators/api_key.decorator'; +import { + LogClickEventRequest, + LogVisitEventRequest, + LogInputEventRequest, + LogCustomEventRequest, + GetCustomEventTypesQuery, + GetCustomGraphTypesQuery, + GetEventsQuery, + GetCustomEventsQuery, + GetAllEventsQuery, + GetAllCustomEventsQuery, + ClickEventResponse, + VisitEventResponse, + InputEventResponse, + CustomEventResponse, + CustomEventTypeResponse, + CustomGraphTypeResponse, + GetClickEventsResponse, + GetVisitEventsResponse, + GetInputEventsResponse, + GetCustomEventsResponse, + GetAllClickEventsResponse, + GetAllVisitEventsResponse, + GetAllInputEventsResponse, + GetAllCustomEventsResponse, +} from 'src/models/analytics.dto'; + +const { ANALYTICS_SERVICE_NAME } = AnalyticsProto; + +@ApiBearerAuth('API_Key') +@ApiTags('analytics') +@Controller('analytics') +export class AnalyticsController implements OnModuleInit { + private analyticsService: AnalyticsProto.AnalyticsServiceClient; + + constructor( + @Inject(ANALYTICS_SERVICE_NAME) private analyticsClient: ClientGrpc, + ) {} + + onModuleInit() { + this.analyticsService = + this.analyticsClient.getService( + ANALYTICS_SERVICE_NAME, + ); + } + + @Post('events/click') + @ApiOperation({ summary: 'Log a click event' }) + @ApiCreatedResponse({ + description: 'Click event logged successfully', + type: ClickEventResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async logClickEvent( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Body() request: LogClickEventRequest, + ): Promise { + await validateOrReject(request); + + const response = await lastValueFrom( + this.analyticsService.logClickEvent({ + objectId: request.objectId, + userId: request.userId, + configId: apiKey.project.id, + environment: apiKey.environment, + configEnvironment: apiKey.environment, + }), + ); + + return new ClickEventResponse(response); + } + + @Post('events/visit') + @ApiOperation({ summary: 'Log a visit event' }) + @ApiCreatedResponse({ + description: 'Visit event logged successfully', + type: VisitEventResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async logVisitEvent( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Body() request: LogVisitEventRequest, + ): Promise { + await validateOrReject(request); + + const response = await lastValueFrom( + this.analyticsService.logVisitEvent({ + pageUrl: request.pageUrl, + userId: request.userId, + configId: apiKey.project.id, + environment: apiKey.environment, + configEnvironment: apiKey.environment, + }), + ); + + return new VisitEventResponse(response); + } + + @Post('events/input') + @ApiOperation({ summary: 'Log an input event' }) + @ApiCreatedResponse({ + description: 'Input event logged successfully', + type: InputEventResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async logInputEvent( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Body() request: LogInputEventRequest, + ): Promise { + await validateOrReject(request); + + const response = await lastValueFrom( + this.analyticsService.logInputEvent({ + objectId: request.objectId, + userId: request.userId, + textValue: request.textValue, + configId: apiKey.project.id, + environment: apiKey.environment, + configEnvironment: apiKey.environment, + }), + ); + + return new InputEventResponse(response); + } + + @Post('events/custom') + @ApiOperation({ summary: 'Log a custom event' }) + @ApiCreatedResponse({ + description: 'Custom event logged successfully', + type: CustomEventResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + async logCustomEvent( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Body() request: LogCustomEventRequest, + ): Promise { + await validateOrReject(request); + + const response = await lastValueFrom( + this.analyticsService.logCustomEvent({ + category: request.category, + subcategory: request.subcategory, + properties: request.properties, + environment: apiKey.environment, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new CustomEventResponse(response); + } + + @Get('custom-event-types') + @ApiOperation({ summary: 'Get custom event types for a project' }) + @ApiOkResponse({ + description: 'Custom event types retrieved successfully', + type: CustomEventTypeResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + async getCustomEventTypes( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetCustomEventTypesQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getCustomEventTypes({ + projectName: query.projectName, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new CustomEventTypeResponse(response); + } + + @Get('custom-graph-types') + @ApiOperation({ summary: 'Get custom graph types by event type ID' }) + @ApiOkResponse({ + description: 'Custom graph types retrieved successfully', + type: CustomGraphTypeResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ name: 'eventTypeId', type: String, description: 'Event type ID' }) + async getCustomGraphTypesById( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetCustomGraphTypesQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getCustomGraphTypesById({ + projectName: query.projectName, + eventTypeId: query.eventTypeId, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new CustomGraphTypeResponse(response); + } + + @Get('events/click') + @ApiOperation({ summary: 'Get click events with pagination' }) + @ApiOkResponse({ + description: 'Click events retrieved successfully', + type: GetClickEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterId', + type: String, + required: false, + description: 'Pagination cursor', + }) + @ApiQuery({ + name: 'environment', + type: String, + required: false, + description: 'Environment filter', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + async getClickEventsPaginated( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getClickEventsPaginated({ + projectName: query.projectName, + afterId: query.afterId || '', + environment: query.environment || '', + limit: query.limit || 0, + afterTime: query.afterTime || '', + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetClickEventsResponse(response); + } + + @Get('events/click/all') + @ApiOperation({ summary: 'Get all click events' }) + @ApiOkResponse({ + description: 'All click events retrieved successfully', + type: GetAllClickEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + async getAllClickEvents( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetAllEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getAllClickEvents({ + projectName: query.projectName, + afterTime: query.afterTime || '', + limit: query.limit || 0, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetAllClickEventsResponse(response); + } + + @Get('events/visit') + @ApiOperation({ summary: 'Get visit events with pagination' }) + @ApiOkResponse({ + description: 'Visit events retrieved successfully', + type: GetVisitEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterId', + type: String, + required: false, + description: 'Pagination cursor', + }) + @ApiQuery({ + name: 'environment', + type: String, + required: false, + description: 'Environment filter', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + async getVisitEventsPaginated( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getVisitEventsPaginated({ + projectName: query.projectName, + afterId: query.afterId || '', + environment: query.environment || '', + limit: query.limit || 0, + afterTime: query.afterTime || '', + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetVisitEventsResponse(response); + } + + @Get('events/visit/all') + @ApiOperation({ summary: 'Get all visit events' }) + @ApiOkResponse({ + description: 'All visit events retrieved successfully', + type: GetAllVisitEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + async getAllVisitEvents( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetAllEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getAllVisitEvents({ + projectName: query.projectName, + afterTime: query.afterTime || '', + limit: query.limit || 0, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetAllVisitEventsResponse(response); + } + + @Get('events/input') + @ApiOperation({ summary: 'Get input events with pagination' }) + @ApiOkResponse({ + description: 'Input events retrieved successfully', + type: GetInputEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterId', + type: String, + required: false, + description: 'Pagination cursor', + }) + @ApiQuery({ + name: 'environment', + type: String, + required: false, + description: 'Environment filter', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + async getInputEventsPaginated( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getInputEventsPaginated({ + projectName: query.projectName, + afterId: query.afterId || '', + environment: query.environment || '', + limit: query.limit || 0, + afterTime: query.afterTime || '', + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetInputEventsResponse(response); + } + + @Get('events/input/all') + @ApiOperation({ summary: 'Get all input events' }) + @ApiOkResponse({ + description: 'All input events retrieved successfully', + type: GetAllInputEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + async getAllInputEvents( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetAllEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getAllInputEvents({ + projectName: query.projectName, + afterTime: query.afterTime || '', + limit: query.limit || 0, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetAllInputEventsResponse(response); + } + + @Get('events/custom') + @ApiOperation({ summary: 'Get custom events with pagination' }) + @ApiOkResponse({ + description: 'Custom events retrieved successfully', + type: GetCustomEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ name: 'category', type: String, description: 'Event category' }) + @ApiQuery({ + name: 'subcategory', + type: String, + description: 'Event subcategory', + }) + @ApiQuery({ + name: 'afterId', + type: String, + required: false, + description: 'Pagination cursor', + }) + @ApiQuery({ + name: 'environment', + type: String, + required: false, + description: 'Environment filter', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + async getCustomEventsPaginated( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetCustomEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getCustomEventsPaginated({ + projectName: query.projectName, + category: query.category, + subcategory: query.subcategory, + afterId: query.afterId || '', + environment: query.environment || '', + limit: query.limit || 0, + afterTime: query.afterTime || '', + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetCustomEventsResponse(response); + } + + @Get('events/custom/all') + @ApiOperation({ summary: 'Get all custom events' }) + @ApiOkResponse({ + description: 'All custom events retrieved successfully', + type: GetAllCustomEventsResponse, + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @ApiBadRequestResponse({ description: 'Bad Request' }) + @ApiQuery({ name: 'projectName', type: String, description: 'Project name' }) + @ApiQuery({ name: 'category', type: String, description: 'Event category' }) + @ApiQuery({ + name: 'subcategory', + type: String, + description: 'Event subcategory', + }) + @ApiQuery({ + name: 'afterTime', + type: String, + required: false, + description: 'Filter after timestamp', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Max results', + }) + async getAllCustomEvents( + @ApiKey() apiKey: AuthCommonProto.ApiKey, + @Query() query: GetAllCustomEventsQuery, + ): Promise { + await validateOrReject(query); + + const response = await lastValueFrom( + this.analyticsService.getAllCustomEvents({ + projectName: query.projectName, + category: query.category, + subcategory: query.subcategory, + afterTime: query.afterTime || '', + limit: query.limit || 0, + configId: apiKey.project.id, + configEnvironment: apiKey.environment, + }), + ); + + return new GetAllCustomEventsResponse(response); + } +} diff --git a/packages/api-gateway/src/modules/analytics/analytics.module.ts b/packages/api-gateway/src/modules/analytics/analytics.module.ts new file mode 100644 index 00000000..4019f486 --- /dev/null +++ b/packages/api-gateway/src/modules/analytics/analytics.module.ts @@ -0,0 +1,64 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'path'; +import { AnalyticsController } from './analytics.controller'; +import { + AnalyticsProto, + AnalyticsProtoFile, + ApiKeyProto, + ApiKeyProtoFile, + JwtProto, + JwtProtoFile, +} from 'juno-proto'; +import { ApiKeyMiddleware } from 'src/middleware/api_key.middleware'; + +const { JWT_SERVICE_NAME, JUNO_JWT_PACKAGE_NAME } = JwtProto; +const { + ANALYTICS_SERVICE_NAME, + JUNO_ANALYTICS_SERVICE_ANALYTICS_PACKAGE_NAME, +} = AnalyticsProto; +const { API_KEY_SERVICE_NAME, JUNO_API_KEY_PACKAGE_NAME } = ApiKeyProto; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: join(__dirname, '../../../../../.env.local'), + }), + ClientsModule.register([ + { + name: JWT_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.AUTH_SERVICE_ADDR, + package: JUNO_JWT_PACKAGE_NAME, + protoPath: JwtProtoFile, + }, + }, + { + name: API_KEY_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.AUTH_SERVICE_ADDR, + package: JUNO_API_KEY_PACKAGE_NAME, + protoPath: ApiKeyProtoFile, + }, + }, + { + name: ANALYTICS_SERVICE_NAME, + transport: Transport.GRPC, + options: { + url: process.env.ANALYTICS_SERVICE_ADDR, + package: JUNO_ANALYTICS_SERVICE_ANALYTICS_PACKAGE_NAME, + protoPath: AnalyticsProtoFile, + }, + }, + ]), + ], + controllers: [AnalyticsController], +}) +export class AnalyticsModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(ApiKeyMiddleware).forRoutes('analytics/*'); + } +} diff --git a/packages/api-gateway/src/modules/analytics_config/analytics_config.controller.ts b/packages/api-gateway/src/modules/analytics_config/analytics_config.controller.ts index 515734cb..048900a2 100644 --- a/packages/api-gateway/src/modules/analytics_config/analytics_config.controller.ts +++ b/packages/api-gateway/src/modules/analytics_config/analytics_config.controller.ts @@ -66,7 +66,8 @@ export class AnalyticsConfigController implements OnModuleInit { this.analyticsConfigDbService.createAnalyticsConfig({ projectId: apiKey.project.id, environment: apiKey.environment, - analyticsKey: request.analyticsKey, + serverAnalyticsKey: request.serverAnalyticsKey, + clientAnalyticsKey: request.clientAnalyticsKey, }), ); return new AnalyticsConfigResponse(response); @@ -127,7 +128,8 @@ export class AnalyticsConfigController implements OnModuleInit { this.analyticsConfigDbService.updateAnalyticsConfig({ id: id, environment: apiKey.environment, - analyticsKey: request.analyticsKey, + serverAnalyticsKey: request.serverAnalyticsKey, + clientAnalyticsKey: request.clientAnalyticsKey, }), ); diff --git a/packages/api-gateway/test/analytics.e2e-spec.ts b/packages/api-gateway/test/analytics.e2e-spec.ts new file mode 100644 index 00000000..71816d2b --- /dev/null +++ b/packages/api-gateway/test/analytics.e2e-spec.ts @@ -0,0 +1,659 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ClassSerializerInterceptor, + INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { AppModule } from './../src/app.module'; +import { Reflector } from '@nestjs/core'; +import * as request from 'supertest'; +import { ResetProtoFile } from 'juno-proto'; +import * as GRPC from '@grpc/grpc-js'; +import * as ProtoLoader from '@grpc/proto-loader'; +import { RpcExceptionFilter } from 'src/rpc_exception_filter'; + +let app: INestApplication; +const ADMIN_EMAIL = 'test-superadmin@test.com'; +const ADMIN_PASSWORD = 'test-password'; + +let apiKey: string; +jest.setTimeout(15000); + +beforeAll(async () => { + const proto = ProtoLoader.loadSync([ResetProtoFile]) as any; + + const protoGRPC = GRPC.loadPackageDefinition(proto) as any; + const resetClient = new protoGRPC.juno.reset_db.DatabaseReset( + process.env.DB_SERVICE_ADDR, + GRPC.credentials.createInsecure(), + ); + await new Promise((resolve) => { + resetClient.resetDb({}, () => { + resolve(0); + }); + }); +}); + +afterAll((done) => { + app.close(); + done(); +}); + +async function createApiKey(proj: string, env: string): Promise { + const key = await request(app.getHttpServer()) + .post('/auth/key') + .set('X-User-Email', ADMIN_EMAIL) + .set('X-User-Password', ADMIN_PASSWORD) + .send({ + environment: env, + project: { + name: proj, + }, + }); + + return key.body['apiKey']; +} + +beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + }), + ); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + app.useGlobalFilters(new RpcExceptionFilter()); + + await app.init(); + + if (!apiKey) { + apiKey = await createApiKey('test-seed-project', 'prod'); + } +}); + +describe('Analytics Event Logging Routes', () => { + describe('POST /analytics/events/click', () => { + it('Successfully log a click event with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + objectId: 'button-123', + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(201); + }); + + it('Failed to log click event due to missing API key', async () => { + return await request(app.getHttpServer()) + .post('/analytics/events/click') + .send({ + objectId: 'button-123', + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(401); + }); + + it('Failed to log click event due to invalid API key', async () => { + return await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer invalid.api.key') + .send({ + objectId: 'button-123', + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(401); + }); + + it('Failed to log click event due to missing objectId', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + + it('Failed to log click event due to missing userId', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + objectId: 'button-123', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); + + describe('POST /analytics/events/visit', () => { + it('Successfully log a visit event with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .post('/analytics/events/visit') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + pageUrl: 'https://example.com/dashboard', + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(201); + }); + + it('Failed to log visit event due to missing pageUrl', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/visit') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + + it('Failed to log visit event due to missing analytics API key', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/visit') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + pageUrl: 'https://example.com/dashboard', + userId: 'user-456', + }) + .expect(400); + }); + }); + + describe('POST /analytics/events/input', () => { + it('Successfully log an input event with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .post('/analytics/events/input') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + objectId: 'search-field-123', + userId: 'user-456', + textValue: 'search query', + apiKey: 'analytics-api-key-789', + }) + .expect(201); + }); + + it('Failed to log input event due to missing textValue', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/input') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + objectId: 'search-field-123', + userId: 'user-456', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); + + describe('POST /analytics/events/custom', () => { + it('Successfully log a custom event with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .post('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + category: 'user_action', + subcategory: 'form_submission', + properties: { formType: 'contact', source: 'homepage' }, + apiKey: 'analytics-api-key-789', + }) + .expect(201); + }); + + it('Failed to log custom event due to missing category', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + subcategory: 'form_submission', + properties: { formType: 'contact' }, + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + + it('Failed to log custom event due to missing subcategory', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .post('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + category: 'user_action', + properties: { formType: 'contact' }, + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); +}); + +describe('Analytics Event Retrieval Routes', () => { + describe('GET /analytics/custom-event-types', () => { + it('Successfully get custom event types with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/custom-event-types') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get custom event types due to missing project name', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/custom-event-types') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + + it('Failed to get custom event types due to missing analytics API key', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/custom-event-types') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + }) + .expect(400); + }); + }); + + describe('GET /analytics/custom-graph-types', () => { + it('Successfully get custom graph types with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/custom-graph-types') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + eventTypeId: 'event-type-123', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get custom graph types due to missing event type ID', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/custom-graph-types') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); + + describe('GET /analytics/events/click', () => { + it('Successfully get paginated click events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + environment: 'production', + limit: 10, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Successfully get paginated click events with optional parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + afterId: 'event-123', + environment: 'development', + limit: 5, + afterTime: '2023-01-01T00:00:00Z', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get click events due to missing project name', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); + + describe('GET /analytics/events/click/all', () => { + it('Successfully get all click events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/click/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + limit: 100, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Successfully get all click events with optional afterTime parameter', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/click/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + afterTime: '2023-01-01T00:00:00Z', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + }); + + describe('GET /analytics/events/visit', () => { + it('Successfully get paginated visit events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/visit') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + environment: 'production', + limit: 10, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get visit events due to unauthorized access', async () => { + return await request(app.getHttpServer()) + .get('/analytics/events/visit') + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(401); + }); + }); + + describe('GET /analytics/events/visit/all', () => { + it('Successfully get all visit events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/visit/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + }); + + describe('GET /analytics/events/input', () => { + it('Successfully get paginated input events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/input') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + environment: 'production', + limit: 10, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + }); + + describe('GET /analytics/events/input/all', () => { + it('Successfully get all input events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/input/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + }); + + describe('GET /analytics/events/custom', () => { + it('Successfully get paginated custom events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + category: 'user_action', + subcategory: 'form_submission', + environment: 'production', + limit: 10, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get custom events due to missing category', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + subcategory: 'form_submission', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + + it('Failed to get custom events due to missing subcategory', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + category: 'user_action', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); + + describe('GET /analytics/events/custom/all', () => { + it('Successfully get all custom events with valid parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/custom/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + category: 'user_action', + subcategory: 'form_submission', + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Successfully get all custom events with optional parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/custom/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + category: 'user_action', + subcategory: 'form_submission', + afterTime: '2023-01-01T00:00:00Z', + limit: 50, + apiKey: 'analytics-api-key-789', + }) + .expect(200); + }); + + it('Failed to get all custom events due to missing required parameters', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + return await request(app.getHttpServer()) + .get('/analytics/events/custom/all') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); + }); +}); + +describe('Analytics Authentication and Error Handling', () => { + it('Failed to access any analytics endpoint with invalid API key', async () => { + const endpoints = [ + '/analytics/events/click', + '/analytics/events/visit', + '/analytics/events/input', + '/analytics/events/custom', + '/analytics/custom-event-types', + '/analytics/custom-graph-types', + '/analytics/events/click/all', + '/analytics/events/visit/all', + '/analytics/events/input/all', + '/analytics/events/custom/all', + ]; + + for (const endpoint of endpoints) { + await request(app.getHttpServer()) + .get(endpoint) + .set('Authorization', 'Bearer invalid.api.key') + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + ...(endpoint.includes('custom') && !endpoint.includes('types') + ? { + category: 'user_action', + subcategory: 'form_submission', + } + : {}), + ...(endpoint.includes('graph-types') + ? { + eventTypeId: 'event-type-123', + } + : {}), + }) + .expect(401); + } + }); + + it('Failed to access analytics endpoints without authorization header', async () => { + const endpoints = [ + '/analytics/custom-event-types', + '/analytics/events/click', + '/analytics/events/visit/all', + ]; + + for (const endpoint of endpoints) { + await request(app.getHttpServer()) + .get(endpoint) + .query({ + projectName: 'test-seed-project', + apiKey: 'analytics-api-key-789', + ...(endpoint.includes('custom') && !endpoint.includes('types') + ? { + category: 'user_action', + subcategory: 'form_submission', + } + : {}), + }) + .expect(401); + } + }); + + it('POST endpoints should validate request bodies properly', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + + // Test empty body + await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .send({}) + .expect(400); + + // Test invalid properties + await request(app.getHttpServer()) + .post('/analytics/events/custom') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + category: '', + subcategory: 'form_submission', + properties: 'invalid-properties', + apiKey: 'analytics-api-key-789', + }) + .expect(400); + }); +}); + +describe('Analytics Service Routes Log Events', () => { + it('Successfully log a click event ', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .post('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .send({ + objectId: 'button-123', + userId: 'user-456', + apiKey: 'api-key-789', + }) + .expect(201); + }); +}); + +describe('Analytics Service Routes Fetch Events', () => { + it('Successfully fetch click events', async () => { + const apiKey = await createApiKey('test-seed-project', 'prod'); + await request(app.getHttpServer()) + .get('/analytics/events/click') + .set('Authorization', 'Bearer ' + apiKey) + .query({ + projectName: 'test-seed-project', + apiKey: 'api-key-789', + }) + .expect(200); + }); +}); diff --git a/packages/api-gateway/test/analytics_config.e2e-spec.ts b/packages/api-gateway/test/analytics_config.e2e-spec.ts index ad45b0c4..25b24914 100644 --- a/packages/api-gateway/test/analytics_config.e2e-spec.ts +++ b/packages/api-gateway/test/analytics_config.e2e-spec.ts @@ -83,14 +83,16 @@ describe('Analytics Config Routes (e2e)', () => { .post('/analytics/config') .set('Authorization', 'Bearer ' + apiKey) .send({ - analyticsKey: 'test-analytics-key-123', + serverAnalyticsKey: 'test-analytics-key-123', + clientAnalyticsKey: 'test-analytics-key-123', }) .expect(201) .expect((res) => { expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('environment'); expect(res.body).toHaveProperty('analyticsKey'); - expect(res.body.analyticsKey).toBe('test-analytics-key-123'); + expect(res.body.serverAnalyticsKey).toBe('test-analytics-key-123'); + expect(res.body.clientAnalyticsKey).toBe('test-analytics-key-123'); }); }); @@ -106,7 +108,8 @@ describe('Analytics Config Routes (e2e)', () => { return await request(app.getHttpServer()) .post('/analytics/config') .send({ - analyticsKey: 'test-analytics-key-123', + serverAnalyticsKey: 'test-analytics-key-123', + clientAnalyticsKey: 'test-analytics-key-123', }) .expect(401); }); @@ -118,7 +121,8 @@ describe('Analytics Config Routes (e2e)', () => { .post('/analytics/config') .set('Authorization', 'Bearer ' + apiKey) .send({ - analyticsKey: 'duplicate-test-key', + serverAnalyticsKey: 'test-analytics-key-123', + clientAnalyticsKey: 'test-analytics-key-123', }) .expect(409); }); @@ -134,7 +138,11 @@ describe('Analytics Config Routes (e2e)', () => { expect(res.body).toHaveProperty('id', 0); expect(res.body).toHaveProperty('environment'); expect(res.body).toHaveProperty( - 'analyticsKey', + 'serverAnalyticsKey', + 'test-analytics-key-123', + ); + expect(res.body).toHaveProperty( + 'clientAnalyticsKey', 'test-analytics-key-123', ); }); @@ -167,13 +175,18 @@ describe('Analytics Config Routes (e2e)', () => { .put('/analytics/config/0') .set('Authorization', 'Bearer ' + apiKey) .send({ - analyticsKey: 'updated-analytics-key', + serverAnalyticsKey: 'updated-analytics-key', + clientAnalyticsKey: 'updated-analytics-key', }) .expect(200) .expect((res) => { expect(res.body).toHaveProperty('id', 0); expect(res.body).toHaveProperty( - 'analyticsKey', + 'serverAnalyticsKey', + 'updated-analytics-key', + ); + expect(res.body).toHaveProperty( + 'clientAnalyticsKey', 'updated-analytics-key', ); }); @@ -184,7 +197,8 @@ describe('Analytics Config Routes (e2e)', () => { .put('/analytics/config/invalid-id') .set('Authorization', 'Bearer ' + apiKey) .send({ - analyticsKey: 'updated-key', + serverAnalyticsKey: 'updated-key', + clientAnalyticsKey: 'updated-key', }) .expect(400); }); @@ -193,7 +207,8 @@ describe('Analytics Config Routes (e2e)', () => { return await request(app.getHttpServer()) .put('/analytics/config/0') .send({ - analyticsKey: 'updated-key', + serverAnalyticsKey: 'updated-key', + clientAnalyticsKey: 'updated-key', }) .expect(401); }); @@ -203,7 +218,8 @@ describe('Analytics Config Routes (e2e)', () => { .put('/analytics/config/999') .set('Authorization', 'Bearer ' + apiKey) .send({ - analyticsKey: 'updated-key', + serverAnalyticsKey: 'updated-key', + clientAnalyticsKey: 'updated-key', }) .expect(404); }); @@ -218,7 +234,7 @@ describe('Analytics Config Routes (e2e)', () => { .expect((res) => { expect(res.body).toHaveProperty('id', 0); expect(res.body).toHaveProperty( - 'analyticsKey', + 'serverAnalyticsKey', 'updated-analytics-key', ); }); diff --git a/packages/db-service/prisma/migrations/20251025064359_/migration.sql b/packages/db-service/prisma/migrations/20251025064359_/migration.sql new file mode 100644 index 00000000..ebcc3921 --- /dev/null +++ b/packages/db-service/prisma/migrations/20251025064359_/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `analyticsKey` on the `AnalyticsServiceConfig` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "AnalyticsServiceConfig" DROP COLUMN "analyticsKey", +ADD COLUMN "clientAnalyticsKey" TEXT NOT NULL DEFAULT '', +ADD COLUMN "serverAnalyticsKey" TEXT NOT NULL DEFAULT ''; diff --git a/packages/db-service/prisma/schema.prisma b/packages/db-service/prisma/schema.prisma index 5590d083..c12b4678 100644 --- a/packages/db-service/prisma/schema.prisma +++ b/packages/db-service/prisma/schema.prisma @@ -53,7 +53,8 @@ enum Role { model AnalyticsServiceConfig { id Int environment String @default("test") - analyticsKey String @default("") + serverAnalyticsKey String @default("") + clientAnalyticsKey String @default("") Project Project @relation(fields: [id], references: [id]) @@id([id, environment]) } diff --git a/packages/db-service/src/modules/analytics_config/analytics_config.controller.ts b/packages/db-service/src/modules/analytics_config/analytics_config.controller.ts index 3a4bb9f7..9da4e348 100644 --- a/packages/db-service/src/modules/analytics_config/analytics_config.controller.ts +++ b/packages/db-service/src/modules/analytics_config/analytics_config.controller.ts @@ -24,7 +24,8 @@ export class AnalyticsConfigDbController return { id: config.id, environment: config.environment, - analyticsKey: config.analyticsKey, + serverAnalyticsKey: config.serverAnalyticsKey, + clientAnalyticsKey: config.clientAnalyticsKey, }; } catch (e) { if ( @@ -60,7 +61,8 @@ export class AnalyticsConfigDbController return { id: config.id, environment: config.environment, - analyticsKey: config.analyticsKey, + serverAnalyticsKey: config.serverAnalyticsKey, + clientAnalyticsKey: config.clientAnalyticsKey, }; } catch (error) { throw error; @@ -70,10 +72,13 @@ export class AnalyticsConfigDbController async updateAnalyticsConfig( request: AnalyticsConfigProto.UpdateConfigRequest, ): Promise { - //Can only update analyticsKey, is this how it should work? const updateData: any = {}; - if (request.analyticsKey !== undefined) { - updateData.analyticsKey = request.analyticsKey; + if (request.serverAnalyticsKey !== undefined) { + updateData.serverAnalyticsKey = request.serverAnalyticsKey; + } + + if (request.clientAnalyticsKey !== undefined) { + updateData.clientAnalyticsKey = request.clientAnalyticsKey; } try { @@ -85,7 +90,8 @@ export class AnalyticsConfigDbController return { id: config.id, environment: config.environment, - analyticsKey: config.analyticsKey, + serverAnalyticsKey: config.serverAnalyticsKey, + clientAnalyticsKey: config.clientAnalyticsKey, }; } catch (e) { if ( @@ -114,7 +120,8 @@ export class AnalyticsConfigDbController return { id: config.id, environment: config.environment, - analyticsKey: config.analyticsKey, + clientAnalyticsKey: config.clientAnalyticsKey, + serverAnalyticsKey: config.serverAnalyticsKey, }; } catch (e) { if ( diff --git a/packages/db-service/src/modules/analytics_config/analytics_config.service.ts b/packages/db-service/src/modules/analytics_config/analytics_config.service.ts index 971c16d5..e034e4db 100644 --- a/packages/db-service/src/modules/analytics_config/analytics_config.service.ts +++ b/packages/db-service/src/modules/analytics_config/analytics_config.service.ts @@ -14,7 +14,8 @@ export class AnalyticsConfigService { data: { id: Number(configData.projectId), environment: configData.environment, - analyticsKey: configData.analyticsKey, + serverAnalyticsKey: configData.serverAnalyticsKey, + clientAnalyticsKey: configData.clientAnalyticsKey, }, include: { Project: true, diff --git a/packages/db-service/test/analytics_config.e2e-spec.ts b/packages/db-service/test/analytics_config.e2e-spec.ts index 4e87a222..b08b37ce 100644 --- a/packages/db-service/test/analytics_config.e2e-spec.ts +++ b/packages/db-service/test/analytics_config.e2e-spec.ts @@ -73,7 +73,8 @@ function tryCreateConfig(configClient: any) { { projectId: 0, environment: 'test-env', - analyticsKey: 'test-analytics-key', + serverAnalyticsKey: 'mock-api-key-123', + clientAnalyticsKey: 'mock-api-key-123', }, () => { resolve(); @@ -102,7 +103,8 @@ describe('Analytics Config Tests', () => { { projectId: 0, environment: 'test-env', - analyticsKey: 'test-analytics-key', + serverAnalyticsKey: 'test-analytics-key', + clientAnalyticsKey: 'test-analytics-key-2', }, (err, res) => { if (err) { @@ -117,7 +119,8 @@ describe('Analytics Config Tests', () => { expect(response).toHaveProperty('id'); expect(response.environment).toBe('test-env'); - expect(response.analyticsKey).toBe('test-analytics-key'); + expect(response.serverAnalyticsKey).toBe('test-analytics-key'); + expect(response.clientAnalyticsKey).toBe('test-analytics-key-2'); }); it('creates a duplicate analytics config', async () => { @@ -127,7 +130,8 @@ describe('Analytics Config Tests', () => { { projectId: 0, environment: 'prod', - analyticsKey: 'prod-analytics-key', + serverAnalyticsKey: 'test-analytics-key', + clientAnalyticsKey: 'test-analytics-key-2', }, (err, res) => { if (err) { @@ -140,7 +144,8 @@ describe('Analytics Config Tests', () => { }); expect(response).toHaveProperty('id'); expect(response.environment).toBe('prod'); - expect(response.analyticsKey).toBe('prod-analytics-key'); + expect(response.serverAnalyticsKey).toBe('test-analytics-key'); + expect(response.clientAnalyticsKey).toBe('test-analytics-key-2'); }); it('reads an analytics config', async () => { @@ -161,7 +166,6 @@ describe('Analytics Config Tests', () => { expect(readResponse).toHaveProperty('id', createdConfigId); expect(readResponse.environment).toBe('test-env'); - expect(readResponse.analyticsKey).toBe('test-analytics-key'); }); it('reading a nonexistent analytics config', async () => { @@ -185,7 +189,8 @@ describe('Analytics Config Tests', () => { { id: createdConfigId, environment: 'test-env', - analyticsKey: 'updated-analytics-key', + serverAnalyticsKey: 'updated-server-key', + clientAnalyticsKey: 'updated-client-key', }, (err, res) => { if (err) { @@ -198,7 +203,8 @@ describe('Analytics Config Tests', () => { }); expect(updateResponse).toHaveProperty('id', createdConfigId); - expect(updateResponse.analyticsKey).toBe('updated-analytics-key'); + expect(updateResponse.serverAnalyticsKey).toBe('updated-server-key'); + expect(updateResponse.clientAnalyticsKey).toBe('updated-client-key'); }); it('updates a nonexistent analytics config', async () => { @@ -207,7 +213,8 @@ describe('Analytics Config Tests', () => { { id: 9999, environment: 'test-env', - analyticsKey: 'updated-analytics-key', + serverAnalyticsKey: 'mock-api-key-123', + clientAnalyticsKey: 'mock-api-key-123', }, (err) => { expect(err.code).toBe(GRPC.status.NOT_FOUND); diff --git a/packages/proto/definitions/analytics.proto b/packages/proto/definitions/analytics.proto index 5be696a0..42679681 100644 --- a/packages/proto/definitions/analytics.proto +++ b/packages/proto/definitions/analytics.proto @@ -3,10 +3,23 @@ syntax = "proto3"; package juno.analytics_service.analytics; service AnalyticsService { + // BogAnalyticsLogger methods rpc logClickEvent(ClickEventRequest) returns (ClickEventResponse); rpc logVisitEvent(VisitEventRequest) returns (VisitEventResponse); rpc logInputEvent(InputEventRequest) returns (InputEventResponse); rpc logCustomEvent(CustomEventRequest) returns (CustomEventResponse); + + // AnalyticsViewer methods + rpc getCustomEventTypes(CustomEventTypeRequest) returns (CustomEventTypeResponse); + rpc getCustomGraphTypesById(CustomGraphTypeRequest) returns (CustomGraphTypeResponse); + rpc getClickEventsPaginated(GetClickEventsRequest) returns (GetClickEventsResponse); + rpc getAllClickEvents(GetAllClickEventsRequest) returns (GetAllClickEventsResponse); + rpc getVisitEventsPaginated(GetVisitEventsRequest) returns (GetVisitEventsResponse); + rpc getAllVisitEvents(GetAllVisitEventsRequest) returns (GetAllVisitEventsResponse); + rpc getInputEventsPaginated(GetInputEventsRequest) returns (GetInputEventsResponse); + rpc getAllInputEvents(GetAllInputEventsRequest) returns (GetAllInputEventsResponse); + rpc getCustomEventsPaginated(GetCustomEventsRequest) returns (GetCustomEventsResponse); + rpc getAllCustomEvents(GetAllCustomEventsRequest) returns (GetAllCustomEventsResponse); } // Click Event Request - matches bog-analytics ClickEventProperties @@ -15,8 +28,9 @@ message ClickEventRequest { string user_id = 2; // userId in bog-analytics // Note: bog-analytics handles createdAt automatically - int64 project_id = 3; // Project ID for analytics config lookup string environment = 4; // Environment for analytics config lookup + string configEnvironment = 5; + int64 configId = 6; } // Visit Event Request - matches bog-analytics VisitEventProperties @@ -25,8 +39,10 @@ message VisitEventRequest { string user_id = 2; // userId in bog-analytics // Note: bog-analytics handles createdAt automatically - int64 project_id = 3; // Project ID for analytics config lookup string environment = 4; // Environment for analytics config lookup + // + string configEnvironment = 5; + int64 configId = 6; } // Input Event Request - matches bog-analytics InputEventProperties @@ -36,8 +52,9 @@ message InputEventRequest { string text_value = 3; // textValue in bog-analytics // Note: bog-analytics handles createdAt automatically - int64 project_id = 4; // Project ID for analytics config lookup string environment = 5; // Environment for analytics config lookup + string configEnvironment = 6; + int64 configId = 7; } // Custom Event Request - matches bog-analytics logCustomEvent parameters @@ -47,8 +64,104 @@ message CustomEventRequest { map properties = 3; // properties object in bog-analytics // Note: bog-analytics handles createdAt automatically - int64 project_id = 4; // Project ID for analytics config lookup string environment = 5; // Environment for analytics config lookup + + + string configEnvironment = 6; + int64 configId = 7; +} + +message CustomEventTypeRequest { + string project_name = 1; + string configEnvironment = 2; + int64 configId = 3; +} + +message CustomGraphTypeRequest { + string project_name = 1; + string event_type_id = 2; + string configEnvironment = 3; + int64 configId = 4; +} + +// Paginated requests - matches bog-analytics GetEventsQueryParams +message GetClickEventsRequest { + string project_name = 1; + string after_id = 2; // afterId in bog-analytics (optional) + string environment = 3; // environment in bog-analytics + int32 limit = 4; // limit in bog-analytics (optional) + string after_time = 5; // afterTime in bog-analytics (optional) + string configEnvironment = 6; + int64 configId = 7; +} + +message GetVisitEventsRequest { + string project_name = 1; + string after_id = 2; + string environment = 3; + int32 limit = 4; + string after_time = 5; + + string configEnvironment = 6; + int64 configId = 7; +} + +message GetInputEventsRequest { + string project_name = 1; + string after_id = 2; + string environment = 3; + int32 limit = 4; + string after_time = 5; + string configEnvironment = 6; + int64 configId = 7; +} + +// Custom events extends the base pattern with category/subcategory +message GetCustomEventsRequest { + string project_name = 1; + string after_id = 2; + string environment = 3; + int32 limit = 4; + string after_time = 5; + string category = 6; // category for custom events + string subcategory = 7; // subcategory for custom events + string configEnvironment = 8; + int64 configId = 9; +} + +// "Get All" requests - simpler, matches bog-analytics getAllXXXEvents +message GetAllClickEventsRequest { + string project_name = 1; + string after_time = 2; // afterTime in bog-analytics (optional) + int32 limit = 3; // limit in bog-analytics (optional) + string configEnvironment = 4; + int64 configId = 5; +} + +message GetAllVisitEventsRequest { + string project_name = 1; + string after_time = 2; + int32 limit = 3; + string configEnvironment = 4; + int64 configId = 5; +} + +message GetAllInputEventsRequest { + string project_name = 1; + string after_time = 2; + int32 limit = 3; + string configEnvironment = 4; + int64 configId = 5; +} + +message GetAllCustomEventsRequest { + string project_name = 1; + string category = 2; + string subcategory = 3; + string after_time = 4; + int32 limit = 5; + string configEnvironment = 6; + int64 configId = 7; } // Event Properties - match bog-analytics eventProperties @@ -122,4 +235,65 @@ message CustomEventResponse { string created_at = 5; // createdAt field string updated_at = 6; // updatedAt field map properties = 7; // Custom properties object -} \ No newline at end of file +} + +message CustomEventTypeResponse { + string id = 1; + string category = 2; + string subcategory = 3; + repeated string properties = 4; + string project_id = 5; +} + +message CustomGraphTypeResponse { + repeated CustomGraphType graphs = 1; +} + +message CustomGraphType { + string id = 1; + string event_type_id = 2; + string project_id = 3; + string graph_title = 4; + string x_property = 5; + string y_property = 6; + string graph_type = 7; + string caption = 8; // optional field +} + +// Paginated responses - reuse existing response types in arrays + afterId +message GetClickEventsResponse { + repeated ClickEventResponse events = 1; + string after_id = 2; +} + +message GetVisitEventsResponse { + repeated VisitEventResponse events = 1; + string after_id = 2; +} + +message GetInputEventsResponse { + repeated InputEventResponse events = 1; + string after_id = 2; +} + +message GetCustomEventsResponse { + repeated CustomEventResponse events = 1; + string after_id = 2; +} + +// "Get All" responses - just arrays of existing response types +message GetAllClickEventsResponse { + repeated ClickEventResponse events = 1; +} + +message GetAllVisitEventsResponse { + repeated VisitEventResponse events = 1; +} + +message GetAllInputEventsResponse { + repeated InputEventResponse events = 1; +} + +message GetAllCustomEventsResponse { + repeated CustomEventResponse events = 1; +} diff --git a/packages/proto/definitions/analytics_config.proto b/packages/proto/definitions/analytics_config.proto index 45ea9079..7fd4d605 100644 --- a/packages/proto/definitions/analytics_config.proto +++ b/packages/proto/definitions/analytics_config.proto @@ -12,7 +12,8 @@ service AnalyticsConfigDbService { message CreateConfigRequest { int64 projectId = 1; string environment = 2; - string analyticsKey = 3; + string serverAnalyticsKey = 3; + string clientAnalyticsKey = 4; } message ReadConfigRequest { @@ -23,7 +24,8 @@ message ReadConfigRequest { message UpdateConfigRequest { int64 id = 1; string environment = 2; - optional string analyticsKey = 3; + optional string serverAnalyticsKey = 3; + optional string clientAnalyticsKey = 4; } message DeleteConfigRequest { @@ -34,6 +36,7 @@ message DeleteConfigRequest { message AnalyticsServiceConfig { int64 id = 1; string environment = 2; - string analyticsKey = 3; + string serverAnalyticsKey = 3; + string clientAnalyticsKey = 4; } diff --git a/packages/proto/src/gen/analytics.ts b/packages/proto/src/gen/analytics.ts index 619975c3..f6b0cf63 100644 --- a/packages/proto/src/gen/analytics.ts +++ b/packages/proto/src/gen/analytics.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.2 +// protoc v6.32.1 // source: analytics.proto /* eslint-disable */ @@ -16,10 +16,10 @@ export interface ClickEventRequest { objectId: string; /** userId in bog-analytics */ userId: string; - /** Project ID for analytics config lookup */ - projectId: number; /** Environment for analytics config lookup */ environment: string; + configEnvironment: string; + configId: number; } /** Visit Event Request - matches bog-analytics VisitEventProperties */ @@ -28,10 +28,11 @@ export interface VisitEventRequest { pageUrl: string; /** userId in bog-analytics */ userId: string; - /** Project ID for analytics config lookup */ - projectId: number; /** Environment for analytics config lookup */ environment: string; + /** */ + configEnvironment: string; + configId: number; } /** Input Event Request - matches bog-analytics InputEventProperties */ @@ -42,10 +43,10 @@ export interface InputEventRequest { userId: string; /** textValue in bog-analytics */ textValue: string; - /** Project ID for analytics config lookup */ - projectId: number; /** Environment for analytics config lookup */ environment: string; + configEnvironment: string; + configId: number; } /** Custom Event Request - matches bog-analytics logCustomEvent parameters */ @@ -56,10 +57,10 @@ export interface CustomEventRequest { subcategory: string; /** properties object in bog-analytics */ properties: { [key: string]: string }; - /** Project ID for analytics config lookup */ - projectId: number; /** Environment for analytics config lookup */ environment: string; + configEnvironment: string; + configId: number; } export interface CustomEventRequest_PropertiesEntry { @@ -67,6 +68,106 @@ export interface CustomEventRequest_PropertiesEntry { value: string; } +export interface CustomEventTypeRequest { + projectName: string; + configEnvironment: string; + configId: number; +} + +export interface CustomGraphTypeRequest { + projectName: string; + eventTypeId: string; + configEnvironment: string; + configId: number; +} + +/** Paginated requests - matches bog-analytics GetEventsQueryParams */ +export interface GetClickEventsRequest { + projectName: string; + /** afterId in bog-analytics (optional) */ + afterId: string; + /** environment in bog-analytics */ + environment: string; + /** limit in bog-analytics (optional) */ + limit: number; + /** afterTime in bog-analytics (optional) */ + afterTime: string; + configEnvironment: string; + configId: number; +} + +export interface GetVisitEventsRequest { + projectName: string; + afterId: string; + environment: string; + limit: number; + afterTime: string; + configEnvironment: string; + configId: number; +} + +export interface GetInputEventsRequest { + projectName: string; + afterId: string; + environment: string; + limit: number; + afterTime: string; + configEnvironment: string; + configId: number; +} + +/** Custom events extends the base pattern with category/subcategory */ +export interface GetCustomEventsRequest { + projectName: string; + afterId: string; + environment: string; + limit: number; + afterTime: string; + /** category for custom events */ + category: string; + /** subcategory for custom events */ + subcategory: string; + configEnvironment: string; + configId: number; +} + +/** "Get All" requests - simpler, matches bog-analytics getAllXXXEvents */ +export interface GetAllClickEventsRequest { + projectName: string; + /** afterTime in bog-analytics (optional) */ + afterTime: string; + /** limit in bog-analytics (optional) */ + limit: number; + configEnvironment: string; + configId: number; +} + +export interface GetAllVisitEventsRequest { + projectName: string; + afterTime: string; + limit: number; + configEnvironment: string; + configId: number; +} + +export interface GetAllInputEventsRequest { + projectName: string; + afterTime: string; + limit: number; + configEnvironment: string; + configId: number; +} + +export interface GetAllCustomEventsRequest { + projectName: string; + category: string; + subcategory: string; + afterTime: string; + limit: number; + configEnvironment: string; + configId: number; +} + export interface ClickEventProperties { /** objectId in bog-analytics */ objectId: string; @@ -173,10 +274,74 @@ export interface CustomEventResponse_PropertiesEntry { value: string; } +export interface CustomEventTypeResponse { + id: string; + category: string; + subcategory: string; + properties: string[]; + projectId: string; +} + +export interface CustomGraphTypeResponse { + graphs: CustomGraphType[]; +} + +export interface CustomGraphType { + id: string; + eventTypeId: string; + projectId: string; + graphTitle: string; + xProperty: string; + yProperty: string; + graphType: string; + /** optional field */ + caption: string; +} + +/** Paginated responses - reuse existing response types in arrays + afterId */ +export interface GetClickEventsResponse { + events: ClickEventResponse[]; + afterId: string; +} + +export interface GetVisitEventsResponse { + events: VisitEventResponse[]; + afterId: string; +} + +export interface GetInputEventsResponse { + events: InputEventResponse[]; + afterId: string; +} + +export interface GetCustomEventsResponse { + events: CustomEventResponse[]; + afterId: string; +} + +/** "Get All" responses - just arrays of existing response types */ +export interface GetAllClickEventsResponse { + events: ClickEventResponse[]; +} + +export interface GetAllVisitEventsResponse { + events: VisitEventResponse[]; +} + +export interface GetAllInputEventsResponse { + events: InputEventResponse[]; +} + +export interface GetAllCustomEventsResponse { + events: CustomEventResponse[]; +} + export const JUNO_ANALYTICS_SERVICE_ANALYTICS_PACKAGE_NAME = 'juno.analytics_service.analytics'; export interface AnalyticsServiceClient { + /** BogAnalyticsLogger methods */ + logClickEvent(request: ClickEventRequest): Observable; logVisitEvent(request: VisitEventRequest): Observable; @@ -184,9 +349,53 @@ export interface AnalyticsServiceClient { logInputEvent(request: InputEventRequest): Observable; logCustomEvent(request: CustomEventRequest): Observable; + + /** AnalyticsViewer methods */ + + getCustomEventTypes( + request: CustomEventTypeRequest, + ): Observable; + + getCustomGraphTypesById( + request: CustomGraphTypeRequest, + ): Observable; + + getClickEventsPaginated( + request: GetClickEventsRequest, + ): Observable; + + getAllClickEvents( + request: GetAllClickEventsRequest, + ): Observable; + + getVisitEventsPaginated( + request: GetVisitEventsRequest, + ): Observable; + + getAllVisitEvents( + request: GetAllVisitEventsRequest, + ): Observable; + + getInputEventsPaginated( + request: GetInputEventsRequest, + ): Observable; + + getAllInputEvents( + request: GetAllInputEventsRequest, + ): Observable; + + getCustomEventsPaginated( + request: GetCustomEventsRequest, + ): Observable; + + getAllCustomEvents( + request: GetAllCustomEventsRequest, + ): Observable; } export interface AnalyticsServiceController { + /** BogAnalyticsLogger methods */ + logClickEvent( request: ClickEventRequest, ): @@ -214,6 +423,78 @@ export interface AnalyticsServiceController { | Promise | Observable | CustomEventResponse; + + /** AnalyticsViewer methods */ + + getCustomEventTypes( + request: CustomEventTypeRequest, + ): + | Promise + | Observable + | CustomEventTypeResponse; + + getCustomGraphTypesById( + request: CustomGraphTypeRequest, + ): + | Promise + | Observable + | CustomGraphTypeResponse; + + getClickEventsPaginated( + request: GetClickEventsRequest, + ): + | Promise + | Observable + | GetClickEventsResponse; + + getAllClickEvents( + request: GetAllClickEventsRequest, + ): + | Promise + | Observable + | GetAllClickEventsResponse; + + getVisitEventsPaginated( + request: GetVisitEventsRequest, + ): + | Promise + | Observable + | GetVisitEventsResponse; + + getAllVisitEvents( + request: GetAllVisitEventsRequest, + ): + | Promise + | Observable + | GetAllVisitEventsResponse; + + getInputEventsPaginated( + request: GetInputEventsRequest, + ): + | Promise + | Observable + | GetInputEventsResponse; + + getAllInputEvents( + request: GetAllInputEventsRequest, + ): + | Promise + | Observable + | GetAllInputEventsResponse; + + getCustomEventsPaginated( + request: GetCustomEventsRequest, + ): + | Promise + | Observable + | GetCustomEventsResponse; + + getAllCustomEvents( + request: GetAllCustomEventsRequest, + ): + | Promise + | Observable + | GetAllCustomEventsResponse; } export function AnalyticsServiceControllerMethods() { @@ -223,6 +504,16 @@ export function AnalyticsServiceControllerMethods() { 'logVisitEvent', 'logInputEvent', 'logCustomEvent', + 'getCustomEventTypes', + 'getCustomGraphTypesById', + 'getClickEventsPaginated', + 'getAllClickEvents', + 'getVisitEventsPaginated', + 'getAllVisitEvents', + 'getInputEventsPaginated', + 'getAllInputEvents', + 'getCustomEventsPaginated', + 'getAllCustomEvents', ]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor( diff --git a/packages/proto/src/gen/analytics_config.ts b/packages/proto/src/gen/analytics_config.ts index 1091948b..e0ff4315 100644 --- a/packages/proto/src/gen/analytics_config.ts +++ b/packages/proto/src/gen/analytics_config.ts @@ -1,7 +1,7 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v1.181.2 -// protoc v5.28.2 +// protoc v6.32.1 // source: analytics_config.proto /* eslint-disable */ @@ -13,7 +13,8 @@ export const protobufPackage = 'juno.analytics_service.analytics_config'; export interface CreateConfigRequest { projectId: number; environment: string; - analyticsKey: string; + serverAnalyticsKey: string; + clientAnalyticsKey: string; } export interface ReadConfigRequest { @@ -24,7 +25,8 @@ export interface ReadConfigRequest { export interface UpdateConfigRequest { id: number; environment: string; - analyticsKey?: string | undefined; + serverAnalyticsKey?: string | undefined; + clientAnalyticsKey?: string | undefined; } export interface DeleteConfigRequest { @@ -35,7 +37,8 @@ export interface DeleteConfigRequest { export interface AnalyticsServiceConfig { id: number; environment: string; - analyticsKey: string; + serverAnalyticsKey: string; + clientAnalyticsKey: string; } export const JUNO_ANALYTICS_SERVICE_ANALYTICS_CONFIG_PACKAGE_NAME = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a92c1573..14dcd6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: devDependencies: '@openapitools/openapi-generator-cli': specifier: ^2.13.5 - version: 2.13.5(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(class-transformer@0.5.1)(class-validator@0.14.1) + version: 2.13.5(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1))(class-transformer@0.5.1)(class-validator@0.14.1) concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -67,7 +67,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -95,7 +95,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -179,13 +179,13 @@ importers: version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) '@nestjs/swagger': specifier: ^7.3.0 - version: 7.4.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14) + version: 7.4.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14) '@sentry/cli': specifier: ^2.36.3 version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -225,7 +225,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -306,7 +306,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -334,7 +334,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -424,7 +424,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -452,7 +452,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -542,7 +542,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -567,7 +567,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -669,7 +669,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/profiling-node': specifier: ^8.29.0 version: 8.32.0 @@ -694,7 +694,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -778,7 +778,7 @@ importers: version: 2.36.3 '@sentry/nestjs': specifier: ^8.29.0 - version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1) + version: 8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)) '@sentry/node': specifier: ^8.29.0 version: 8.32.0 @@ -803,7 +803,7 @@ importers: version: 10.1.4(chokidar@3.6.0)(typescript@5.5.4) '@nestjs/testing': specifier: ^10.0.0 - version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1) + version: 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 @@ -7612,7 +7612,7 @@ packages: integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==, } engines: { node: '>=6.4.0 <13 || >=14' } - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net superagent@9.0.2: resolution: @@ -9516,7 +9516,7 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.1 - '@nestjs/core@10.3.0(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1)': + '@nestjs/core@10.3.0(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1))(reflect-metadata@0.1.13)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1) '@nuxtjs/opencollective': 0.3.2 @@ -9646,7 +9646,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@7.4.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)': + '@nestjs/swagger@7.4.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)': dependencies: '@microsoft/tsdoc': 0.15.0 '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1) @@ -9661,7 +9661,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/testing@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)': + '@nestjs/testing@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1))': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1) @@ -9729,11 +9729,11 @@ snapshots: '@nx/nx-win32-x64-msvc@17.3.2': optional: true - '@openapitools/openapi-generator-cli@2.13.5(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(class-transformer@0.5.1)(class-validator@0.14.1)': + '@openapitools/openapi-generator-cli@2.13.5(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1))(class-transformer@0.5.1)(class-validator@0.14.1)': dependencies: '@nestjs/axios': 3.0.2(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(axios@1.7.4)(rxjs@7.8.1) '@nestjs/common': 10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1) - '@nestjs/core': 10.3.0(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1) + '@nestjs/core': 10.3.0(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/microservices@10.4.1(@grpc/grpc-js@1.11.1)(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.4.1(@nestjs/common@10.3.0(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.4.1))(reflect-metadata@0.1.13)(rxjs@7.8.1) '@nuxtjs/opencollective': 0.3.2 axios: 1.7.4 chalk: 4.1.2 @@ -10138,7 +10138,7 @@ snapshots: '@sentry/types': 8.32.0 '@sentry/utils': 8.32.0 - '@sentry/nestjs@8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1)': + '@sentry/nestjs@8.32.0(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1) '@nestjs/core': 10.4.1(@nestjs/common@10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/microservices@10.4.1)(@nestjs/platform-express@10.4.1)(reflect-metadata@0.1.14)(rxjs@7.8.1)