Compare commits

..

20 Commits

Author SHA1 Message Date
standardci
4d1e7ff2a5 chore(release): publish new version
- @standardnotes/analytics@2.12.23
 - @standardnotes/api-gateway@1.40.1
 - @standardnotes/auth-server@1.66.8
 - @standardnotes/domain-events-infra@1.9.55
 - @standardnotes/domain-events@2.104.0
 - @standardnotes/event-store@1.6.52
 - @standardnotes/files-server@1.8.51
 - @standardnotes/revisions-server@1.9.24
 - @standardnotes/scheduler-server@1.15.5
 - @standardnotes/syncing-server@1.24.5
 - @standardnotes/websockets-server@1.4.52
 - @standardnotes/workspace-server@1.18.3
2022-12-12 11:26:27 +00:00
Karol Sójko
7f18fcfc13 feat(domain-events): add event for email subscription unsubscribed 2022-12-12 12:24:31 +01:00
standardci
ff02ce0747 chore(release): publish new version
- @standardnotes/analytics@2.12.22
2022-12-12 10:31:56 +00:00
Karol Sójko
a6056600eb fix(analytics): report event publishing 2022-12-12 11:29:41 +01:00
standardci
24c94326d5 chore(release): publish new version
- @standardnotes/analytics@2.12.21
2022-12-12 09:51:01 +00:00
Karol Sójko
48c0cb5e62 fix(analytics): add debug logs for report 2022-12-12 10:49:11 +01:00
standardci
9968efe1b2 chore(release): publish new version
- @standardnotes/syncing-server@1.24.4
2022-12-12 08:49:03 +00:00
Karol Sójko
6368342149 fix(syncing-server): data integrity check on revisions fix 2022-12-12 09:46:35 +01:00
standardci
b5f73db210 chore(release): publish new version
- @standardnotes/api-gateway@1.40.0
2022-12-12 04:12:20 +00:00
Karol Sójko
22d6a02d04 feat(api-gateway): add unsubscribe from emails endpoint 2022-12-12 05:10:18 +01:00
standardci
4e0bcfcccf chore(release): publish new version
- @standardnotes/auth-server@1.66.7
2022-12-09 14:30:03 +00:00
Karol Sójko
104313c15d fix(auth): linter issue 2022-12-09 15:27:39 +01:00
standardci
814289af46 chore(release): publish new version
- @standardnotes/analytics@2.12.20
 - @standardnotes/api-gateway@1.39.24
 - @standardnotes/auth-server@1.66.6
 - @standardnotes/domain-events-infra@1.9.54
 - @standardnotes/domain-events@2.103.2
 - @standardnotes/event-store@1.6.51
 - @standardnotes/files-server@1.8.50
 - @standardnotes/revisions-server@1.9.23
 - @standardnotes/scheduler-server@1.15.4
 - @standardnotes/syncing-server@1.24.3
 - @standardnotes/websockets-server@1.4.51
 - @standardnotes/workspace-server@1.18.2
2022-12-09 14:10:16 +00:00
Karol Sójko
3096cd98d5 feat(analytics) replace daily analytics report generated event with email requested 2022-12-09 15:08:17 +01:00
standardci
45dfefbc7a chore(release): publish new version
- @standardnotes/analytics@2.12.19
 - @standardnotes/api-gateway@1.39.23
 - @standardnotes/auth-server@1.66.5
 - @standardnotes/domain-events-infra@1.9.53
 - @standardnotes/domain-events@2.103.1
 - @standardnotes/event-store@1.6.50
 - @standardnotes/files-server@1.8.49
 - @standardnotes/revisions-server@1.9.22
 - @standardnotes/scheduler-server@1.15.3
 - @standardnotes/syncing-server@1.24.2
 - @standardnotes/websockets-server@1.4.50
 - @standardnotes/workspace-server@1.18.1
2022-12-09 13:39:59 +00:00
Karol Sójko
20d92149a8 fix(domain-events): add additional styles option for sending email 2022-12-09 14:37:22 +01:00
standardci
9c01fffca5 chore(release): publish new version
- @standardnotes/analytics@2.12.18
 - @standardnotes/api-gateway@1.39.22
 - @standardnotes/auth-server@1.66.4
 - @standardnotes/domain-events-infra@1.9.52
 - @standardnotes/domain-events@2.103.0
 - @standardnotes/event-store@1.6.49
 - @standardnotes/files-server@1.8.48
 - @standardnotes/revisions-server@1.9.21
 - @standardnotes/scheduler-server@1.15.2
 - @standardnotes/syncing-server@1.24.1
 - @standardnotes/websockets-server@1.4.49
 - @standardnotes/workspace-server@1.18.0
2022-12-09 13:32:57 +00:00
Karol Sójko
61c1cfff4b feat(workspace): replace workspace invite created event with email requested 2022-12-09 14:30:58 +01:00
standardci
7e74261f62 chore(release): publish new version
- @standardnotes/analytics@2.12.17
 - @standardnotes/api-gateway@1.39.21
 - @standardnotes/auth-server@1.66.3
 - @standardnotes/domain-events-infra@1.9.51
 - @standardnotes/domain-events@2.102.0
 - @standardnotes/event-store@1.6.48
 - @standardnotes/files-server@1.8.47
 - @standardnotes/revisions-server@1.9.20
 - @standardnotes/scheduler-server@1.15.1
 - @standardnotes/syncing-server@1.24.0
 - @standardnotes/websockets-server@1.4.48
 - @standardnotes/workspace-server@1.17.47
2022-12-09 13:11:30 +00:00
Karol Sójko
32601f34f1 feat(syncing-server): replace email backup attachment created with email requested 2022-12-09 14:09:30 +01:00
68 changed files with 1555 additions and 407 deletions

1
.pnp.cjs generated
View File

