Compare commits

..

54 Commits

Author SHA1 Message Date
standardci
b9032f3012 chore(release): publish new version
- @standardnotes/syncing-server@1.26.3
2022-12-15 06:31:07 +00:00
Karol Sójko
ce53c459e6 fix(syncing-server): select fields in query for revisions 2022-12-15 07:28:43 +01:00
standardci
6df42fb0d5 chore(release): publish new version
- @standardnotes/syncing-server@1.26.2
2022-12-14 19:08:28 +00:00
Karol Sójko
1e2b496f4f fix(syncing-server): revisions procedure logs 2022-12-14 20:06:07 +01:00
standardci
528c1b0d57 chore(release): publish new version
- @standardnotes/syncing-server@1.26.1
2022-12-14 18:58:11 +00:00
Karol Sójko
22fba8ba80 fix(syncing-server): revisions procedure logs 2022-12-14 19:56:17 +01:00
standardci
6f26261ebe chore(release): publish new version
- @standardnotes/syncing-server@1.26.0
2022-12-14 15:51:14 +00:00
Karol Sójko
4b1fe3ba91 feat(syncing-server): change revisions procedure to pagination instead of streaming 2022-12-14 16:49:21 +01:00
standardci
9f95262bd4 chore(release): publish new version
- @standardnotes/syncing-server@1.25.6
2022-12-14 09:33:24 +00:00
Karol Sójko
2ec28e541e fix(syncing-server): additional stream events handling on revisions procedure 2022-12-14 10:31:06 +01:00
standardci
4764d4b19a chore(release): publish new version
- @standardnotes/syncing-server@1.25.5
2022-12-14 09:00:42 +00:00
Karol Sójko
9b27547dae fix(syncing-server): revisions procedure with env var defined ranges 2022-12-14 09:58:41 +01:00
standardci
a96f2c9153 chore(release): publish new version
- @standardnotes/syncing-server@1.25.4
2022-12-13 11:14:35 +00:00
Karol Sójko
225e0aaf88 fix(syncing-server): logs on revisions procedure 2022-12-13 12:12:42 +01:00
standardci
f0c85910bc chore(release): publish new version
- @standardnotes/syncing-server@1.25.3
2022-12-13 11:07:23 +00:00
Karol Sójko
124c443528 fix(syncing-server): revisions ownership procedure destructured 2022-12-13 12:05:10 +01:00
standardci
37c7f8d39f chore(release): publish new version
- @standardnotes/syncing-server@1.25.2
2022-12-13 07:19:55 +00:00
Karol Sójko
c419f1ce22 fix(syncing-server): change revisions migration to notes 2022-12-13 08:17:55 +01:00
standardci
4949cdfe2f chore(release): publish new version
- @standardnotes/syncing-server@1.25.1
2022-12-13 06:03:06 +00:00
Karol Sójko
cd101b96ea fix(syncing-server): revisions procedure properties 2022-12-13 07:01:06 +01:00
standardci
40d0e4631f chore(release): publish new version
- @standardnotes/syncing-server@1.25.0
2022-12-12 19:06:24 +00:00
Karol Sójko
a55a995660 feat(syncing-server): fix streaming items for revisions update 2022-12-12 20:03:45 +01:00
standardci
1d576d48ad chore(release): publish new version
- @standardnotes/auth-server@1.67.1
 - @standardnotes/syncing-server@1.24.7
2022-12-12 13:20:47 +00:00
Karol Sójko
4ff8030f87 fix(syncing-server): revisions updating - select fields 2022-12-12 14:18:45 +01:00
Karol Sójko
c15e2e2c8f fix: user signed in email template 2022-12-12 14:18:45 +01:00
standardci
41d31a8d75 chore(release): publish new version
- @standardnotes/auth-server@1.67.0
2022-12-12 13:00:40 +00:00
Karol Sójko
10e2a26352 feat(auth): add email subscription unsubscribed event handler 2022-12-12 13:58:35 +01:00
standardci
6e547f77d0 chore(release): publish new version
- @standardnotes/revisions-server@1.9.26
2022-12-12 12:14:52 +00:00
Karol Sójko
530a426601 fix(revisions): responses to match previous response structure 2022-12-12 13:12:46 +01:00
Karol Sójko
642d6bab77 chore: fix triggers for other repos dep 2022-12-12 12:56:28 +01:00
standardci
7980af3d82 chore(release): publish new version
- @standardnotes/analytics@2.12.25
 - @standardnotes/api-gateway@1.40.2
 - @standardnotes/auth-server@1.66.9
 - @standardnotes/domain-events-infra@1.9.56
 - @standardnotes/domain-events@2.104.1
 - @standardnotes/event-store@1.6.53
 - @standardnotes/files-server@1.8.52
 - @standardnotes/revisions-server@1.9.25
 - @standardnotes/scheduler-server@1.15.6
 - @standardnotes/syncing-server@1.24.6
 - @standardnotes/websockets-server@1.4.53
 - @standardnotes/workspace-server@1.18.4
2022-12-12 11:38:37 +00:00
Karol Sójko
2980c42e88 fix(domain-events): add additional domain event services 2022-12-12 12:36:09 +01:00
standardci
b03994f9db chore(release): publish new version
- @standardnotes/analytics@2.12.24
2022-12-12 11:31:36 +00:00
Karol Sójko
41906ec2f9 fix(analytics): daily analytics report template 2022-12-12 12:29:16 +01:00
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
88 changed files with 1983 additions and 859 deletions

View File

