-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(api-service): Usage insights email #7346
Draft
scopsy
wants to merge
34
commits into
next
Choose a base branch
from
insights-email
base: next
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,325
−3
Draft
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
9f7b9dc
feat: insights
scopsy 5728669
feat: wip
scopsy 6eb853e
fix: hello world
scopsy 099fea9
fix: email
scopsy 8fb84e3
fix: email style
scopsy 60a85a3
fix: add marketing section
scopsy a49b625
fix: upload
scopsy d56c811
fix: logo
scopsy 8a89c1b
Merge branch 'next' into insights-email
scopsy 747dffd
fix: items
scopsy 5ee4ad9
fix: workflows
scopsy 6d7c867
fix: items
scopsy 63ef939
fix: items
scopsy 836c44f
fix: items
scopsy 0dd5361
fix: refactor
scopsy 9570eff
fix: review
scopsy 2be2bf1
fix: items
scopsy 5464b96
fix: items
scopsy 2945749
fix: bugs
scopsy 4c9bd12
fix: working state
scopsy 3b4e5a9
feat: add controller
scopsy 4a4f6bc
feat: add insights tester
scopsy 88f0530
fix: mixpanel
scopsy 510860a
fix: remove cache
scopsy efc2df7
fix: remove unused import
scopsy bd7e05f
fix: trigger
scopsy 65c3d67
fix: empty state
scopsy 9ea8c3c
fix: refactpr
scopsy 69a10f8
fix: r emov unused
scopsy 3173273
fix: validation
scopsy 465427d
fix: remove pr info
scopsy 2c39098
Merge branch 'next' into insights-email
scopsy ed1d443
Merge branch 'next' into insights-email
scopsy 325762b
fix: import
scopsy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { Controller, Get, Query, UnauthorizedException } from '@nestjs/common'; | ||
import { ApiOperation, ApiQuery } from '@nestjs/swagger'; | ||
import { FeatureFlagsService } from '@novu/application-generic'; | ||
import { FeatureFlagsKeysEnum } from '@novu/shared'; | ||
import { UsageInsights } from './usecases/usage-insights/usage-insights.usecase'; | ||
import { UsageInsightsCommand } from './usecases/usage-insights/usage-insights.command'; | ||
|
||
@Controller({ | ||
path: 'insights', | ||
}) | ||
export class InsightsController { | ||
constructor( | ||
private usageInsights: UsageInsights, | ||
private featureFlagsService: FeatureFlagsService | ||
) {} | ||
|
||
@Get('/execute') | ||
@ApiOperation({ | ||
summary: 'Execute insights for a specific organization', | ||
}) | ||
@ApiQuery({ | ||
name: 'organizationId', | ||
type: String, | ||
required: true, | ||
description: 'The ID of the organization to execute insights for', | ||
}) | ||
async executeInsights(@Query('organizationId') organizationId: string) { | ||
const isAllowedToTestInsights = await this.featureFlagsService.get( | ||
FeatureFlagsKeysEnum.IS_ALLOWED_TO_TEST_INSIGHTS_ENABLED, | ||
false, | ||
{ | ||
organizationId, | ||
userId: 'system', | ||
environmentId: 'system', | ||
} | ||
); | ||
|
||
if (!isAllowedToTestInsights) { | ||
throw new UnauthorizedException('Organization is not allowed to test insights'); | ||
} | ||
|
||
const command = new UsageInsightsCommand({ organizationId }); | ||
|
||
return this.usageInsights.execute(command); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { Module } from '@nestjs/common'; | ||
import { | ||
OrganizationRepository, | ||
MessageRepository, | ||
NotificationRepository, | ||
CommunityOrganizationRepository, | ||
} from '@novu/dal'; | ||
import { USE_CASES } from './usecases'; | ||
import { SharedModule } from '../shared/shared.module'; | ||
import { InsightsInitializerService } from './services/insights-initializer.service'; | ||
import { MixpanelService } from './services/mixpanel.service'; | ||
import { MetricsCalculatorService } from './services/metrics-calculator.service'; | ||
import { OrganizationNotificationService } from './services/organization-notification.service'; | ||
import { InsightsController } from './insights.controller'; | ||
|
||
@Module({ | ||
imports: [SharedModule], | ||
providers: [ | ||
...USE_CASES, | ||
InsightsInitializerService, | ||
OrganizationRepository, | ||
MessageRepository, | ||
NotificationRepository, | ||
CommunityOrganizationRepository, | ||
MixpanelService, | ||
MetricsCalculatorService, | ||
OrganizationNotificationService, | ||
], | ||
controllers: [InsightsController], | ||
exports: [...USE_CASES], | ||
}) | ||
export class InsightsModule {} |
17 changes: 17 additions & 0 deletions
17
apps/api/src/app/insights/services/insights-initializer.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { Injectable, OnApplicationBootstrap, Logger } from '@nestjs/common'; | ||
import { UsageInsights } from '../usecases/usage-insights/usage-insights.usecase'; | ||
|
||
@Injectable() | ||
export class InsightsInitializerService implements OnApplicationBootstrap { | ||
constructor(private usageInsights: UsageInsights) {} | ||
|
||
async onApplicationBootstrap() { | ||
try { | ||
Logger.log('Initializing usage insights...'); | ||
|
||
Logger.log('Usage insights initialization completed'); | ||
Comment on lines
+10
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will be used in the future to handle monthly cron post testing period |
||
} catch (error) { | ||
Logger.error('Failed to initialize insights:', error); | ||
} | ||
} | ||
} |
241 changes: 241 additions & 0 deletions
241
apps/api/src/app/insights/services/metrics-calculator.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,241 @@ | ||
import { Injectable, Logger } from '@nestjs/common'; | ||
import { startOfDay, formatISO } from 'date-fns'; | ||
import { ChannelTypeEnum } from '@novu/shared'; | ||
import { | ||
IDateRange, | ||
IMixpanelInboxResponse, | ||
IInboxMetrics, | ||
IOrganizationMetrics, | ||
IMixpanelTriggerResponse, | ||
IMetricStats, | ||
IChannelData, | ||
MixpanelInboxSeriesNameEnum, | ||
} from '../types/usage-insights.types'; | ||
|
||
@Injectable() | ||
export class MetricsCalculatorService { | ||
private roundToStartOfDay(date: string): string { | ||
return formatISO(startOfDay(new Date(date))); | ||
} | ||
|
||
calculateChange(current: number, previous: number): number { | ||
let change: number; | ||
|
||
if (previous === 0) { | ||
change = current > 0 ? 100 : 0; | ||
} else { | ||
change = Number(((current - previous) / previous) * 100); | ||
} | ||
|
||
Logger.debug(`Calculating change: current=${current}, previous=${previous}, change=${change}%`); | ||
|
||
return change; | ||
} | ||
|
||
getSeriesDateRange(currentSeries: IChannelData, previousSeries: IChannelData): IDateRange { | ||
const currentDates = Object.keys(currentSeries?.$overall || {}); | ||
const previousDates = Object.keys(previousSeries?.$overall || {}); | ||
|
||
if (!currentDates.length || !previousDates.length) { | ||
return { from_date: '', to_date: '' }; | ||
} | ||
|
||
return { | ||
from_date: this.roundToStartOfDay(previousDates[0]), | ||
to_date: this.roundToStartOfDay(currentDates[0]), | ||
}; | ||
} | ||
|
||
calculateInboxMetrics( | ||
inboxSeries?: Record<MixpanelInboxSeriesNameEnum, IChannelData>, | ||
inboxTimeComparison?: Record<MixpanelInboxSeriesNameEnum, IChannelData>, | ||
orgId?: string, | ||
dateRange?: IDateRange | ||
): IInboxMetrics { | ||
const emptyResponse = { current: 0, previous: 0, change: 0 }; | ||
|
||
if (!inboxSeries || !inboxTimeComparison || !orgId || !dateRange) { | ||
return { | ||
sessionInitialized: emptyResponse, | ||
updatePreferences: emptyResponse, | ||
markNotification: emptyResponse, | ||
updateAction: emptyResponse, | ||
}; | ||
} | ||
|
||
Logger.debug(`Calculating inbox metrics for organization`); | ||
const getMetricStats = ( | ||
currentSeriesData: IChannelData | undefined, | ||
previousSeriesData: IChannelData | undefined | ||
): IMetricStats => { | ||
if (!currentSeriesData || !previousSeriesData) { | ||
Logger.debug(`No series data available for ${orgId}`); | ||
|
||
return emptyResponse; | ||
} | ||
|
||
const currentOrgData = currentSeriesData[orgId]; | ||
const previousOrgData = previousSeriesData[orgId]; | ||
|
||
if (!currentOrgData || !previousOrgData) { | ||
Logger.debug(`No series data available for ${orgId}`); | ||
|
||
return emptyResponse; | ||
} | ||
|
||
const currentData = currentOrgData[this.roundToStartOfDay(dateRange.to_date)]; | ||
const previousData = previousOrgData[this.roundToStartOfDay(dateRange.from_date)]; | ||
|
||
if (!currentData || !previousData) { | ||
Logger.debug(`No data available for ${orgId}`); | ||
|
||
return emptyResponse; | ||
} | ||
|
||
const current = Number(currentData || 0); | ||
const previous = Number(previousData || 0); | ||
const change = this.calculateChange(current, previous); | ||
|
||
Logger.debug(`Metric stats for ${orgId}: current=${current}, previous=${previous}, change=${change}%`); | ||
|
||
return { current, previous, change }; | ||
}; | ||
|
||
return { | ||
sessionInitialized: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_SESSION_INITIALIZED], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_SESSION_INITIALIZED] | ||
), | ||
updatePreferences: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_PREFERENCES], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_PREFERENCES] | ||
), | ||
markNotification: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_MARK_NOTIFICATION], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_MARK_NOTIFICATION] | ||
), | ||
updateAction: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_ACTION], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_ACTION] | ||
), | ||
}; | ||
} | ||
|
||
calculateOverallInboxMetrics( | ||
orgId: string, | ||
inboxSeries: IMixpanelInboxResponse['series'], | ||
inboxTimeComparison: IMixpanelInboxResponse['time_comparison']['series'] | ||
): IInboxMetrics { | ||
Logger.debug('Calculating overall inbox metrics'); | ||
|
||
const getMetricStats = ( | ||
currentSeriesData: IChannelData | undefined, | ||
previousSeriesData: IChannelData | undefined | ||
): IMetricStats => { | ||
if (!currentSeriesData?.$overall || !previousSeriesData?.$overall) { | ||
return { current: 0, previous: 0, change: 0 }; | ||
} | ||
|
||
const current = Number(Object.values(currentSeriesData.$overall)[0] || 0); | ||
const previous = Number(Object.values(previousSeriesData.$overall)[0] || 0); | ||
const change = this.calculateChange(current, previous); | ||
|
||
return { current, previous, change }; | ||
}; | ||
|
||
return { | ||
sessionInitialized: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_SESSION_INITIALIZED][orgId], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_SESSION_INITIALIZED][orgId] | ||
), | ||
updatePreferences: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_PREFERENCES][orgId], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_PREFERENCES][orgId] | ||
), | ||
markNotification: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_MARK_NOTIFICATION][orgId], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_MARK_NOTIFICATION][orgId] | ||
), | ||
updateAction: getMetricStats( | ||
inboxSeries[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_ACTION][orgId], | ||
inboxTimeComparison[MixpanelInboxSeriesNameEnum.INBOX_UPDATE_ACTION][orgId] | ||
), | ||
}; | ||
} | ||
|
||
calculateEventTriggersMetrics( | ||
subscriberSeries: IChannelData, | ||
subscriberTimeComparison: IChannelData | ||
): IOrganizationMetrics['eventTriggers'] { | ||
const current = Number(Object.values(subscriberSeries?.$overall || {})[0] || 0); | ||
const previous = Number(Object.values(subscriberTimeComparison?.$overall || {})[0] || 0); | ||
const change = this.calculateChange(current, previous); | ||
|
||
return { current, previous, change }; | ||
} | ||
|
||
calculateChannelBreakdown( | ||
workflowSeries: IChannelData, | ||
workflowTimeComparison: IChannelData | ||
): IOrganizationMetrics['channelBreakdown'] { | ||
const channelBreakdown: IOrganizationMetrics['channelBreakdown'] = { | ||
[ChannelTypeEnum.EMAIL]: { current: 0, previous: 0, change: 0 }, | ||
[ChannelTypeEnum.SMS]: { current: 0, previous: 0, change: 0 }, | ||
[ChannelTypeEnum.PUSH]: { current: 0, previous: 0, change: 0 }, | ||
[ChannelTypeEnum.IN_APP]: { current: 0, previous: 0, change: 0 }, | ||
[ChannelTypeEnum.CHAT]: { current: 0, previous: 0, change: 0 }, | ||
}; | ||
|
||
const orgWorkflowData = workflowSeries; | ||
const orgWorkflowPreviousData = workflowTimeComparison; | ||
|
||
if (orgWorkflowData && orgWorkflowPreviousData) { | ||
Object.entries(orgWorkflowData).forEach(([channel, data]) => { | ||
if (channel !== '$overall') { | ||
const currentChannelData = Number(Object.values(data.$overall || {})[0] || 0); | ||
const previousChannelData = Number(Object.values(orgWorkflowPreviousData[channel]?.$overall || {})[0] || 0); | ||
const currentChannelChange = this.calculateChange(currentChannelData, previousChannelData); | ||
|
||
channelBreakdown[channel] = { | ||
current: currentChannelData, | ||
previous: previousChannelData, | ||
change: currentChannelChange, | ||
}; | ||
} | ||
}); | ||
} else { | ||
Logger.debug(`No workflow data available for organization`); | ||
} | ||
|
||
return channelBreakdown; | ||
} | ||
|
||
calculateWorkflowStats( | ||
triggerEventSeries: IChannelData, | ||
previousTriggerEventSeries: IChannelData | ||
): IMixpanelTriggerResponse['workflowStats']['workflows'] { | ||
Logger.debug('Calculating workflow statistics'); | ||
const workflowStats: IMixpanelTriggerResponse['workflowStats']['workflows'] = {}; | ||
|
||
const currentWorkflowsData = triggerEventSeries.undefined; | ||
const previousWorkflowsData = previousTriggerEventSeries.undefined; | ||
if (!currentWorkflowsData || !previousWorkflowsData) { | ||
Logger.debug(`No workflow data found for organization`); | ||
|
||
return workflowStats; | ||
} | ||
|
||
Object.entries(currentWorkflowsData) | ||
.filter(([name]) => name !== '$overall') | ||
.forEach(([name, data]) => { | ||
const current = Number(Object.values(data)[0] || 0); | ||
const previous = Number(Object.values(previousWorkflowsData[name] || {})[0] || 0); | ||
const change = this.calculateChange(current, previous); | ||
|
||
workflowStats[name] = { current, previous, change }; | ||
Logger.debug(`Workflow stats for ${name}: current=${current}, previous=${previous}, change=${change}%`); | ||
}); | ||
|
||
return workflowStats; | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a temporary controller protected allowed to be triggered by novu admins (protected by LD) to trigger individual insight emails during testing period