@@ -3324,6 +3324,7 @@ const RAW_RUNTIME_STATE =
["@sentry/node", "npm:7.19.0"],\
["@standardnotes/api", "npm:1.19.0"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-core", "workspace:packages/domain-core"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
["@standardnotes/models", "npm:1.28.0"],\

View File

@@ -3,6 +3,38 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.12.23](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.22...@standardnotes/analytics@2.12.23) (2022-12-12)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.22](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.21...@standardnotes/analytics@2.12.22) (2022-12-12)
### Bug Fixes
* **analytics:** report event publishing ([a605660](https://github.com/standardnotes/server/commit/a6056600eb96bf175189ad6d62870c9d736f331b))
## [2.12.21](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.20...@standardnotes/analytics@2.12.21) (2022-12-12)
### Bug Fixes
* **analytics:** add debug logs for report ([48c0cb5](https://github.com/standardnotes/server/commit/48c0cb5e62dc8af930de191deaa1eb3ff6c5a29f))
## [2.12.20](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.19...@standardnotes/analytics@2.12.20) (2022-12-09)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.19](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.18...@standardnotes/analytics@2.12.19) (2022-12-09)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.18](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.17...@standardnotes/analytics@2.12.18) (2022-12-09)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.17](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.16...@standardnotes/analytics@2.12.17) (2022-12-09)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.16](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.15...@standardnotes/analytics@2.12.16) (2022-12-09)
**Note:** Version bump only for package @standardnotes/analytics

View File

@@ -4,6 +4,7 @@ import 'newrelic'
import { Logger } from 'winston'
import { EmailLevel } from '@standardnotes/domain-core'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { AnalyticsActivity } from '../src/Domain/Analytics/AnalyticsActivity'
import { Period } from '../src/Domain/Time/Period'
@@ -16,6 +17,8 @@ import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
import { CalculateMonthlyRecurringRevenue } from '../src/Domain/UseCase/CalculateMonthlyRecurringRevenue/CalculateMonthlyRecurringRevenue'
import { getBody, getSubject } from '../src/Domain/Email/DailyAnalyticsReport'
import { TimerInterface } from '@standardnotes/time'
const requestReport = async (
analyticsStore: AnalyticsStoreInterface,
@@ -24,6 +27,8 @@ const requestReport = async (
domainEventPublisher: DomainEventPublisherInterface,
periodKeyGenerator: PeriodKeyGeneratorInterface,
calculateMonthlyRecurringRevenue: CalculateMonthlyRecurringRevenue,
timer: TimerInterface,
adminEmails: string[],
): Promise<void> => {
await calculateMonthlyRecurringRevenue.execute({})
@@ -213,18 +218,29 @@ const requestReport = async (
})
}
const event = domainEventFactory.createDailyAnalyticsReportGeneratedEvent({
activityStatistics: yesterdayActivityStatistics,
activityStatisticsOverTime: analyticsOverTime,
statisticsOverTime,
statisticMeasures,
churn: {
periodKeys: monthlyPeriodKeys,
values: churnRates,
},
})
await domainEventPublisher.publish(event)
for (const adminEmail of adminEmails) {
await domainEventPublisher.publish(
domainEventFactory.createEmailRequestedEvent({
messageIdentifier: 'VERSION_ADOPTION_REPORT',
subject: getSubject(),
body: getBody(
{
activityStatistics: yesterdayActivityStatistics,
activityStatisticsOverTime: analyticsOverTime,
statisticsOverTime,
statisticMeasures,
churn: {
periodKeys: monthlyPeriodKeys,
values: churnRates,
},
},
timer,
),
level: EmailLevel.LEVELS.System,
userEmail: adminEmail,
}),
)
}
}
const container = new ContainerConfigLoader()
@@ -241,9 +257,13 @@ void container.load().then((container) => {
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.DomainEventFactory)
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.DomainEventPublisher)
const periodKeyGenerator: PeriodKeyGeneratorInterface = container.get(TYPES.PeriodKeyGenerator)
const timer: TimerInterface = container.get(TYPES.Timer)
const calculateMonthlyRecurringRevenue: CalculateMonthlyRecurringRevenue = container.get(
TYPES.CalculateMonthlyRecurringRevenue,
)
const adminEmails = container.get(TYPES.ADMIN_EMAILS) as string[]
logger.info('Sending report to following admins: %O', adminEmails)
Promise.resolve(
requestReport(
@@ -253,6 +273,8 @@ void container.load().then((container) => {
domainEventPublisher,
periodKeyGenerator,
calculateMonthlyRecurringRevenue,
timer,
adminEmails,
),
)
.then(() => {

View File

@@ -5,17 +5,17 @@ COMMAND=$1 && shift 1
case "$COMMAND" in
'start-worker' )
echo "Starting Worker..."
echo "[Docker] Starting Worker..."
yarn workspace @standardnotes/analytics worker
;;
'report' )
echo "Starting Usage Report Generation..."
echo "[Docker] Starting Usage Report Generation..."
yarn workspace @standardnotes/analytics report
;;
* )
echo "Unknown command"
echo "[Docker] Unknown command"
;;
esac

View File

@@ -7,5 +7,5 @@ module.exports = {
transform: {
...tsjPreset.transform,
},
coveragePathIgnorePatterns: ['/Infra/'],
coveragePathIgnorePatterns: ['/Infra/', '/Domain/Email/'],
}

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/analytics",
"version": "2.12.16",
"version": "2.12.23",
"engines": {
"node": ">=18.0.0 <19.0.0"
},
@@ -40,7 +40,7 @@
"@newrelic/winston-enricher": "^4.0.0",
"@sentry/node": "^7.19.0",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-core": "workspace:*",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/time": "workspace:*",

View File

@@ -130,6 +130,7 @@ export class ContainerConfigLoader {
container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL', true))
container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL'))
container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
container.bind(TYPES.ADMIN_EMAILS).toConstantValue(env.get('ADMIN_EMAILS').split(','))
// Repositories
container

View File

@@ -11,6 +11,7 @@ const TYPES = {
SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'),
REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'),
NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'),
ADMIN_EMAILS: Symbol.for('ADMIN_EMAILS'),
// Repositories
AnalyticsEntityRepository: Symbol.for('AnalyticsEntityRepository'),
RevenueModificationRepository: Symbol.for('RevenueModificationRepository'),

View File

@@ -0,0 +1,11 @@
import { TimerInterface } from '@standardnotes/time'
import { html } from './daily-analytics-report.html'
export function getSubject(): string {
return `Daily analytics report ${new Date().toLocaleDateString('en-US')}`
}
export function getBody(data: unknown, timer: TimerInterface): string {
return html(data, timer)
}

View File

@@ -0,0 +1,966 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TimerInterface } from '@standardnotes/time'
import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { StatisticsMeasure } from '../Statistics/StatisticsMeasure'
import { Period } from '../Time/Period'
const getChartUrls = (
data: any,
): {
subscriptions: string
users: string
quarterlyPerformance: string
churn: string
mrr: string
mrrMonthly: string
} => {
const subscriptionPurchasingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionPurchased && a.period === Period.Last30Days,
)
const subscriptionRenewingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionRenewed && a.period === Period.Last30Days,
)
const subscriptionRefundingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionRefunded && a.period === Period.Last30Days,
)
const subscriptionCancelledOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionCancelled && a.period === Period.Last30Days,
)
const subscriptionReactivatedOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionReactivated && a.period === Period.Last30Days,
)
const subscriptionsLinerOverTimeConfig = {
type: 'line',
data: {
labels: subscriptionPurchasingOverTime?.counts.map((count: { periodKey: any }) => count.periodKey),
datasets: [
{
label: 'Subscription Purchases',
backgroundColor: 'rgb(25, 255, 140)',
borderColor: 'rgb(25, 255, 140)',
data: subscriptionPurchasingOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'Subscription Renewals',
backgroundColor: 'rgb(54, 162, 235)',
borderColor: 'rgb(54, 162, 235)',
data: subscriptionRenewingOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'Subscription Refunds',
backgroundColor: 'rgb(255, 221, 51)',
borderColor: 'rgb(255, 221, 51)',
data: subscriptionRefundingOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'Subscription Cancels',
backgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
data: subscriptionCancelledOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'Subscription Reactivations',
backgroundColor: 'rgb(221, 51, 255)',
borderColor: 'rgb(221, 51, 255)',
data: subscriptionReactivatedOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
],
},
}
const userRegistrationOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.Register && a.period === Period.Last30Days,
)
const userDeletionOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.DeleteAccount && a.period === Period.Last30Days,
)
const usersLinerOverTimeConfig = {
type: 'line',
data: {
labels: userRegistrationOverTime?.counts.map((count: { periodKey: any }) => count.periodKey),
datasets: [
{
label: 'User Registrations',
backgroundColor: 'rgb(25, 255, 140)',
borderColor: 'rgb(25, 255, 140)',
data: userRegistrationOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'Account Deletions',
backgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
data: userDeletionOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
],
},
}
const quarters = [Period.Q1ThisYear, Period.Q2ThisYear, Period.Q3ThisYear, Period.Q4ThisYear]
const quarterlyUserRegistrations = []
const quarterlySubscriptionPurchases = []
const quarterlySubscriptionRenewals = []
for (const quarter of quarters) {
const registrations =
data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.Register && a.period === quarter,
)?.totalCount ?? 0
const purchases =
data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionPurchased && a.period === quarter,
)?.totalCount ?? 0
const renewals =
data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionRenewed && a.period === quarter,
)?.totalCount ?? 0
quarterlyUserRegistrations.push(registrations)
quarterlySubscriptionPurchases.push(purchases)
quarterlySubscriptionRenewals.push(renewals)
}
const quarterlyConfig = {
type: 'bar',
data: {
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
datasets: [
{
label: 'User Registrations',
backgroundColor: 'rgba(255, 99, 132, 0.5)',
borderColor: 'rgb(255, 99, 132)',
borderWidth: 1,
data: quarterlyUserRegistrations,
},
{
label: 'Subscription Purchases',
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgb(54, 162, 235)',
borderWidth: 1,
data: quarterlySubscriptionPurchases,
},
{
label: 'Subscription Renewals',
backgroundColor: 'rgb(25, 255, 140, 0.5)',
borderColor: 'rgb(25, 255, 140)',
borderWidth: 1,
data: quarterlySubscriptionRenewals,
},
],
},
options: {
title: {
display: true,
text: 'Quarterly Performance',
},
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#666',
font: {
weight: 'normal',
},
},
},
},
}
const monthlyChurnRates = data.churn.values.map((value: { rate: number }) => +value.rate.toFixed(2))
const churnConfig = {
type: 'bar',
data: {
labels: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
datasets: [
{
label: 'Churn Percent',
backgroundColor: 'rgba(255, 99, 132, 0.5)',
borderColor: 'rgb(255, 99, 132)',
borderWidth: 1,
data: monthlyChurnRates,
},
],
},
options: {
title: {
display: true,
text: 'Monthly Churn Rate',
},
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#666',
font: {
weight: 'normal',
},
},
},
},
}
const mrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'mrr' && a.period === 27,
)
const monthlyPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'monthly-plans-mrr' && a.period === 27,
)
const annualPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'annual-plans-mrr' && a.period === 27,
)
const fiveYearPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'five-year-plans-mrr' && a.period === 27,
)
const proPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'pro-plans-mrr' && a.period === 27,
)
const plusPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'plus-plans-mrr' && a.period === 27,
)
const mrrOverTimeConfig = {
type: 'line',
data: {
labels: mrrOverTime?.counts.map((count: { periodKey: any }) => count.periodKey),
datasets: [
{
label: 'MRR',
backgroundColor: 'rgb(25, 255, 140)',
borderColor: 'rgb(25, 255, 140)',
data: mrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'MRR - Monthly Plans',
backgroundColor: 'rgb(54, 162, 235)',
borderColor: 'rgb(54, 162, 235)',
data: monthlyPlansMrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'MRR - Annual Plans',
backgroundColor: 'rgb(255, 221, 51)',
borderColor: 'rgb(255, 221, 51)',
data: annualPlansMrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'MRR - Five Year Plans',
backgroundColor: 'rgb(255, 120, 120)',
borderColor: 'rgb(255, 120, 120)',
data: fiveYearPlansMrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'MRR - PRO Plans',
backgroundColor: 'rgb(255, 99, 132)',
borderColor: 'rgb(255, 99, 132)',
data: proPlansMrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
{
label: 'MRR - PLUS Plans',
backgroundColor: 'rgb(221, 51, 255)',
borderColor: 'rgb(221, 51, 255)',
data: plusPlansMrrOverTime?.counts.map((count: { totalCount: any }) => count.totalCount),
fill: false,
pointRadius: 2,
},
],
},
}
const mrrMonthlyOverTime = data.statisticsOverTime
.find((a: { name: string; period: Period }) => a.name === 'mrr' && a.period === Period.ThisYear)
?.counts.map((count: { totalCount: number }) => +count.totalCount.toFixed(2))
const mrrMonthlyConfig = {
type: 'bar',
data: {
labels: [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
datasets: [
{
label: 'MRR',
backgroundColor: 'rgba(25, 255, 140, 0.5)',
borderColor: 'rgb(25, 255, 140)',
borderWidth: 1,
data: mrrMonthlyOverTime,
},
],
},
options: {
title: {
display: true,
text: 'Monthly MRR',
},
plugins: {
datalabels: {
anchor: 'center',
align: 'center',
color: '#666',
font: {
weight: 'normal',
},
},
},
},
}
return {
subscriptions: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(
JSON.stringify(subscriptionsLinerOverTimeConfig),
)}`,
users: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(JSON.stringify(usersLinerOverTimeConfig))}`,
quarterlyPerformance: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(
JSON.stringify(quarterlyConfig),
)}`,
churn: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(JSON.stringify(churnConfig))}`,
mrr: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(JSON.stringify(mrrOverTimeConfig))}`,
mrrMonthly: `https://quickchart.io/chart?width=800&c=${encodeURIComponent(JSON.stringify(mrrMonthlyConfig))}`,
}
}
export const html = (data: any, timer: TimerInterface) => {
const chartUrls = getChartUrls(event)
const successfullPaymentsActivity = data.activityStatistics.find(
(a: { name: AnalyticsActivity }) => a.name === AnalyticsActivity.PaymentSuccess && Period.Yesterday,
)
const failedPaymentsActivity = data.activityStatistics.find(
(a: { name: AnalyticsActivity }) => a.name === AnalyticsActivity.PaymentFailed && Period.Yesterday,
)
const limitedDiscountPurchasedActivity = data.activityStatistics.find(
(a: { name: AnalyticsActivity }) => a.name === AnalyticsActivity.LimitedDiscountOfferPurchased && Period.Yesterday,
)
const subscriptionPurchasingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionPurchased && a.period === Period.Last30Days,
)
const subscriptionRenewingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionRenewed && a.period === Period.Last30Days,
)
const subscriptionRefundingOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionRefunded && a.period === Period.Last30Days,
)
const subscriptionCancelledOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionCancelled && a.period === Period.Last30Days,
)
const subscriptionReactivatedOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.SubscriptionReactivated && a.period === Period.Last30Days,
)
const userRegistrationOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.Register && a.period === Period.Last30Days,
)
const userDeletionOverTime = data.activityStatisticsOverTime.find(
(a: { name: AnalyticsActivity; period: Period }) =>
a.name === AnalyticsActivity.DeleteAccount && a.period === Period.Last30Days,
)
const incomeMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.Income && a.period === Period.Yesterday,
)
const refundMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.Refunds && a.period === Period.Yesterday,
)
const incomeYesterday = incomeMeasureYesterday?.totalValue ?? 0
const refundsYesterday = refundMeasureYesterday?.totalValue ?? 0
const revenueYesterday = incomeYesterday - refundsYesterday
const subscriptionLengthMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.SubscriptionLength && a.period === Period.Yesterday,
)
const subscriptionLengthDurationYesterday = timer.convertMicrosecondsToTimeStructure(
Math.floor(subscriptionLengthMeasureYesterday?.average ?? 0),
)
const subscriptionRemainingTimePercentageMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RemainingSubscriptionTimePercentage && a.period === Period.Yesterday,
)
const subscriptionRemainingTimePercentageYesterday = Math.floor(
subscriptionRemainingTimePercentageMeasureYesterday?.average ?? 0,
)
const registrationLengthMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RegistrationLength && a.period === Period.Yesterday,
)
const registrationLengthDurationYesterday = timer.convertMicrosecondsToTimeStructure(
Math.floor(registrationLengthMeasureYesterday?.average ?? 0),
)
const registrationToSubscriptionMeasureYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RegistrationToSubscriptionTime && a.period === Period.Yesterday,
)
const registrationToSubscriptionDurationYesterday = timer.convertMicrosecondsToTimeStructure(
Math.floor(registrationToSubscriptionMeasureYesterday?.average ?? 0),
)
const incomeMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.Income && a.period === Period.ThisMonth,
)
const refundMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.Refunds && a.period === Period.ThisMonth,
)
const incomeThisMonth = incomeMeasureThisMonth?.totalValue ?? 0
const refundsThisMonth = refundMeasureThisMonth?.totalValue ?? 0
const revenueThisMonth = incomeThisMonth - refundsThisMonth
const subscriptionLengthMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.SubscriptionLength && a.period === Period.ThisMonth,
)
const subscriptionLengthDurationThisMonth = timer.convertMicrosecondsToTimeStructure(
Math.floor(subscriptionLengthMeasureThisMonth?.average ?? 0),
)
const subscriptionRemainingTimePercentageMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RemainingSubscriptionTimePercentage && a.period === Period.ThisMonth,
)
const subscriptionRemainingTimePercentageThisMonth = Math.floor(
subscriptionRemainingTimePercentageMeasureThisMonth?.average ?? 0,
)
const registrationLengthMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RegistrationLength && a.period === Period.ThisMonth,
)
const registrationLengthDurationThisMonth = timer.convertMicrosecondsToTimeStructure(
Math.floor(registrationLengthMeasureThisMonth?.average ?? 0),
)
const registrationToSubscriptionMeasureThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.RegistrationToSubscriptionTime && a.period === Period.ThisMonth,
)
const registrationToSubscriptionDurationThisMonth = timer.convertMicrosecondsToTimeStructure(
Math.floor(registrationToSubscriptionMeasureThisMonth?.average ?? 0),
)
const plusSubscriptionsInitialAnnualPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionInitialAnnualPaymentsIncome && a.period === Period.Yesterday,
)
const plusSubscriptionsInitialMonthlyPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionInitialMonthlyPaymentsIncome && a.period === Period.Yesterday,
)
const plusSubscriptionsRenewingAnnualPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionRenewingAnnualPaymentsIncome && a.period === Period.Yesterday,
)
const plusSubscriptionsRenewingMonthlyPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionRenewingMonthlyPaymentsIncome && a.period === Period.Yesterday,
)
const proSubscriptionsInitialAnnualPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionInitialAnnualPaymentsIncome && a.period === Period.Yesterday,
)
const proSubscriptionsInitialMonthlyPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionInitialMonthlyPaymentsIncome && a.period === Period.Yesterday,
)
const proSubscriptionsRenewingAnnualPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionRenewingAnnualPaymentsIncome && a.period === Period.Yesterday,
)
const proSubscriptionsRenewingMonthlyPaymentsYesterday = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionRenewingMonthlyPaymentsIncome && a.period === Period.Yesterday,
)
const plusSubscriptionsInitialAnnualPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionInitialAnnualPaymentsIncome && a.period === Period.ThisMonth,
)
const plusSubscriptionsInitialMonthlyPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionInitialMonthlyPaymentsIncome && a.period === Period.ThisMonth,
)
const plusSubscriptionsRenewingAnnualPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionRenewingAnnualPaymentsIncome && a.period === Period.ThisMonth,
)
const plusSubscriptionsRenewingMonthlyPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.PlusSubscriptionRenewingMonthlyPaymentsIncome && a.period === Period.ThisMonth,
)
const proSubscriptionsInitialAnnualPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionInitialAnnualPaymentsIncome && a.period === Period.ThisMonth,
)
const proSubscriptionsInitialMonthlyPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionInitialMonthlyPaymentsIncome && a.period === Period.ThisMonth,
)
const proSubscriptionsRenewingAnnualPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionRenewingAnnualPaymentsIncome && a.period === Period.ThisMonth,
)
const proSubscriptionsRenewingMonthlyPaymentsThisMonth = data.statisticMeasures.find(
(a: { name: StatisticsMeasure; period: Period }) =>
a.name === StatisticsMeasure.ProSubscriptionRenewingMonthlyPaymentsIncome && a.period === Period.ThisMonth,
)
const mrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'mrr' && a.period === 27,
)
const monthlyPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'monthly-plans-mrr' && a.period === 27,
)
const annualPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'annual-plans-mrr' && a.period === 27,
)
const fiveYearPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'five-year-plans-mrr' && a.period === 27,
)
const proPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'pro-plans-mrr' && a.period === 27,
)
const plusPlansMrrOverTime = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === 'plus-plans-mrr' && a.period === 27,
)
const today = new Date()
const thisMonthPeriodKey = `${today.getFullYear().toString()}-${(today.getMonth() + 1).toString()}`
const thisMonthChurn = data.churn.values.find(
(value: { periodKey: string }) => value.periodKey === thisMonthPeriodKey,
)
return ` <div>
<p>Hello,</p>
<p>
<strong>Here are some statistics from yesterday:</strong>
</p>
<ul>
<li>
<b>Payments</b>
<ul>
<li>
Revenue: <b>$${revenueYesterday.toLocaleString('en-US')}</b> (Income: $
${incomeYesterday.toLocaleString('en-US')}, Refunds: $${refundsYesterday.toLocaleString('en-US')})
</li>
<li>
Successfull payments: <b>${successfullPaymentsActivity?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Failed payments: <b>${failedPaymentsActivity?.totalCount.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
<li>
<b>MRR Breakdown</b>
<ul>
<li>
<b>Total:</b> $${mrrOverTime?.counts[mrrOverTime?.counts.length - 1].totalCount.toLocaleString('en-US')}
</li>
<li>
<b>By Subscription Type:</b>
<ul>
<li>
<b>PLUS:</b> $
${plusPlansMrrOverTime?.counts[plusPlansMrrOverTime?.counts.length - 1].totalCount.toLocaleString('en-US')}
</li>
<li>
<b>PRO:</b> $
${proPlansMrrOverTime?.counts[proPlansMrrOverTime?.counts.length - 1].totalCount.toLocaleString('en-US')}
</li>
</ul>
</li>
<li>
<b>By Billing Frequency:</b>
<ul>
<li>
<b>Monthly:</b> $
${monthlyPlansMrrOverTime?.counts[monthlyPlansMrrOverTime?.counts.length - 1].totalCount.toLocaleString(
'en-US',
)}
</li>
<li>
<b>Annual:</b> $
${annualPlansMrrOverTime?.counts[annualPlansMrrOverTime?.counts.length - 1].totalCount.toLocaleString(
'en-US',
)}
</li>
<li>
<b>5-year:</b> $
${fiveYearPlansMrrOverTime?.counts[fiveYearPlansMrrOverTime?.counts.length - 1].totalCount.toLocaleString(
'en-US',
)}
</li>
</ul>
</li>
</ul>
</li>
<li>
<b>Income Breakdown</b>
<ul>
<li>
<b>Plus Subscription:</b>
<ul>
<li>
<b>${plusSubscriptionsInitialMonthlyPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>monhtly</u> plan, totaling${' '}
<b>$${plusSubscriptionsInitialMonthlyPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsInitialAnnualPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${plusSubscriptionsInitialAnnualPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsRenewingMonthlyPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>monthly</u> plan, totaling${' '}
<b>$${plusSubscriptionsRenewingMonthlyPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsRenewingAnnualPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${plusSubscriptionsRenewingAnnualPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
<li>
<b>Pro Subscription:</b>
<ul>
<li>
<b>${proSubscriptionsInitialMonthlyPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>monhtly</u> plan, totaling${' '}
<b>$${proSubscriptionsInitialMonthlyPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsInitialAnnualPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${proSubscriptionsInitialAnnualPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsRenewingMonthlyPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>monthly</u> plan, totaling${' '}
<b>$${proSubscriptionsRenewingMonthlyPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsRenewingAnnualPaymentsYesterday?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${proSubscriptionsRenewingAnnualPaymentsYesterday?.totalValue.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
</ul>
</li>
<li>
<b>Users</b>
<ul>
<li>
Number of users registered:${' '}
<b>
${userRegistrationOverTime?.counts[userRegistrationOverTime?.counts.length - 1]?.totalCount.toLocaleString(
'en-US',
)}
</b>
</li>
<li>
Number of users unregistered:${' '}
<b>
${userDeletionOverTime?.counts[userDeletionOverTime?.counts.length - 1]?.totalCount.toLocaleString('en-US')}
</b>${' '}
(average account duration: ${registrationLengthDurationYesterday.days} days${' '}
${registrationLengthDurationYesterday.hours} hours ${registrationLengthDurationYesterday.minutes} minutes)
</li>
</ul>
</li>
<li>
<b>Subscriptions</b>
<ul>
<li>
Number of subscriptions purchased:${' '}
<b>
${subscriptionPurchasingOverTime?.counts[
subscriptionPurchasingOverTime?.counts.length - 1
]?.totalCount.toLocaleString('en-US')}
</b>${' '}
(includes <b>${limitedDiscountPurchasedActivity?.totalCount.toLocaleString('en-US')}</b> limited time
offer purchases)
</li>
<li>
Number of subscriptions renewed:${' '}
<b>
${subscriptionRenewingOverTime?.counts[
subscriptionRenewingOverTime?.counts.length - 1
]?.totalCount.toLocaleString('en-US')}
</b>
</li>
<li>
Number of subscriptions refunded:${' '}
<b>
${subscriptionRefundingOverTime?.counts[
subscriptionRefundingOverTime?.counts.length - 1
]?.totalCount.toLocaleString('en-US')}
</b>
</li>
<li>
Number of subscriptions cancelled:${' '}
<b>
${subscriptionCancelledOverTime?.counts[
subscriptionCancelledOverTime?.counts.length - 1
]?.totalCount.toLocaleString('en-US')}
</b>${' '}
(average subscription duration: ${subscriptionLengthDurationYesterday.days} days${' '}
${subscriptionLengthDurationYesterday.hours} hours ${subscriptionLengthDurationYesterday.minutes} minutes,
average remaining subscription percentage: ${subscriptionRemainingTimePercentageYesterday}%)
</li>
<li>
Number of subscriptions reactivated:${' '}
<b>
${subscriptionReactivatedOverTime?.counts[
subscriptionReactivatedOverTime?.counts.length - 1
]?.totalCount.toLocaleString('en-US')}
</b>
</li>
<li>
Average time from registration to subscription purchase:${' '}
<b>
${registrationToSubscriptionDurationYesterday.days} days${' '}
${registrationToSubscriptionDurationYesterday.hours} hours${' '}
${registrationToSubscriptionDurationYesterday.minutes} minutes
</b>
</li>
</ul>
</li>
</ul>
<p>
<strong>Here are some statistics from last 30 days:</strong>
</p>
<ul>
<li>
<b>Payments (This Month)</b>
<ul>
<li>
Revenue: <b>$${revenueThisMonth.toLocaleString('en-US')}</b>
</li>
<li>
Income: <b>$${incomeThisMonth.toLocaleString('en-US')}</b>
</li>
<li>
Refunds: <b>$${refundsThisMonth.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
<li>
<b>Income Breakdown (This Month)</b>
<ul>
<li>
<b>Plus Subscription:</b>
<ul>
<li>
<b>${plusSubscriptionsInitialMonthlyPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>monhtly</u> plan, totaling${' '}
<b>$${plusSubscriptionsInitialMonthlyPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsInitialAnnualPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${plusSubscriptionsInitialAnnualPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsRenewingMonthlyPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>monthly</u> plan, totaling${' '}
<b>$${plusSubscriptionsRenewingMonthlyPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${plusSubscriptionsRenewingAnnualPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${plusSubscriptionsRenewingAnnualPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
<li>
<b>Pro Subscription:</b>
<ul>
<li>
<b>${proSubscriptionsInitialMonthlyPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>monhtly</u> plan, totaling${' '}
<b>$${proSubscriptionsInitialMonthlyPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsInitialAnnualPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>initial</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${proSubscriptionsInitialAnnualPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsRenewingMonthlyPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>monthly</u> plan, totaling${' '}
<b>$${proSubscriptionsRenewingMonthlyPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
<li>
<b>${proSubscriptionsRenewingAnnualPaymentsThisMonth?.increments.toLocaleString('en-US')}</b>${' '}
<i>renewing</i> payments on <u>annual</u> plan, totaling${' '}
<b>$${proSubscriptionsRenewingAnnualPaymentsThisMonth?.totalValue.toLocaleString('en-US')}</b>
</li>
</ul>
</li>
</ul>
</li>
<li>
<b>Users</b>
<ul>
<li>
Number of users registered: <b>${userRegistrationOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Number of users unregistered: <b>${userDeletionOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Average account duration this month:${' '}
<b>
${registrationLengthDurationThisMonth.days} days ${registrationLengthDurationThisMonth.hours} hours${' '}
${registrationLengthDurationThisMonth.minutes} minutes
</b>
</li>
</ul>
</li>
<li>
<b>Subscriptions</b>
<ul>
<li>
Number of subscriptions purchased:${' '}
<b>${subscriptionPurchasingOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Number of subscriptions renewed:${' '}
<b>${subscriptionRenewingOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Number of subscriptions refunded:${' '}
<b>${subscriptionRefundingOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Number of subscriptions cancelled:${' '}
<b>${subscriptionCancelledOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Number of subscriptions reactivated:${' '}
<b>${subscriptionReactivatedOverTime?.totalCount.toLocaleString('en-US')}</b>
</li>
<li>
Average subscription duration this month:${' '}
<b>
${subscriptionLengthDurationThisMonth.days} days ${subscriptionLengthDurationThisMonth.hours} hours${' '}
${subscriptionLengthDurationThisMonth.minutes} minutes
</b>
</li>
<li>
Average subscription remaining percentage this month:${' '}
<b>${subscriptionRemainingTimePercentageThisMonth}%</b>
</li>
<li>
Average time from registration to subscription purchase this month:${' '}
<b>
${registrationToSubscriptionDurationThisMonth.days} days${' '}
${registrationToSubscriptionDurationThisMonth.hours} hours${' '}
${registrationToSubscriptionDurationThisMonth.minutes} minutes
</b>
</li>
</ul>
</li>
</ul>
<p>
<strong>Here is the MRR chart over 30 days:</strong>
</p>
<img src=${chartUrls.mrr}></img>
<p>
<strong>Here is the MRR Monthly chart this year:</strong>
</p>
<img src=${chartUrls.mrrMonthly}></img>
<p>
<strong>Here is the subscription chart over 30 days:</strong>
</p>
<img src=${chartUrls.subscriptions}></img>
<p>
<strong>Here is the users chart over 30 days:</strong>
</p>
<img src=${chartUrls.users}></img>
<p>
<strong>Here is the monthly churn rate percentage:</strong>
</p>
<p>✅ GREAT! Up to 7% 🔶 OKAY: 8-10% 🩸 BAD: 11 -15 % 🚨 TERRIBLE! 16-20%</p>
<p>Churn is calculated by the following formula:</p>
<p>
( Existing Customers Churn [${thisMonthChurn?.existingCustomersChurn}] + New Customers Churn [
${thisMonthChurn?.newCustomersChurn}] ) * 100 / Average Customers Count This Month [
${thisMonthChurn?.averageCustomersCount}]
</p>
<img src=${chartUrls.churn}></img>
<p>
<strong>Here is quarterly performance chart:</strong>
</p>
<img src=${chartUrls.quarterlyPerformance}></img>
<p>Thanks,SN</p>
</div>`
}