@@ -187,7 +187,7 @@ jobs:
tags: standardnotes/${{ inputs.service_name }}:${{ github.sha }}
- name: Run E2E test suite
uses: convictional/trigger-workflow-and-wait@v1.6.3
uses: convictional/trigger-workflow-and-wait@master
with:
owner: standardnotes
repo: e2e

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,48 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.12.25](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.24...@standardnotes/analytics@2.12.25) (2022-12-12)
**Note:** Version bump only for package @standardnotes/analytics
## [2.12.24](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.12.23...@standardnotes/analytics@2.12.24) (2022-12-12)
### Bug Fixes
* **analytics:** daily analytics report template ([41906ec](https://github.com/standardnotes/server/commit/41906ec2f9fd4d605b1c002826173e14fb534e00))
## [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: ${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.25",
"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(data)
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,36 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.40.1...@standardnotes/api-gateway@1.40.2) (2022-12-12)
**Note:** Version bump only for package @standardnotes/api-gateway
## [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.2",
"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,48 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.67.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.67.0...@standardnotes/auth-server@1.67.1) (2022-12-12)
### Bug Fixes
* user signed in email template ([c15e2e2](https://github.com/standardnotes/server/commit/c15e2e2c8f3a6c177e227d25440501fa38dd3d0e))
# [1.67.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.9...@standardnotes/auth-server@1.67.0) (2022-12-12)
### Features
* **auth:** add email subscription unsubscribed event handler ([10e2a26](https://github.com/standardnotes/server/commit/10e2a263522dfa33c06940f29cb77f783f66b20c))
## [1.66.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.66.8...@standardnotes/auth-server@1.66.9) (2022-12-12)
**Note:** Version bump only for package @standardnotes/auth-server
## [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.67.1",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -193,6 +193,7 @@ import { SubscriptionInvitesController } from '../Controller/SubscriptionInvites
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { ProcessUserRequest } from '../Domain/UseCase/ProcessUserRequest/ProcessUserRequest'
import { UserRequestsController } from '../Controller/UserRequestsController'
import { EmailSubscriptionUnsubscribedEventHandler } from '../Domain/Handler/EmailSubscriptionUnsubscribedEventHandler'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -560,6 +561,15 @@ export class ContainerConfigLoader {
)
}
container
.bind<EmailSubscriptionUnsubscribedEventHandler>(TYPES.EmailSubscriptionUnsubscribedEventHandler)
.toConstantValue(
new EmailSubscriptionUnsubscribedEventHandler(
container.get(TYPES.UserRepository),
container.get(TYPES.SettingService),
),
)
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)],
['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.AccountDeletionRequestedEventHandler)],
@@ -582,6 +592,7 @@ export class ContainerConfigLoader {
],
['SHARED_SUBSCRIPTION_INVITATION_CREATED', container.get(TYPES.SharedSubscriptionInvitationCreatedEventHandler)],
['PREDICATE_VERIFICATION_REQUESTED', container.get(TYPES.PredicateVerificationRequestedEventHandler)],
['EMAIL_SUBSCRIPTION_UNSUBSCRIBED', container.get(TYPES.EmailSubscriptionUnsubscribedEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {

View File

@@ -138,6 +138,7 @@ const TYPES = {
UserDisabledSessionUserAgentLoggingEventHandler: Symbol.for('UserDisabledSessionUserAgentLoggingEventHandler'),
SharedSubscriptionInvitationCreatedEventHandler: Symbol.for('SharedSubscriptionInvitationCreatedEventHandler'),
PredicateVerificationRequestedEventHandler: Symbol.for('PredicateVerificationRequestedEventHandler'),
EmailSubscriptionUnsubscribedEventHandler: Symbol.for('EmailSubscriptionUnsubscribedEventHandler'),
// Services
DeviceDetector: Symbol.for('DeviceDetector'),
SessionService: Symbol.for('SessionService'),

View File

@@ -20,6 +20,5 @@ export const html = (email: string, device: string, browser: string, timeAndDate
<br />
SN
</p>
<a href="https://app.standardnotes.com/?settings=account">Mute these emails</a>
</div>
`

View File

@@ -0,0 +1,109 @@
import { EmailLevel } from '@standardnotes/domain-core'
import { EmailSubscriptionUnsubscribedEvent } from '@standardnotes/domain-events'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { EmailSubscriptionUnsubscribedEventHandler } from './EmailSubscriptionUnsubscribedEventHandler'
describe('EmailSubscriptionUnsubscribedEventHandler', () => {
let userRepository: UserRepositoryInterface
let settingsService: SettingServiceInterface
let event: EmailSubscriptionUnsubscribedEvent
const createHandler = () => new EmailSubscriptionUnsubscribedEventHandler(userRepository, settingsService)
beforeEach(() => {
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByEmail = jest.fn().mockReturnValue({} as jest.Mocked<User>)
settingsService = {} as jest.Mocked<SettingServiceInterface>
settingsService.createOrReplace = jest.fn()
event = {
payload: {
userEmail: 'test@test.te',
level: EmailLevel.LEVELS.Marketing,
},
} as jest.Mocked<EmailSubscriptionUnsubscribedEvent>
})
it('should not do anything if user is not found', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
await createHandler().handle(event)
expect(settingsService.createOrReplace).not.toHaveBeenCalled()
})
it('should update user marketing email settings', async () => {
await createHandler().handle(event)
expect(settingsService.createOrReplace).toHaveBeenCalledWith({
user: {},
props: {
name: 'MUTE_MARKETING_EMAILS',
unencryptedValue: 'muted',
sensitive: false,
},
})
})
it('should update user sign in email settings', async () => {
event.payload.level = EmailLevel.LEVELS.SignIn
await createHandler().handle(event)
expect(settingsService.createOrReplace).toHaveBeenCalledWith({
user: {},
props: {
name: 'MUTE_SIGN_IN_EMAILS',
unencryptedValue: 'muted',
sensitive: false,
},
})
})
it('should update user email backup email settings', async () => {
event.payload.level = EmailLevel.LEVELS.FailedEmailBackup
await createHandler().handle(event)
expect(settingsService.createOrReplace).toHaveBeenCalledWith({
user: {},
props: {
name: 'MUTE_FAILED_BACKUPS_EMAILS',
unencryptedValue: 'muted',
sensitive: false,
},
})
})
it('should update user email backup email settings', async () => {
event.payload.level = EmailLevel.LEVELS.FailedCloudBackup
await createHandler().handle(event)
expect(settingsService.createOrReplace).toHaveBeenCalledWith({
user: {},
props: {
name: 'MUTE_FAILED_CLOUD_BACKUPS_EMAILS',
unencryptedValue: 'muted',
sensitive: false,
},
})
})
it('should throw error for unrecognized level', async () => {
event.payload.level = 'foobar'
let caughtError = null
try {
await createHandler().handle(event)
} catch (error) {
caughtError = error
}
expect(caughtError).not.toBeNull()
})
})

View File

@@ -0,0 +1,41 @@
import { EmailLevel } from '@standardnotes/domain-core'
import { DomainEventHandlerInterface, EmailSubscriptionUnsubscribedEvent } from '@standardnotes/domain-events'
import { SettingName } from '@standardnotes/settings'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
export class EmailSubscriptionUnsubscribedEventHandler implements DomainEventHandlerInterface {
constructor(private userRepository: UserRepositoryInterface, private settingsService: SettingServiceInterface) {}
async handle(event: EmailSubscriptionUnsubscribedEvent): Promise<void> {
const user = await this.userRepository.findOneByEmail(event.payload.userEmail)
if (user === null) {
return
}
await this.settingsService.createOrReplace({
user,
props: {
name: this.getSettingNameFromLevel(event.payload.level),
unencryptedValue: 'muted',
sensitive: false,
},
})
}
private getSettingNameFromLevel(level: string): string {
switch (level) {
case EmailLevel.LEVELS.FailedCloudBackup:
return SettingName.MuteFailedCloudBackupsEmails
case EmailLevel.LEVELS.FailedEmailBackup:
return SettingName.MuteFailedBackupsEmails
case EmailLevel.LEVELS.Marketing:
return SettingName.MuteMarketingEmails
case EmailLevel.LEVELS.SignIn:
return SettingName.MuteSignInEmails
default:
throw new Error(`Unknown level: ${level}`)
}
}
}

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,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.9.56](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.9.55...@standardnotes/domain-events-infra@1.9.56) (2022-12-12)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [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.56",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,40 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.104.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.104.0...@standardnotes/domain-events@2.104.1) (2022-12-12)
### Bug Fixes
* **domain-events:** add additional domain event services ([2980c42](https://github.com/standardnotes/server/commit/2980c42e88b6be5f065c91c86bf85a706975f801))
# [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.1",
"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

@@ -10,4 +10,7 @@ export enum DomainEventService {
Scheduler = 'scheduler',
Workspace = 'workspace',
Analytics = 'analytics',
Revisions = 'revisions',
Email = 'email',
Settings = 'settings',
}

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,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.6.53](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.6.52...@standardnotes/event-store@1.6.53) (2022-12-12)
**Note:** Version bump only for package @standardnotes/event-store
## [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.53",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.52](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.8.51...@standardnotes/files-server@1.8.52) (2022-12-12)
**Note:** Version bump only for package @standardnotes/files-server
## [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.52",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,36 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.9.26](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.25...@standardnotes/revisions-server@1.9.26) (2022-12-12)
### Bug Fixes
* **revisions:** responses to match previous response structure ([530a426](https://github.com/standardnotes/server/commit/530a42660157b63d034cd2228fc83a3fcce921e0))
## [1.9.25](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.9.24...@standardnotes/revisions-server@1.9.25) (2022-12-12)
**Note:** Version bump only for package @standardnotes/revisions-server
## [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.26",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -2,11 +2,13 @@ import { Logger } from 'winston'
import { HttpResponse, HttpStatusCode } from '@standardnotes/api'
import { GetRevisionsMetada } from '../Domain/UseCase/GetRevisionsMetada/GetRevisionsMetada'
import { GetRevisionsMetadataRequestParams } from '../Infra/Http/GetRevisionsMetadataRequestParams'
import { GetRevisionRequestParams } from '../Infra/Http/GetRevisionRequestParams'
import { GetRevisionsMetadataRequestParams } from '../Infra/Http/Request/GetRevisionsMetadataRequestParams'
import { GetRevisionRequestParams } from '../Infra/Http/Request/GetRevisionRequestParams'
import { DeleteRevisionRequestParams } from '../Infra/Http/Request/DeleteRevisionRequestParams'
import { GetRevision } from '../Domain/UseCase/GetRevision/GetRevision'
import { DeleteRevision } from '../Domain/UseCase/DeleteRevision/DeleteRevision'
import { DeleteRevisionRequestParams } from '../Infra/Http/DeleteRevisionRequestParams'
import { GetRevisionsMetadataResponse } from '../Infra/Http/Response/GetRevisionsMetadataResponse'
import { GetRevisionResponse } from '../Infra/Http/Response/GetRevisionResponse'
export class RevisionsController {
constructor(
@@ -16,7 +18,7 @@ export class RevisionsController {
private logger: Logger,
) {}
async getRevisions(params: GetRevisionsMetadataRequestParams): Promise<HttpResponse> {
async getRevisions(params: GetRevisionsMetadataRequestParams): Promise<GetRevisionsMetadataResponse> {
const revisionMetadataOrError = await this.getRevisionsMetadata.execute({
itemUuid: params.itemUuid,
userUuid: params.userUuid,
@@ -41,7 +43,7 @@ export class RevisionsController {
}
}
async getRevision(params: GetRevisionRequestParams): Promise<HttpResponse> {
async getRevision(params: GetRevisionRequestParams): Promise<GetRevisionResponse> {
const revisionOrError = await this.doGetRevision.execute({
revisionUuid: params.revisionUuid,
userUuid: params.userUuid,

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { GetRevisionResponseBody } from './GetRevisionResponseBody'
export interface GetRevisionResponse extends HttpResponse {
data: Either<GetRevisionResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,5 @@
import { Revision } from '../../../Domain/Revision/Revision'
export interface GetRevisionResponseBody {
revision: Revision
}

View File

@@ -0,0 +1,8 @@
import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
import { Either } from '@standardnotes/common'
import { GetRevisionsMetadataResponseBody } from './GetRevisionsMetadataResponseBody'
export interface GetRevisionsMetadataResponse extends HttpResponse {
data: Either<GetRevisionsMetadataResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,5 @@
import { RevisionMetadata } from '../../../Domain/Revision/RevisionMetadata'
export interface GetRevisionsMetadataResponseBody {
revisions: Array<RevisionMetadata>
}

View File

@@ -18,7 +18,7 @@ export class InversifyExpressRevisionsController extends BaseHttpController {
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
return this.json(result.data.error ? result.data : result.data.revisions, result.status)
}
@httpGet('/:uuid')
@@ -28,7 +28,7 @@ export class InversifyExpressRevisionsController extends BaseHttpController {
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
return this.json(result.data.error ? result.data : result.data.revision, result.status)
}
@httpDelete('/:uuid')

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.15.6](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.15.5...@standardnotes/scheduler-server@1.15.6) (2022-12-12)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [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.6",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

View File

@@ -3,6 +3,110 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.26.3](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.26.2...@standardnotes/syncing-server@1.26.3) (2022-12-15)
### Bug Fixes
* **syncing-server:** select fields in query for revisions ([ce53c45](https://github.com/standardnotes/syncing-server-js/commit/ce53c459e6ad0d469fcd0ebd7bf4caeb0e1d9c9c))
## [1.26.2](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.26.1...@standardnotes/syncing-server@1.26.2) (2022-12-14)
### Bug Fixes
* **syncing-server:** revisions procedure logs ([1e2b496](https://github.com/standardnotes/syncing-server-js/commit/1e2b496f4f87fd49ae8fba8ed9b76d3b6a2c31fa))
## [1.26.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.26.0...@standardnotes/syncing-server@1.26.1) (2022-12-14)
### Bug Fixes
* **syncing-server:** revisions procedure logs ([22fba8b](https://github.com/standardnotes/syncing-server-js/commit/22fba8ba806115b0f4bb4b083ae8595a3f0010b0))
# [1.26.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.6...@standardnotes/syncing-server@1.26.0) (2022-12-14)
### Features
* **syncing-server:** change revisions procedure to pagination instead of streaming ([4b1fe3b](https://github.com/standardnotes/syncing-server-js/commit/4b1fe3ba91594858e15cbdfbc21062c428dd03b4))
## [1.25.6](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.5...@standardnotes/syncing-server@1.25.6) (2022-12-14)
### Bug Fixes
* **syncing-server:** additional stream events handling on revisions procedure ([2ec28e5](https://github.com/standardnotes/syncing-server-js/commit/2ec28e541efa2bd9172431d45c5c1560692a912c))
## [1.25.5](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.4...@standardnotes/syncing-server@1.25.5) (2022-12-14)
### Bug Fixes
* **syncing-server:** revisions procedure with env var defined ranges ([9b27547](https://github.com/standardnotes/syncing-server-js/commit/9b27547dae1e5d5e6d071a069803e2bf3f8acdda))
## [1.25.4](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.3...@standardnotes/syncing-server@1.25.4) (2022-12-13)
### Bug Fixes
* **syncing-server:** logs on revisions procedure ([225e0aa](https://github.com/standardnotes/syncing-server-js/commit/225e0aaf88a396bf308c2e5eed0bb6e130cb2d64))
## [1.25.3](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.2...@standardnotes/syncing-server@1.25.3) (2022-12-13)
### Bug Fixes
* **syncing-server:** revisions ownership procedure destructured ([124c443](https://github.com/standardnotes/syncing-server-js/commit/124c4435285c2c2e8d0ce8b47907ebd47af27576))
## [1.25.2](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.1...@standardnotes/syncing-server@1.25.2) (2022-12-13)
### Bug Fixes
* **syncing-server:** change revisions migration to notes ([c419f1c](https://github.com/standardnotes/syncing-server-js/commit/c419f1ce220c27acabfc813a30b3edd6c4aadaa1))
## [1.25.1](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.25.0...@standardnotes/syncing-server@1.25.1) (2022-12-13)
### Bug Fixes
* **syncing-server:** revisions procedure properties ([cd101b9](https://github.com/standardnotes/syncing-server-js/commit/cd101b96eae8969a4dd2387deb1d4e8679ead216))
# [1.25.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.7...@standardnotes/syncing-server@1.25.0) (2022-12-12)
### Features
* **syncing-server:** fix streaming items for revisions update ([a55a995](https://github.com/standardnotes/syncing-server-js/commit/a55a9956602bee7dbb0f93f058aceff7a2136ffd))
## [1.24.7](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.6...@standardnotes/syncing-server@1.24.7) (2022-12-12)
### Bug Fixes
* **syncing-server:** revisions updating - select fields ([4ff8030](https://github.com/standardnotes/syncing-server-js/commit/4ff8030f8709ee18853c2e782cfc5d99c826f074))
## [1.24.6](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.24.5...@standardnotes/syncing-server@1.24.6) (2022-12-12)
**Note:** Version bump only for package @standardnotes/syncing-server
## [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

@@ -10,45 +10,69 @@ import { Env } from '../src/Bootstrap/Env'
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { ItemRepositoryInterface } from '../src/Domain/Item/ItemRepositoryInterface'
import { Stream } from 'stream'
import { ContentType } from '@standardnotes/common'
const fixRevisionsOwnership = async (
year: number,
month: number,
itemRepository: ItemRepositoryInterface,
domainEventFactory: DomainEventFactoryInterface,
domainEventPublisher: DomainEventPublisherInterface,
logger: Logger,
): Promise<void> => {
const stream = await itemRepository.streamAll({
sortBy: 'updated_at_timestamp',
const createdAfter = new Date(`${year}-${month}-1`)
const createdBefore = new Date(`${month !== 12 ? year : year + 1}-${month !== 12 ? month + 1 : 1}-1`)
logger.info(`Processing items between ${createdAfter.toISOString()} and ${createdBefore.toISOString()}`)
const itemsCount = await itemRepository.countAll({
createdBetween: [createdAfter, createdBefore],
selectFields: ['uuid', 'userUuid'],
contentType: [ContentType.Note, ContentType.File],
sortOrder: 'ASC',
createdBefore: new Date('2022-11-23'),
selectFields: ['user_uuid', 'item_uuid'],
sortBy: 'uuid',
})
return new Promise((resolve, reject) => {
stream
.pipe(
new Stream.Transform({
objectMode: true,
transform: async (rawItemData, _encoding, callback) => {
try {
await domainEventPublisher.publish(
domainEventFactory.createRevisionsOwnershipUpdateRequestedEvent({
userUuid: rawItemData.item_user_uuid,
itemUuid: rawItemData.item_uuid,
}),
)
} catch (error) {
logger.error(`Could not process item ${rawItemData.item_uuid}: ${(error as Error).message}`)
}
logger.info(`There are ${itemsCount} items between ${createdAfter.toISOString()} and ${createdBefore.toISOString()}`)
callback()
},
const limit = 500
const amountOfPages = Math.ceil(itemsCount / limit)
const tenPercentOfPages = Math.ceil(amountOfPages / 10)
let itemsProcessedCounter = 0
let itemsSkippedCounter = 0
for (let page = 1; page <= amountOfPages; page++) {
if (page % tenPercentOfPages === 0) {
logger.info(
`Processing page ${page} of ${amountOfPages} items between ${createdAfter.toISOString()} and ${createdBefore.toISOString()}. Processed successfully ${itemsProcessedCounter} items. Skipped ${itemsSkippedCounter} items.`,
)
}
const items = await itemRepository.findAll({
createdBetween: [createdAfter, createdBefore],
selectFields: ['uuid', 'userUuid'],
contentType: [ContentType.Note, ContentType.File],
offset: (page - 1) * limit,
limit,
sortOrder: 'ASC',
sortBy: 'uuid',
})
for (const item of items) {
if (!item.userUuid || !item.uuid) {
itemsSkippedCounter++
continue
}
await domainEventPublisher.publish(
domainEventFactory.createRevisionsOwnershipUpdateRequestedEvent({
userUuid: item.userUuid,
itemUuid: item.uuid,
}),
)
.on('finish', resolve)
.on('error', reject)
})
itemsProcessedCounter++
}
}
}
const container = new ContainerConfigLoader()
@@ -64,7 +88,19 @@ void container.load().then((container) => {
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.DomainEventFactory)
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.DomainEventPublisher)
Promise.resolve(fixRevisionsOwnership(itemRepository, domainEventFactory, domainEventPublisher, logger))
const years = env.get('REVISION_YEARS').split(',')
const months = env.get('REVISION_MONTHS').split(',')
const promises = []
for (const year of years) {
for (const month of months) {
promises.push(
fixRevisionsOwnership(+year, +month, itemRepository, domainEventFactory, domainEventPublisher, logger),
)
}
}
Promise.all(promises)
.then(() => {
logger.info('revisions ownership fix complete.')

View File

@@ -5,33 +5,33 @@ COMMAND=$1 && shift 1
case "$COMMAND" in
'start-local')
echo "Starting Web in Local Mode..."
echo "[Docker] Starting Web in Local Mode..."
yarn workspace @standardnotes/syncing-server start:local
;;
'start-web' )
echo "Starting Web..."
echo "[Docker] Starting Web..."
yarn workspace @standardnotes/syncing-server start
;;
'start-worker' )
echo "Starting Worker..."
echo "[Docker] Starting Worker..."
yarn workspace @standardnotes/syncing-server worker
;;
'content-size-recalculate' )
echo "Starting Content Size Recalculation..."
echo "[Docker] Starting Content Size Recalculation..."
USER_UUID=$1 && shift 1
yarn workspace @standardnotes/syncing-server content-size $USER_UUID
;;
'revisions-ownership-fix' )
echo "Starting Revisions Ownership Fixing..."
echo "[Docker] Starting Revisions Ownership Fixing..."
yarn workspace @standardnotes/syncing-server revisions-ownership
;;
* )
echo "Unknown command"
echo "[Docker] Unknown command"
;;
esac

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.23.0",
"version": "1.26.3",
"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

@@ -1,14 +1,14 @@
export type ItemQuery = {
userUuid?: string
sortBy: string
sortOrder: 'ASC' | 'DESC'
sortBy?: string
sortOrder?: 'ASC' | 'DESC'
uuids?: Array<string>
lastSyncTime?: number
syncTimeComparison?: '>' | '>='
contentType?: string
contentType?: string | string[]
deleted?: boolean
offset?: number
limit?: number
createdBefore?: Date
createdBetween?: Date[]
selectFields?: string[]
}

View File

@@ -1,401 +0,0 @@
import 'reflect-metadata'
import { Repository, SelectQueryBuilder } from 'typeorm'
import { ContentType } from '@standardnotes/common'
import { Item } from '../../Domain/Item/Item'
import { MySQLItemRepository } from './MySQLItemRepository'
import { TimerInterface } from '@standardnotes/time'
import { ReadStream } from 'fs'
describe('MySQLItemRepository', () => {
let queryBuilder: SelectQueryBuilder<Item>
let ormRepository: Repository<Item>
let item: Item
let timer: TimerInterface
const createRepository = () => new MySQLItemRepository(ormRepository)
beforeEach(() => {
queryBuilder = {} as jest.Mocked<SelectQueryBuilder<Item>>
item = {} as jest.Mocked<Item>
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn(() => 1616161616161616)
ormRepository = {} as jest.Mocked<Repository<Item>>
ormRepository.save = jest.fn()
ormRepository.remove = jest.fn()
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
})
it('should save', async () => {
await createRepository().save(item)
expect(ormRepository.save).toHaveBeenCalledWith(item)
})
it('should remove', async () => {
await createRepository().remove(item)
expect(ormRepository.remove).toHaveBeenCalledWith(item)
})
it('should delete all items for a given user', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.delete = jest.fn().mockReturnThis()
queryBuilder.from = jest.fn().mockReturnThis()
queryBuilder.execute = jest.fn()
await createRepository().deleteByUserUuid('123')
expect(queryBuilder.delete).toHaveBeenCalled()
expect(queryBuilder.from).toHaveBeenCalledWith('items')
expect(queryBuilder.where).toHaveBeenCalledWith('user_uuid = :userUuid', { userUuid: '123' })
expect(queryBuilder.execute).toHaveBeenCalled()
})
it('should find one item by uuid and user uuid', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.getOne = jest.fn().mockReturnValue(item)
const result = await createRepository().findByUuidAndUserUuid('1-2-3', '2-3-4')
expect(queryBuilder.where).toHaveBeenCalledWith('item.uuid = :uuid AND item.user_uuid = :userUuid', {
uuid: '1-2-3',
userUuid: '2-3-4',
})
expect(result).toEqual(item)
})
it('should find one item by uuid', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.getOne = jest.fn().mockReturnValue(item)
const result = await createRepository().findByUuid('1-2-3')
expect(queryBuilder.where).toHaveBeenCalledWith('item.uuid = :uuid', {
uuid: '1-2-3',
})
expect(result).toEqual(item)
})
it('should find items by all query criteria filled in', async () => {
queryBuilder.getMany = jest.fn().mockReturnValue([item])
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
queryBuilder.orderBy = jest.fn()
queryBuilder.skip = jest.fn()
queryBuilder.take = jest.fn()
const result = await createRepository().findAll({
userUuid: '1-2-3',
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
deleted: false,
contentType: ContentType.Note,
lastSyncTime: 123,
syncTimeComparison: '>=',
uuids: ['2-3-4'],
offset: 1,
limit: 10,
})
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(4)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.uuid IN (:...uuids)', { uuids: ['2-3-4'] })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(2, 'item.deleted = :deleted', { deleted: false })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(3, 'item.content_type = :contentType', {
contentType: 'Note',
})
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(4, 'item.updated_at_timestamp >= :lastSyncTime', {
lastSyncTime: 123,
})
expect(queryBuilder.skip).toHaveBeenCalledWith(1)
expect(queryBuilder.take).toHaveBeenCalledWith(10)
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual([item])
})
it('should stream items by all query criteria filled in', async () => {
const stream = {} as jest.Mocked<ReadStream>
queryBuilder.stream = jest.fn().mockReturnValue(stream)
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
queryBuilder.orderBy = jest.fn()
queryBuilder.skip = jest.fn()
queryBuilder.take = jest.fn()
const result = await createRepository().streamAll({
userUuid: '1-2-3',
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
deleted: false,
contentType: ContentType.Note,
lastSyncTime: 123,
syncTimeComparison: '>=',
uuids: ['2-3-4'],
offset: 1,
limit: 10,
})
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(4)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.uuid IN (:...uuids)', { uuids: ['2-3-4'] })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(2, 'item.deleted = :deleted', { deleted: false })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(3, 'item.content_type = :contentType', {
contentType: 'Note',
})
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(4, 'item.updated_at_timestamp >= :lastSyncTime', {
lastSyncTime: 123,
})
expect(queryBuilder.skip).toHaveBeenCalledWith(1)
expect(queryBuilder.take).toHaveBeenCalledWith(10)
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual(stream)
})
it('should find items content sizes by all query criteria filled in', async () => {
queryBuilder.getRawMany = jest.fn().mockReturnValue([{ uuid: item.uuid, contentSize: item.contentSize }])
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
queryBuilder.orderBy = jest.fn()
queryBuilder.select = jest.fn()
queryBuilder.addSelect = jest.fn()
queryBuilder.skip = jest.fn()
queryBuilder.take = jest.fn()
const result = await createRepository().findContentSizeForComputingTransferLimit({
userUuid: '1-2-3',
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
deleted: false,
contentType: ContentType.Note,
lastSyncTime: 123,
syncTimeComparison: '>=',
uuids: ['2-3-4'],
offset: 1,
limit: 10,
})
expect(queryBuilder.select).toHaveBeenCalledWith('item.uuid', 'uuid')
expect(queryBuilder.addSelect).toHaveBeenCalledWith('item.content_size', 'contentSize')
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(4)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.uuid IN (:...uuids)', { uuids: ['2-3-4'] })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(2, 'item.deleted = :deleted', { deleted: false })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(3, 'item.content_type = :contentType', {
contentType: 'Note',
})
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(4, 'item.updated_at_timestamp >= :lastSyncTime', {
lastSyncTime: 123,
})
expect(queryBuilder.skip).toHaveBeenCalledWith(1)
expect(queryBuilder.take).toHaveBeenCalledWith(10)
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual([item])
})
it('should find items by all query criteria filled in', async () => {
queryBuilder.getMany = jest.fn().mockReturnValue([item])
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
queryBuilder.orderBy = jest.fn()
queryBuilder.skip = jest.fn()
queryBuilder.take = jest.fn()
const result = await createRepository().findAll({
userUuid: '1-2-3',
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
deleted: false,
contentType: ContentType.Note,
lastSyncTime: 123,
syncTimeComparison: '>=',
uuids: ['2-3-4'],
offset: 1,
limit: 10,
})
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(4)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.uuid IN (:...uuids)', { uuids: ['2-3-4'] })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(2, 'item.deleted = :deleted', { deleted: false })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(3, 'item.content_type = :contentType', {
contentType: 'Note',
})
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(4, 'item.updated_at_timestamp >= :lastSyncTime', {
lastSyncTime: 123,
})
expect(queryBuilder.skip).toHaveBeenCalledWith(1)
expect(queryBuilder.take).toHaveBeenCalledWith(10)
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual([item])
})
it('should count items by all query criteria filled in', async () => {
queryBuilder.getCount = jest.fn().mockReturnValue(1)
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
queryBuilder.orderBy = jest.fn()
queryBuilder.skip = jest.fn()
queryBuilder.take = jest.fn()
const result = await createRepository().countAll({
userUuid: '1-2-3',
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
deleted: false,
contentType: ContentType.Note,
lastSyncTime: 123,
syncTimeComparison: '>=',
uuids: ['2-3-4'],
offset: 1,
limit: 10,
})
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(4)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.uuid IN (:...uuids)', { uuids: ['2-3-4'] })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(2, 'item.deleted = :deleted', { deleted: false })
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(3, 'item.content_type = :contentType', {
contentType: 'Note',
})
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(4, 'item.updated_at_timestamp >= :lastSyncTime', {
lastSyncTime: 123,
})
expect(queryBuilder.skip).toHaveBeenCalledWith(1)
expect(queryBuilder.take).toHaveBeenCalledWith(10)
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual(1)
})
it('should find items by only mandatory query criteria', async () => {
queryBuilder.getMany = jest.fn().mockReturnValue([item])
queryBuilder.where = jest.fn()
queryBuilder.orderBy = jest.fn()
const result = await createRepository().findAll({
sortBy: 'updated_at_timestamp',
sortOrder: 'DESC',
})
expect(queryBuilder.orderBy).toHaveBeenCalledWith('item.updated_at_timestamp', 'DESC')
expect(result).toEqual([item])
})
it('should find dates for computing integrity hash', async () => {
queryBuilder.getRawMany = jest
.fn()
.mockReturnValue([{ updated_at_timestamp: 1616164633241312 }, { updated_at_timestamp: 1616164633242313 }])
queryBuilder.select = jest.fn()
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
const result = await createRepository().findDatesForComputingIntegrityHash('1-2-3')
expect(queryBuilder.select).toHaveBeenCalledWith('item.updated_at_timestamp')
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.deleted = :deleted', { deleted: false })
expect(result.length).toEqual(2)
expect(result[0]).toEqual({ updated_at_timestamp: 1616164633242313 })
expect(result[1]).toEqual({ updated_at_timestamp: 1616164633241312 })
})
it('should find items for computing integrity payloads', async () => {
queryBuilder.getRawMany = jest.fn().mockReturnValue([
{ uuid: '1-2-3', updated_at_timestamp: 1616164633241312, content_type: ContentType.Note },
{ uuid: '2-3-4', updated_at_timestamp: 1616164633242313, content_type: ContentType.ItemsKey },
])
queryBuilder.select = jest.fn()
queryBuilder.addSelect = jest.fn()
queryBuilder.where = jest.fn()
queryBuilder.andWhere = jest.fn()
const result = await createRepository().findItemsForComputingIntegrityPayloads('1-2-3')
expect(queryBuilder.select).toHaveBeenCalledWith('item.uuid', 'uuid')
expect(queryBuilder.addSelect).toHaveBeenNthCalledWith(1, 'item.updated_at_timestamp', 'updated_at_timestamp')
expect(queryBuilder.addSelect).toHaveBeenNthCalledWith(2, 'item.content_type', 'content_type')
expect(queryBuilder.where).toHaveBeenCalledTimes(1)
expect(queryBuilder.where).toHaveBeenNthCalledWith(1, 'item.user_uuid = :userUuid', { userUuid: '1-2-3' })
expect(queryBuilder.andWhere).toHaveBeenCalledTimes(1)
expect(queryBuilder.andWhere).toHaveBeenNthCalledWith(1, 'item.deleted = :deleted', { deleted: false })
expect(result.length).toEqual(2)
expect(result[0]).toEqual({
uuid: '2-3-4',
updated_at_timestamp: 1616164633242313,
content_type: ContentType.ItemsKey,
})
expect(result[1]).toEqual({ uuid: '1-2-3', updated_at_timestamp: 1616164633241312, content_type: ContentType.Note })
})
it('should find item by uuid and mark it for deletion', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.update = jest.fn().mockReturnThis()
queryBuilder.update().set = jest.fn().mockReturnThis()
queryBuilder.execute = jest.fn()
const item = { uuid: 'e-1-2-3' } as jest.Mocked<Item>
const updatedAtTimestamp = timer.getTimestampInMicroseconds()
await createRepository().markItemsAsDeleted([item.uuid], updatedAtTimestamp)
expect(queryBuilder.update).toHaveBeenCalled()
expect(queryBuilder.update().set).toHaveBeenCalledWith(
expect.objectContaining({
deleted: true,
content: null,
encItemKey: null,
authHash: null,
updatedAtTimestamp: expect.anything(),
}),
)
expect(queryBuilder.where).toHaveBeenCalledWith('uuid IN (:...uuids)', {
uuids: ['e-1-2-3'],
})
expect(queryBuilder.execute).toHaveBeenCalled()
})
it('should update item content size', async () => {
queryBuilder.where = jest.fn().mockReturnThis()
queryBuilder.update = jest.fn().mockReturnThis()
queryBuilder.update().set = jest.fn().mockReturnThis()
queryBuilder.execute = jest.fn()
await createRepository().updateContentSize('1-2-3', 345)
expect(queryBuilder.update).toHaveBeenCalled()
expect(queryBuilder.update().set).toHaveBeenCalledWith(
expect.objectContaining({
contentSize: 345,
}),
)
expect(queryBuilder.where).toHaveBeenCalledWith('uuid = :itemUuid', {
itemUuid: '1-2-3',
})
expect(queryBuilder.execute).toHaveBeenCalled()
})
})

View File

@@ -131,10 +131,12 @@ export class MySQLItemRepository implements ItemRepositoryInterface {
private createFindAllQueryBuilder(query: ItemQuery): SelectQueryBuilder<Item> {
const queryBuilder = this.ormRepository.createQueryBuilder('item')
queryBuilder.orderBy(`item.${query.sortBy}`, query.sortOrder)
if (query.sortBy !== undefined && query.sortOrder !== undefined) {
queryBuilder.orderBy(`item.${query.sortBy}`, query.sortOrder)
}
if (query.selectFields !== undefined) {
queryBuilder.select(query.selectFields.map((field) => `item.${field}`))
queryBuilder.select(query.selectFields)
}
if (query.userUuid !== undefined) {
queryBuilder.where('item.user_uuid = :userUuid', { userUuid: query.userUuid })
@@ -146,15 +148,22 @@ export class MySQLItemRepository implements ItemRepositoryInterface {
queryBuilder.andWhere('item.deleted = :deleted', { deleted: query.deleted })
}
if (query.contentType) {
queryBuilder.andWhere('item.content_type = :contentType', { contentType: query.contentType })
if (Array.isArray(query.contentType)) {
queryBuilder.andWhere('item.content_type IN (:...contentTypes)', { contentTypes: query.contentType })
} else {
queryBuilder.andWhere('item.content_type = :contentType', { contentType: query.contentType })
}
}
if (query.lastSyncTime && query.syncTimeComparison) {
queryBuilder.andWhere(`item.updated_at_timestamp ${query.syncTimeComparison} :lastSyncTime`, {
lastSyncTime: query.lastSyncTime,
})
}
if (query.createdBefore !== undefined) {
queryBuilder.andWhere('item.created_at < :createdAt', { createdAt: query.createdBefore.toISOString() })
if (query.createdBetween !== undefined) {
queryBuilder.andWhere('item.created_at BETWEEN :createdAfter AND :createdBefore', {
createdAfter: query.createdBetween[0].toISOString(),
createdBefore: query.createdBetween[1].toISOString(),
})
}
if (query.offset !== undefined) {
queryBuilder.skip(query.offset)

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.4.53](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.4.52...@standardnotes/websockets-server@1.4.53) (2022-12-12)
**Note:** Version bump only for package @standardnotes/websockets-server
## [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.53",
"engines": {
"node": ">=18.0.0 <19.0.0"
},

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.18.4](https://github.com/standardnotes/server/compare/@standardnotes/workspace-server@1.18.3...@standardnotes/workspace-server@1.18.4) (2022-12-12)
**Note:** Version bump only for package @standardnotes/workspace-server
## [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.4",
"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"