View File

@@ -1,6 +1,6 @@
/* istanbul ignore file */
import { DomainEventService, DailyAnalyticsReportGeneratedEvent } from '@standardnotes/domain-events'
import { DomainEventService, EmailRequestedEvent } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
@@ -9,55 +9,20 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
@injectable()
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
createDailyAnalyticsReportGeneratedEvent(dto: {
activityStatistics: Array<{
name: string
retention: number
totalCount: number
}>
statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}>
activityStatisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}>
statisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
}>
churn: {
periodKeys: Array<string>
values: Array<{
rate: number
periodKey: string
averageCustomersCount: number
existingCustomersChurn: number
newCustomersChurn: number
}>
}
}): DailyAnalyticsReportGeneratedEvent {
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent {
return {
type: 'DAILY_ANALYTICS_REPORT_GENERATED',
type: 'EMAIL_REQUESTED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: '',
userIdentifierType: 'uuid',
userIdentifier: dto.userEmail,
userIdentifierType: 'email',
},
origin: DomainEventService.Analytics,
},

View File

@@ -1,45 +1,11 @@
import { DailyAnalyticsReportGeneratedEvent } from '@standardnotes/domain-events'
import { EmailRequestedEvent } from '@standardnotes/domain-events'
export interface DomainEventFactoryInterface {
createDailyAnalyticsReportGeneratedEvent(dto: {
activityStatistics: Array<{
name: string
retention: number
totalCount: number
}>
statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}>
activityStatisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}>
statisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
}>
churn: {
periodKeys: Array<string>
values: Array<{
rate: number
periodKey: string
averageCustomersCount: number
existingCustomersChurn: number
newCustomersChurn: number
}>
}
}): DailyAnalyticsReportGeneratedEvent
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent
}

View File

@@ -11,6 +11,7 @@ WEB_SOCKET_SERVER_URL=http://websockets:3000
PAYMENTS_SERVER_URL=http://payments:3000
FILES_SERVER_URL=http://files:3000
REVISIONS_SERVER_URL=http://revisions:3000
EMAIL_SERVER_URL=http://email:3000
HTTP_CALL_TIMEOUT=60000

View File

@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.40.0...@standardnotes/api-gateway@1.40.1) (2022-12-12)
**Note:** Version bump only for package @standardnotes/api-gateway
# [1.40.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.24...@standardnotes/api-gateway@1.40.0) (2022-12-12)
### Features
* **api-gateway:** add unsubscribe from emails endpoint ([22d6a02](https://github.com/standardnotes/api-gateway/commit/22d6a02d049ba3bde890c7def91e19f013ba3e22))
## [1.39.24](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.23...@standardnotes/api-gateway@1.39.24) (2022-12-09)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.39.23](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.22...@standardnotes/api-gateway@1.39.23) (2022-12-09)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.39.22](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.21...@standardnotes/api-gateway@1.39.22) (2022-12-09)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.39.21](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.20...@standardnotes/api-gateway@1.39.21) (2022-12-09)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.39.20](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.39.19...@standardnotes/api-gateway@1.39.20) (2022-12-09)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.39.20",
"version": "1.40.1",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -55,6 +55,7 @@ export class ContainerConfigLoader {
container.bind(TYPES.SYNCING_SERVER_JS_URL).toConstantValue(env.get('SYNCING_SERVER_JS_URL'))
container.bind(TYPES.AUTH_SERVER_URL).toConstantValue(env.get('AUTH_SERVER_URL'))
container.bind(TYPES.REVISIONS_SERVER_URL).toConstantValue(env.get('REVISIONS_SERVER_URL', true))
container.bind(TYPES.EMAIL_SERVER_URL).toConstantValue(env.get('EMAIL_SERVER_URL', true))
container.bind(TYPES.PAYMENTS_SERVER_URL).toConstantValue(env.get('PAYMENTS_SERVER_URL', true))
container.bind(TYPES.FILES_SERVER_URL).toConstantValue(env.get('FILES_SERVER_URL', true))
container.bind(TYPES.AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET'))

View File

@@ -8,6 +8,7 @@ const TYPES = {
PAYMENTS_SERVER_URL: Symbol.for('PAYMENTS_SERVER_URL'),
FILES_SERVER_URL: Symbol.for('FILES_SERVER_URL'),
REVISIONS_SERVER_URL: Symbol.for('REVISIONS_SERVER_URL'),
EMAIL_SERVER_URL: Symbol.for('EMAIL_SERVER_URL'),
WORKSPACE_SERVER_URL: Symbol.for('WORKSPACE_SERVER_URL'),
WEB_SOCKET_SERVER_URL: Symbol.for('WEB_SOCKET_SERVER_URL'),
AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'),

View File

@@ -29,4 +29,14 @@ export class ActionsController extends BaseHttpController {
async methods(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/methods', request.body)
}
@httpGet('/unsubscribe/:token')
async emailUnsubscribe(request: Request, response: Response): Promise<void> {
await this.httpService.callEmailServer(
request,
response,
`subscriptions/actions/unsubscribe/${request.params.token}`,
request.body,
)
}
}

View File

@@ -19,6 +19,7 @@ export class HttpService implements HttpServiceInterface {
@inject(TYPES.WORKSPACE_SERVER_URL) private workspaceServerUrl: string,
@inject(TYPES.WEB_SOCKET_SERVER_URL) private webSocketServerUrl: string,
@inject(TYPES.REVISIONS_SERVER_URL) private revisionsServerUrl: string,
@inject(TYPES.EMAIL_SERVER_URL) private emailServerUrl: string,
@inject(TYPES.HTTP_CALL_TIMEOUT) private httpCallTimeout: number,
@inject(TYPES.CrossServiceTokenCache) private crossServiceTokenCache: CrossServiceTokenCacheInterface,
@inject(TYPES.Logger) private logger: Logger,
@@ -65,6 +66,21 @@ export class HttpService implements HttpServiceInterface {
await this.callServer(this.authServerUrl, request, response, endpoint, payload)
}
async callEmailServer(
request: Request,
response: Response,
endpoint: string,
payload?: Record<string, unknown> | string,
): Promise<void> {
if (!this.emailServerUrl) {
response.status(400).send({ message: 'Email Server not configured' })
return
}
await this.callServer(this.emailServerUrl, request, response, endpoint, payload)
}
async callWorkspaceServer(
request: Request,
response: Response,

View File

@@ -1,6 +1,12 @@
import { Request, Response } from 'express'
export interface HttpServiceInterface {
callEmailServer(
request: Request,
response: Response,
endpoint: string,
payload?: Record<string, unknown> | string,
): Promise<void>
callAuthServer(
request: Request,
response: Response,

View File

@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.66.8](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.7...@standardnotes/auth-server@1.66.8) (2022-12-12)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.66.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.6...@standardnotes/auth-server@1.66.7) (2022-12-09)
### Bug Fixes
* **auth:** linter issue ([104313c](https://github.com/standardnotes/server/commit/104313c15df79f6308d23e21f65111e5bd3d9c72))
## [1.66.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.5...@standardnotes/auth-server@1.66.6) (2022-12-09)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.66.5](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.4...@standardnotes/auth-server@1.66.5) (2022-12-09)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.66.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.3...@standardnotes/auth-server@1.66.4) (2022-12-09)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.66.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.2...@standardnotes/auth-server@1.66.3) (2022-12-09)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.66.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.1...@standardnotes/auth-server@1.66.2) (2022-12-09)
**Note:** Version bump only for package @standardnotes/auth-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.66.2",
"version": "1.66.8",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -47,9 +47,7 @@ describe('CreateOfflineSubscriptionToken', () => {
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailRequestedEvent>)
domainEventFactory.createEmailRequestedEvent = jest.fn().mockReturnValue({} as jest.Mocked<EmailRequestedEvent>)
timer = {} as jest.Mocked<TimerInterface>
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1)

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.9.55](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.54...@standardnotes/domain-events-infra@1.9.55) (2022-12-12)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.54](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.53...@standardnotes/domain-events-infra@1.9.54) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.53](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.52...@standardnotes/domain-events-infra@1.9.53) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.52](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.51...@standardnotes/domain-events-infra@1.9.52) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.51](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.50...@standardnotes/domain-events-infra@1.9.51) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.9.50](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.49...@standardnotes/domain-events-infra@1.9.50) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events-infra

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events-infra",
"version": "1.9.50",
"version": "1.9.55",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,34 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.104.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.103.2...@standardnotes/domain-events@2.104.0) (2022-12-12)
### Features
* **domain-events:** add event for email subscription unsubscribed ([7f18fcf](https://github.com/standardnotes/server/commit/7f18fcfc139911620f2ea72729357aefd0613315))
## [2.103.2](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.103.1...@standardnotes/domain-events@2.103.2) (2022-12-09)
**Note:** Version bump only for package @standardnotes/domain-events
## [2.103.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.103.0...@standardnotes/domain-events@2.103.1) (2022-12-09)
### Bug Fixes
* **domain-events:** add additional styles option for sending email ([20d9214](https://github.com/standardnotes/server/commit/20d92149a8c559edf6aa25932b3dbcbc00b2e878))
# [2.103.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.102.0...@standardnotes/domain-events@2.103.0) (2022-12-09)
### Features
* **workspace:** replace workspace invite created event with email requested ([61c1cff](https://github.com/standardnotes/server/commit/61c1cfff4bcee09e1f933cb3e085412b6f07cc42))
# [2.102.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.101.0...@standardnotes/domain-events@2.102.0) (2022-12-09)
### Features
* **syncing-server:** replace email backup attachment created with email requested ([32601f3](https://github.com/standardnotes/server/commit/32601f34f181b29b7c62cd2926111a0887d97fbf))
# [2.101.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.100.0...@standardnotes/domain-events@2.101.0) (2022-12-09)
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events",
"version": "2.101.0",
"version": "2.104.0",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -1,7 +0,0 @@
import { DomainEventInterface } from './DomainEventInterface'
import { DailyAnalyticsReportGeneratedEventPayload } from './DailyAnalyticsReportGeneratedEventPayload'
export interface DailyAnalyticsReportGeneratedEvent extends DomainEventInterface {
type: 'DAILY_ANALYTICS_REPORT_GENERATED'
payload: DailyAnalyticsReportGeneratedEventPayload
}

View File

@@ -1,41 +0,0 @@
export interface DailyAnalyticsReportGeneratedEventPayload {
activityStatistics: Array<{
name: string
retention: number
totalCount: number
}>
statisticMeasures: Array<{
name: string
totalValue: number
average: number
increments: number
period: number
}>
activityStatisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
totalCount: number
}>
statisticsOverTime: Array<{
name: string
period: number
counts: Array<{
periodKey: string
totalCount: number
}>
}>
churn: {
periodKeys: Array<string>
values: Array<{
rate: number
averageCustomersCount: number
existingCustomersChurn: number
newCustomersChurn: number
periodKey: string
}>
}
}

View File

@@ -1,7 +0,0 @@
import { DomainEventInterface } from './DomainEventInterface'
import { EmailBackupAttachmentCreatedEventPayload } from './EmailBackupAttachmentCreatedEventPayload'
export interface EmailBackupAttachmentCreatedEvent extends DomainEventInterface {
type: 'EMAIL_BACKUP_ATTACHMENT_CREATED'
payload: EmailBackupAttachmentCreatedEventPayload
}

View File

@@ -1,6 +0,0 @@
export interface EmailBackupAttachmentCreatedEventPayload {
backupFileName: string
backupFileIndex: number
backupFilesTotal: number
email: string
}

View File

@@ -4,6 +4,8 @@ export interface EmailRequestedEventPayload {
level: string
subject: string
body: string
sender?: string
additionalStyles?: string
attachments?: Array<{
filePath: string
fileName: string

View File

@@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { EmailSubscriptionUnsubscribedEventPayload } from './EmailSubscriptionUnsubscribedEventPayload'
export interface EmailSubscriptionUnsubscribedEvent extends DomainEventInterface {
type: 'EMAIL_SUBSCRIPTION_UNSUBSCRIBED'
payload: EmailSubscriptionUnsubscribedEventPayload
}

View File

@@ -0,0 +1,4 @@
export interface EmailSubscriptionUnsubscribedEventPayload {
userEmail: string
level: string
}

View File

@@ -1,7 +0,0 @@
import { DomainEventInterface } from './DomainEventInterface'
import { WorkspaceInviteCreatedEventPayload } from './WorkspaceInviteCreatedEventPayload'
export interface WorkspaceInviteCreatedEvent extends DomainEventInterface {
type: 'WORKSPACE_INVITE_CREATED'
payload: WorkspaceInviteCreatedEventPayload
}

View File

@@ -1,6 +0,0 @@
export interface WorkspaceInviteCreatedEventPayload {
inviterUuid: string
inviteeEmail: string
inviteUuid: string
workspaceUuid: string
}

View File

@@ -2,8 +2,6 @@ export * from './Event/AccountDeletionRequestedEvent'
export * from './Event/AccountDeletionRequestedEventPayload'
export * from './Event/CloudBackupRequestedEvent'
export * from './Event/CloudBackupRequestedEventPayload'
export * from './Event/DailyAnalyticsReportGeneratedEvent'
export * from './Event/DailyAnalyticsReportGeneratedEventPayload'
export * from './Event/DiscountApplyRequestedEvent'
export * from './Event/DiscountApplyRequestedEventPayload'
export * from './Event/DiscountWithdrawRequestedEvent'
@@ -14,12 +12,12 @@ export * from './Event/DuplicateItemSyncedEvent'
export * from './Event/DuplicateItemSyncedEventPayload'
export * from './Event/EmailArchiveExtensionSyncedEvent'
export * from './Event/EmailArchiveExtensionSyncedEventPayload'
export * from './Event/EmailBackupAttachmentCreatedEvent'
export * from './Event/EmailBackupAttachmentCreatedEventPayload'
export * from './Event/EmailBackupRequestedEvent'
export * from './Event/EmailBackupRequestedEventPayload'
export * from './Event/EmailRequestedEvent'
export * from './Event/EmailRequestedEventPayload'
export * from './Event/EmailSubscriptionUnsubscribedEvent'
export * from './Event/EmailSubscriptionUnsubscribedEventPayload'
export * from './Event/ExitDiscountAppliedEvent'
export * from './Event/ExitDiscountAppliedEventPayload'
export * from './Event/ExitDiscountApplyRequestedEvent'
@@ -96,8 +94,6 @@ export * from './Event/WebSocketMessageRequestedEvent'
export * from './Event/WebSocketMessageRequestedEventPayload'
export * from './Event/WorkspaceInviteAcceptedEvent'
export * from './Event/WorkspaceInviteAcceptedEventPayload'
export * from './Event/WorkspaceInviteCreatedEvent'
export * from './Event/WorkspaceInviteCreatedEventPayload'
export * from './Handler/DomainEventHandlerInterface'
export * from './Handler/DomainEventMessageHandlerInterface'

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.6.52](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.51...@standardnotes/event-store@1.6.52) (2022-12-12)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.51](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.50...@standardnotes/event-store@1.6.51) (2022-12-09)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.50](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.49...@standardnotes/event-store@1.6.50) (2022-12-09)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.49](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.48...@standardnotes/event-store@1.6.49) (2022-12-09)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.48](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.47...@standardnotes/event-store@1.6.48) (2022-12-09)
**Note:** Version bump only for package @standardnotes/event-store
## [1.6.47](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.46...@standardnotes/event-store@1.6.47) (2022-12-09)
**Note:** Version bump only for package @standardnotes/event-store

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/event-store",
"version": "1.6.47",
"version": "1.6.52",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.51](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.50...@standardnotes/files-server@1.8.51) (2022-12-12)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.50](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.49...@standardnotes/files-server@1.8.50) (2022-12-09)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.49](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.48...@standardnotes/files-server@1.8.49) (2022-12-09)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.48](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.47...@standardnotes/files-server@1.8.48) (2022-12-09)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.47](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.46...@standardnotes/files-server@1.8.47) (2022-12-09)
**Note:** Version bump only for package @standardnotes/files-server
## [1.8.46](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.45...@standardnotes/files-server@1.8.46) (2022-12-09)
**Note:** Version bump only for package @standardnotes/files-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files-server",
"version": "1.8.46",
"version": "1.8.51",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.9.24](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.23...@standardnotes/revisions-server@1.9.24) (2022-12-12)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.9.23](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.22...@standardnotes/revisions-server@1.9.23) (2022-12-09)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.9.22](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.21...@standardnotes/revisions-server@1.9.22) (2022-12-09)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.9.21](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.20...@standardnotes/revisions-server@1.9.21) (2022-12-09)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.9.20](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.19...@standardnotes/revisions-server@1.9.20) (2022-12-09)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.9.19](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.18...@standardnotes/revisions-server@1.9.19) (2022-12-09)
**Note:** Version bump only for package @standardnotes/revisions-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/revisions-server",
"version": "1.9.19",
"version": "1.9.24",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.15.5](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.4...@standardnotes/scheduler-server@1.15.5) (2022-12-12)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.15.4](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.3...@standardnotes/scheduler-server@1.15.4) (2022-12-09)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.15.3](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.2...@standardnotes/scheduler-server@1.15.3) (2022-12-09)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.15.2](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.1...@standardnotes/scheduler-server@1.15.2) (2022-12-09)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.15.1](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.0...@standardnotes/scheduler-server@1.15.1) (2022-12-09)
**Note:** Version bump only for package @standardnotes/scheduler-server
# [1.15.0](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.14.10...@standardnotes/scheduler-server@1.15.0) (2022-12-09)
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/scheduler-server",
"version": "1.15.0",
"version": "1.15.5",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,34 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.5](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.4...@standardnotes/syncing-server@1.24.5) (2022-12-12)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.24.4](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.3...@standardnotes/syncing-server@1.24.4) (2022-12-12)
### Bug Fixes
* **syncing-server:** data integrity check on revisions fix ([6368342](https://github.com/standardnotes/syncing-server-js/commit/6368342149d658898aef62651bfafddf51c26dbe))
## [1.24.3](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.2...@standardnotes/syncing-server@1.24.3) (2022-12-09)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.24.2](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.1...@standardnotes/syncing-server@1.24.2) (2022-12-09)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.24.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.0...@standardnotes/syncing-server@1.24.1) (2022-12-09)
**Note:** Version bump only for package @standardnotes/syncing-server
# [1.24.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.23.0...@standardnotes/syncing-server@1.24.0) (2022-12-09)
### Features
* **syncing-server:** replace email backup attachment created with email requested ([32601f3](https://github.com/standardnotes/syncing-server-js/commit/32601f34f181b29b7c62cd2926111a0887d97fbf))
# [1.23.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.22.0...@standardnotes/syncing-server@1.23.0) (2022-12-09)
### Features

View File

@@ -32,6 +32,12 @@ const fixRevisionsOwnership = async (
objectMode: true,
transform: async (rawItemData, _encoding, callback) => {
try {
if (!rawItemData.item_user_uuid || !rawItemData.item_uuid) {
logger.error('Could not process item %O', rawItemData)
return callback()
}
await domainEventPublisher.publish(
domainEventFactory.createRevisionsOwnershipUpdateRequestedEvent({
userUuid: rawItemData.item_user_uuid,

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.23.0",
"version": "1.24.5",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -0,0 +1,14 @@
import { html } from './email-backup-attachment-created.html'
export function getSubject(fileIndex: number, numberOfFiles: number, date: string): string {
let subject = `Data Backup for ${date}`
if (numberOfFiles > 1) {
subject = `Data Backup for ${date} - Part ${fileIndex} Of ${numberOfFiles}`
}
return subject
}
export function getBody(email: string): string {
return html(email)
}

View File

@@ -0,0 +1,33 @@
export const html = (email: string) => `
<p>
Your encrypted data backup is attached for ${email}. You can import this file using
the Standard Notes web or desktop app, or by using the offline decryption script available at
<a style="text-decoration:none !important; text-decoration:none;">standardnotes.org/offline</a>.
</p>
<p>
<strong>Please note:</strong>
<ol>
<li>
We will never send anything other than a <code>txt</code> file
as part of your daily backups. To protect yourself against phishing attacks, never open
any other kind of file, and always open the <code>txt</code> file with a text editor to
verify its contents before decrypting.
</li>
<li>
We will never include clickable links in this email. Instead, manually verify
and copy/paste the offline link above in your browser.
</li>
</ol>
</p>
<hr />
<p>
<i>
Want to disable daily backups? Uninstall 'Daily Email Backups' from your Extensions
menu in Standard Notes to immediately disable backups.
Otherwise, reply to this email with "Stop". Note that it may
take up to 72 hours or more to perform manual removal via the "Stop" method.
</i>
</p>
`

View File

@@ -3,7 +3,6 @@ import {
DomainEventService,
DuplicateItemSyncedEvent,
EmailArchiveExtensionSyncedEvent,
EmailBackupAttachmentCreatedEvent,
EmailRequestedEvent,
ItemDumpedEvent,
ItemRevisionCreationRequestedEvent,
@@ -135,6 +134,13 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
level: string
body: string
subject: string
sender?: string
attachments?: Array<{
filePath: string
fileName: string
attachmentFileName: string
attachmentContentType: string
}>
}): EmailRequestedEvent {
return {
type: 'EMAIL_REQUESTED',
@@ -190,24 +196,4 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
},
}
}
createEmailBackupAttachmentCreatedEvent(dto: {
backupFileName: string
backupFileIndex: number
backupFilesTotal: number
email: string
}): EmailBackupAttachmentCreatedEvent {
return {
type: 'EMAIL_BACKUP_ATTACHMENT_CREATED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: dto.email,
userIdentifierType: 'email',
},
origin: DomainEventService.SyncingServer,
},
payload: dto,
}
}
}

View File

@@ -1,7 +1,6 @@
import {
DuplicateItemSyncedEvent,
EmailArchiveExtensionSyncedEvent,
EmailBackupAttachmentCreatedEvent,
EmailRequestedEvent,
ItemDumpedEvent,
ItemRevisionCreationRequestedEvent,
@@ -19,6 +18,13 @@ export interface DomainEventFactoryInterface {
level: string
body: string
subject: string
sender?: string
attachments?: Array<{
filePath: string
fileName: string
attachmentFileName: string
attachmentContentType: string
}>
}): EmailRequestedEvent
createItemsSyncedEvent(dto: {
userUuid: string
@@ -30,12 +36,6 @@ export interface DomainEventFactoryInterface {
source: 'account-deletion' | 'realtime-extensions-sync'
}): ItemsSyncedEvent
createEmailArchiveExtensionSyncedEvent(userUuid: string, extensionId: string): EmailArchiveExtensionSyncedEvent
createEmailBackupAttachmentCreatedEvent(dto: {
backupFileName: string
backupFileIndex: number
backupFilesTotal: number
email: string
}): EmailBackupAttachmentCreatedEvent
createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent
createItemRevisionCreationRequested(itemUuid: string, userUuid: string): ItemRevisionCreationRequestedEvent
createItemDumpedEvent(fileDumpPath: string, userUuid: string): ItemDumpedEvent

View File

@@ -3,7 +3,7 @@ import 'reflect-metadata'
import {
DomainEventPublisherInterface,
EmailArchiveExtensionSyncedEvent,
EmailBackupAttachmentCreatedEvent,
EmailRequestedEvent,
} from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface'
@@ -35,6 +35,7 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
domainEventFactory,
emailAttachmentMaxByteSize,
itemTransferCalculator,
's3-backup-bucket-name',
logger,
)
@@ -62,9 +63,7 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailBackupAttachmentCreatedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailBackupAttachmentCreatedEvent>)
domainEventFactory.createEmailRequestedEvent = jest.fn().mockReturnValue({} as jest.Mocked<EmailRequestedEvent>)
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidBundlesToFetch = jest.fn().mockReturnValue([['1-2-3']])
@@ -78,12 +77,7 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenCalledWith({
backupFileIndex: 1,
backupFileName: 'backup-file-name',
backupFilesTotal: 1,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
})
it('should inform that multipart backup attachment for email was created', async () => {
@@ -96,18 +90,7 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(1, {
backupFileIndex: 1,
backupFileName: 'backup-file-name-1',
backupFilesTotal: 2,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(2, {
backupFileIndex: 2,
backupFileName: 'backup-file-name-2',
backupFilesTotal: 2,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalledTimes(2)
})
it('should not inform that backup attachment for email was created if user key params cannot be obtained', async () => {
@@ -118,7 +101,7 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
})
it('should not inform that backup attachment for email was created if backup file name is empty', async () => {
@@ -127,6 +110,6 @@ describe('EmailArchiveExtensionSyncedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
})
})

View File

@@ -4,6 +4,7 @@ import {
DomainEventPublisherInterface,
EmailArchiveExtensionSyncedEvent,
} from '@standardnotes/domain-events'
import { EmailLevel } from '@standardnotes/domain-core'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
@@ -13,6 +14,7 @@ import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { ItemQuery } from '../Item/ItemQuery'
import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface'
import { getBody, getSubject } from '../Email/EmailBackupAttachmentCreated'
@injectable()
export class EmailArchiveExtensionSyncedEventHandler implements DomainEventHandlerInterface {
@@ -24,6 +26,7 @@ export class EmailArchiveExtensionSyncedEventHandler implements DomainEventHandl
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.EMAIL_ATTACHMENT_MAX_BYTE_SIZE) private emailAttachmentMaxByteSize: number,
@inject(TYPES.ItemTransferCalculator) private itemTransferCalculator: ItemTransferCalculatorInterface,
@inject(TYPES.S3_BACKUP_BUCKET_NAME) private s3BackupBucketName: string,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -64,14 +67,24 @@ export class EmailArchiveExtensionSyncedEventHandler implements DomainEventHandl
this.logger.debug(`Data backed up into: ${backupFileName}`)
if (backupFileName.length !== 0) {
this.logger.debug('Publishing EMAIL_BACKUP_ATTACHMENT_CREATED event')
const dateOnly = new Date().toISOString().substring(0, 10)
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailBackupAttachmentCreatedEvent({
backupFileName,
backupFileIndex: bundleIndex++,
backupFilesTotal: itemUuidBundles.length,
email: authParams.identifier as string,
this.domainEventFactory.createEmailRequestedEvent({
body: getBody(authParams.identifier as string),
level: EmailLevel.LEVELS.System,
messageIdentifier: 'DATA_BACKUP',
subject: getSubject(bundleIndex++, itemUuidBundles.length, dateOnly),
userEmail: authParams.identifier as string,
sender: 'backups@standardnotes.org',
attachments: [
{
fileName: backupFileName,
filePath: this.s3BackupBucketName,
attachmentFileName: `SN-Data-${dateOnly}.txt`,
attachmentContentType: 'application/json',
},
],
}),
)
}

View File

@@ -3,7 +3,7 @@ import 'reflect-metadata'
import {
DomainEventPublisherInterface,
EmailBackupRequestedEvent,
EmailBackupAttachmentCreatedEvent,
EmailRequestedEvent,
} from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface'
@@ -35,6 +35,7 @@ describe('EmailBackupRequestedEventHandler', () => {
domainEventFactory,
emailAttachmentMaxByteSize,
itemTransferCalculator,
's3-backup-bucket-name',
logger,
)
@@ -62,9 +63,7 @@ describe('EmailBackupRequestedEventHandler', () => {
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailBackupAttachmentCreatedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailBackupAttachmentCreatedEvent>)
domainEventFactory.createEmailRequestedEvent = jest.fn().mockReturnValue({} as jest.Mocked<EmailRequestedEvent>)
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidBundlesToFetch = jest.fn().mockReturnValue([['1-2-3']])
@@ -79,12 +78,7 @@ describe('EmailBackupRequestedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenCalledWith({
backupFileIndex: 1,
backupFileName: 'backup-file-name',
backupFilesTotal: 1,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
})
it('should inform that multipart backup attachment for email was created', async () => {
@@ -97,18 +91,7 @@ describe('EmailBackupRequestedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(1, {
backupFileIndex: 1,
backupFileName: 'backup-file-name-1',
backupFilesTotal: 2,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(2, {
backupFileIndex: 2,
backupFileName: 'backup-file-name-2',
backupFilesTotal: 2,
email: 'test@test.com',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalledTimes(2)
})
it('should not inform that backup attachment for email was created if user key params cannot be obtained', async () => {
@@ -119,7 +102,7 @@ describe('EmailBackupRequestedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
})
it('should not inform that backup attachment for email was created if backup file name is empty', async () => {
@@ -128,6 +111,6 @@ describe('EmailBackupRequestedEventHandler', () => {
await createHandler().handle(event)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
})
})

View File

@@ -4,6 +4,7 @@ import {
DomainEventPublisherInterface,
EmailBackupRequestedEvent,
} from '@standardnotes/domain-events'
import { EmailLevel } from '@standardnotes/domain-core'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
@@ -13,6 +14,7 @@ import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface'
import { ItemQuery } from '../Item/ItemQuery'
import { getBody, getSubject } from '../Email/EmailBackupAttachmentCreated'
@injectable()
export class EmailBackupRequestedEventHandler implements DomainEventHandlerInterface {
@@ -24,6 +26,7 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.EMAIL_ATTACHMENT_MAX_BYTE_SIZE) private emailAttachmentMaxByteSize: number,
@inject(TYPES.ItemTransferCalculator) private itemTransferCalculator: ItemTransferCalculatorInterface,
@inject(TYPES.S3_BACKUP_BUCKET_NAME) private s3BackupBucketName: string,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -68,15 +71,24 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
return
}
this.logger.debug('Publishing EMAIL_BACKUP_ATTACHMENT_CREATED event')
const dateOnly = new Date().toISOString().substring(0, 10)
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailBackupAttachmentCreatedEvent({
backupFileName,
backupFileIndex: bundleIndex++,
backupFilesTotal: itemUuidBundles.length,
email: authParams.identifier as string,
this.domainEventFactory.createEmailRequestedEvent({
body: getBody(authParams.identifier as string),
level: EmailLevel.LEVELS.System,
messageIdentifier: 'DATA_BACKUP',
subject: getSubject(bundleIndex++, itemUuidBundles.length, dateOnly),
userEmail: authParams.identifier as string,
sender: 'backups@standardnotes.org',
attachments: [
{
fileName: backupFileName,
filePath: this.s3BackupBucketName,
attachmentFileName: `SN-Data-${dateOnly}.txt`,
attachmentContentType: 'application/json',
},
],
}),
)
}

View File

@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.4.52](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.51...@standardnotes/websockets-server@1.4.52) (2022-12-12)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.51](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.50...@standardnotes/websockets-server@1.4.51) (2022-12-09)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.50](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.49...@standardnotes/websockets-server@1.4.50) (2022-12-09)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.49](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.48...@standardnotes/websockets-server@1.4.49) (2022-12-09)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.48](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.47...@standardnotes/websockets-server@1.4.48) (2022-12-09)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.4.47](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.46...@standardnotes/websockets-server@1.4.47) (2022-12-09)
**Note:** Version bump only for package @standardnotes/websockets-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/websockets-server",
"version": "1.4.47",
"version": "1.4.52",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.18.3](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.18.2...@standardnotes/workspace-server@1.18.3) (2022-12-12)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.18.2](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.18.1...@standardnotes/workspace-server@1.18.2) (2022-12-09)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.18.1](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.18.0...@standardnotes/workspace-server@1.18.1) (2022-12-09)
**Note:** Version bump only for package @standardnotes/workspace-server
# [1.18.0](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.47...@standardnotes/workspace-server@1.18.0) (2022-12-09)
### Features
* **workspace:** replace workspace invite created event with email requested ([61c1cff](https://github.com/standardnotes/server/commit/61c1cfff4bcee09e1f933cb3e085412b6f07cc42))
## [1.17.47](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.46...@standardnotes/workspace-server@1.17.47) (2022-12-09)
**Note:** Version bump only for package @standardnotes/workspace-server
## [1.17.46](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.17.45...@standardnotes/workspace-server@1.17.46) (2022-12-09)
**Note:** Version bump only for package @standardnotes/workspace-server

View File

@@ -7,6 +7,6 @@ module.exports = {
transform: {
...tsjPreset.transform,
},
coveragePathIgnorePatterns: ['/Bootstrap/', '/InversifyExpressUtils/'],
coveragePathIgnorePatterns: ['/Bootstrap/', '/InversifyExpressUtils/', '/Domain/Email/', '/Domain/Event'],
setupFilesAfterEnv: ['./test-setup.ts'],
}

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/workspace-server",
"version": "1.17.46",
"version": "1.18.3",
"engines": {
"node": ">=18.0.0 <19.0.0"
},
@@ -26,6 +26,7 @@
"@sentry/node": "^7.19.0",
"@standardnotes/api": "^1.19.0",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:^",
"@standardnotes/domain-events-infra": "workspace:^",
"@standardnotes/models": "^1.26.0",

View File

@@ -0,0 +1,9 @@
import { html } from './workspace-invite-created.html'
export function getSubject(): string {
return 'You have been invited to a Standard Notes workspace'
}
export function getBody(inviteUuid: string): string {
return html(inviteUuid)
}

View File

@@ -0,0 +1,11 @@
export const html = (inviteUuid: string) => `<p>Hello,</p>
<p>We are happy to inform that you have been invited to a shared workspace in Standard Notes.</p>
<p>
Please
<a href='https://app.standardnotes.com/?accept_workspace_invite=${inviteUuid}'>accept the invitation</a>
to see the shared content.
</p>
<p>
Thanks,
<br>SN</br>
</p>`

View File

@@ -1,92 +0,0 @@
import 'reflect-metadata'
import { TimerInterface } from '@standardnotes/time'
import { DomainEventFactory } from './DomainEventFactory'
describe('DomainEventFactory', () => {
let timer: TimerInterface
const createFactory = () => new DomainEventFactory(timer)
beforeEach(() => {
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
timer.getUTCDate = jest.fn().mockReturnValue(new Date(1))
})
it('should create a WEB_SOCKET_MESSAGE_REQUESTED event', () => {
expect(
createFactory().createWebSocketMessageRequestedEvent({
userUuid: '1-2-3',
message: 'foobar',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '1-2-3',
userIdentifierType: 'uuid',
},
origin: 'workspace',
},
payload: {
userUuid: '1-2-3',
message: 'foobar',
},
type: 'WEB_SOCKET_MESSAGE_REQUESTED',
})
})
it('should create a WORKSPACE_INVITE_ACCEPTED event', () => {
expect(
createFactory().createWorkspaceInviteAcceptedEvent({
inviterUuid: '1-2-3',
inviteeUuid: '2-3-4',
workspaceUuid: 'w-1-2-3',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
},
origin: 'workspace',
},
payload: {
inviterUuid: '1-2-3',
inviteeUuid: '2-3-4',
workspaceUuid: 'w-1-2-3',
},
type: 'WORKSPACE_INVITE_ACCEPTED',
})
})
it('should create a WORKSPACE_INVITE_CREATED event', () => {
expect(
createFactory().createWorkspaceInviteCreatedEvent({
inviterUuid: '1-2-3',
inviteeEmail: 'test@test.te',
inviteUuid: 'i-1-2-3',
workspaceUuid: 'w-1-2-3',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '1-2-3',
userIdentifierType: 'uuid',
},
origin: 'workspace',
},
payload: {
inviterUuid: '1-2-3',
inviteeEmail: 'test@test.te',
inviteUuid: 'i-1-2-3',
workspaceUuid: 'w-1-2-3',
},
type: 'WORKSPACE_INVITE_CREATED',
})
})
})

View File

@@ -1,8 +1,8 @@
import {
DomainEventService,
EmailRequestedEvent,
WebSocketMessageRequestedEvent,
WorkspaceInviteAcceptedEvent,
WorkspaceInviteCreatedEvent,
} from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
@@ -49,21 +49,22 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
}
}
createWorkspaceInviteCreatedEvent(dto: {
inviterUuid: string
inviteeEmail: string
inviteUuid: string
workspaceUuid: string
}): WorkspaceInviteCreatedEvent {
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent {
return {
type: 'WORKSPACE_INVITE_CREATED',
type: 'EMAIL_REQUESTED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: dto.inviterUuid,
userIdentifierType: 'uuid',
userIdentifier: dto.userEmail,
userIdentifierType: 'email',
},
origin: DomainEventService.Workspace,
origin: DomainEventService.Auth,
},
payload: dto,
}

View File

@@ -1,17 +1,18 @@
import { JSONString } from '@standardnotes/common'
import {
EmailRequestedEvent,
WebSocketMessageRequestedEvent,
WorkspaceInviteAcceptedEvent,
WorkspaceInviteCreatedEvent,
} from '@standardnotes/domain-events'
export interface DomainEventFactoryInterface {
createWorkspaceInviteCreatedEvent(dto: {
inviterUuid: string
inviteeEmail: string
inviteUuid: string
workspaceUuid: string
}): WorkspaceInviteCreatedEvent
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent
createWorkspaceInviteAcceptedEvent(dto: {
inviterUuid: string
inviteeUuid: string

View File

@@ -1,8 +1,10 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { EmailLevel } from '@standardnotes/domain-core'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { getBody, getSubject } from '../../Email/WorkspaceInviteCreated'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { WorkspaceInvite } from '../../Invite/WorkspaceInvite'
import { WorkspaceInviteRepositoryInterface } from '../../Invite/WorkspaceInviteRepositoryInterface'
@@ -36,11 +38,12 @@ export class InviteToWorkspace implements UseCaseInterface {
invite = await this.workspaceInviteRepository.save(invite)
await this.domainEventPublisher.publish(
this.domainEventFactory.createWorkspaceInviteCreatedEvent({
inviterUuid: dto.inviterUuid,
inviteeEmail: dto.inviteeEmail,
workspaceUuid: dto.workspaceUuid,
inviteUuid: invite.uuid,
this.domainEventFactory.createEmailRequestedEvent({
body: getBody(invite.uuid),
subject: getSubject(),
level: EmailLevel.LEVELS.System,
messageIdentifier: 'WORKSPACE_INVITE_CREATED',
userEmail: dto.inviteeEmail,
}),
)

View File

@@ -1795,7 +1795,7 @@ __metadata:
"@newrelic/winston-enricher": "npm:^4.0.0"
"@sentry/node": "npm:^7.19.0"
"@standardnotes/common": "workspace:*"
"@standardnotes/domain-core": "workspace:*"
"@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*"
"@standardnotes/time": "workspace:*"
@@ -1977,7 +1977,7 @@ __metadata:
languageName: node
linkType: hard
"@standardnotes/domain-core@workspace:*, @standardnotes/domain-core@workspace:^, @standardnotes/domain-core@workspace:packages/domain-core":
"@standardnotes/domain-core@workspace:^, @standardnotes/domain-core@workspace:packages/domain-core":
version: 0.0.0-use.local
resolution: "@standardnotes/domain-core@workspace:packages/domain-core"
dependencies:
@@ -2535,6 +2535,7 @@ __metadata:
"@sentry/node": "npm:^7.19.0"
"@standardnotes/api": "npm:^1.19.0"
"@standardnotes/common": "workspace:*"
"@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events": "workspace:^"
"@standardnotes/domain-events-infra": "workspace:^"
"@standardnotes/models": "npm:^1.26.0"