feat(scheduler): add scheduled emails contents

This commit is contained in:
Karol Sójko 2022-12-07 11:09:59 +01:00
parent ec9e9ec387
commit 6e0855f9b3
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
15 changed files with 221 additions and 270 deletions

1
.pnp.cjs generated
View file

@ -3056,6 +3056,7 @@ const RAW_RUNTIME_STATE =
["@newrelic/winston-enricher", "virtual:c66bf20e88479ada0172094776519a9f51acc4731d22079b60a295bcec7ea42d5545cbce58a77a50d932bf953298799135e99707486e343da6d99ba1d167bdbd#npm:4.0.0"],\
["@sentry/node", "npm:7.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/predicates", "workspace:packages/predicates"],\

View file

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

View file

@ -27,6 +27,7 @@
"@newrelic/winston-enricher": "^4.0.0",
"@sentry/node": "^7.19.0",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-core": "workspace:^",
"@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/predicates": "workspace:*",

View file

@ -0,0 +1,9 @@
import { readFileSync } from 'fs'
export function getSubject(): string {
return 'Enable email backups for your account'
}
export function getBody(): string {
return readFileSync(`${__dirname}/encourage-email-backups.html`).toString()
}

View file

@ -0,0 +1,11 @@
import { readFileSync } from 'fs'
export function getSubject(): string {
return 'Checking in after one month with Standard Notes'
}
export function getBody(registrationDate: string): string {
const body = readFileSync(`${__dirname}/encourage-subscription-purchasing.html`).toString()
return body.replace('%%REGISTRATION_DATE%%', registrationDate)
}

View file

@ -0,0 +1,9 @@
import { readFileSync } from 'fs'
export function getSubject(): string {
return 'Can we ask why you canceled?'
}
export function getBody(): string {
return readFileSync(`${__dirname}/exit-interview.html`).toString()
}

View file

@ -0,0 +1,18 @@
<div>
<p>
Did you know you can enable daily email backups for your account? This <strong>free</strong> feature sends an
email to your inbox with an encrypted backup file including all your notes and tags.
</p>
<p>
Email backups are an important feature that help protect you against worst-case scenarios. Your backups can be
used to restore your account to a previous state, or to import old versions of notes into your present
account.
</p>
<p>
To enable free email backups, use the Standard Notes web or desktop app, and open Preferences > Backups > Email Backups.
</p>
<a href="https://standardnotes.com/help/28/how-do-i-enable-daily-email-backups">
Learn more about daily email backups →
</a>
</div>

View file

@ -0,0 +1,83 @@
<div>
<p>Hi there,</p>
<p>
We hope you've been finding great use out of Standard Notes. We built Standard Notes to be a secure place for
your most sensitive notes and files.
</p>
<p>
As a reminder,
<strong>
<em>you signed up for the Standard Notes free plan on %%REGISTRATION_DATE%%</em>
</strong>
Your free account comes with standard features like end-to-end encryption, multiple-device sync, and
two-factor authentication.
</p>
<p>
If you're ready to advance your usage of Standard Notes, we recommend upgrading to one of our more powerful
plans.
</p>
<ul>
<li>
<p>
<strong>Productivity</strong> <strong>($59/year)</strong> powers up your editing experience with unique
and special-built note-types for markdown, rich text, spreadsheets, todo, and more.
</p>
</li>
<li>
<p>
<strong>Professional</strong> <strong>($99/year)</strong> gives you all the power of Productivity plus
100GB of end-to-end encrypted file storage for your private photos, videos, and documents, plus family
subscription sharing with up to 5 people.
</p>
</li>
</ul>
<p>
Professional comes with a 90-day money back guarantee, so if you're not completely satisfied, we're happy to
refund your full purchase amount.
</p>
<p>
<strong>
<a href="https://standardnotes.com/plans">Upgrade your plan →</a>
</strong>
</p>
<p>
<strong>
<a href="https://standardnotes.com/features">Learn more about the features →</a>
</strong>
</p>
<p>
<strong>Questions & Answers</strong>
</p>
<p>
<em>How does Standard Notes compare with conventional note-taking apps?</em>
</p>
<p>
Data you store with Standard Notes is encrypted with end-to-end encryption using a key only you know. Because
of this, we can't read your notes, and neither can anyone else.
</p>
<p>
<em>What kind of notes should I store in Standard Notes?</em>
</p>
<p>
This question can be reframed as: "What shouldn't I store in non-private services?" This would include
sensitive/sensual data related to your health and wellness, secrets and keys, notes and documents with
personally identifiable information that, if leaked, would lead to the theft of your identity, and business,
financial, or legal information which cover non-public or confidential information.
</p>
<p>
<em>Where can I access my notes?</em>
</p>
<p>
Providing you with easy access to your notes and files on all your devices is a key focus for us. We provide
secure and well-designed applications for your web browser, desktop (macOS, Windows, Linux,) and mobile
(Android and iOS).
</p>
<p>
<em>I have more questions.</em>
</p>
<p>
We love questions. Head over to our Help page to see if your question is answered there. If not, reply
directly to this email or send an email to <a href="help@standardnotes.com">help@standardnotes.com</a> and
we'd be happy to help.
</p>
</div>,

View file

@ -0,0 +1,28 @@
<div>
<p>
We're truly sad to see you leave. Our mission is simple: build the best, most private, and most secure
note-taking app available. It's clear we've fallen short of your expectations somewhere along the way.
</p>
<p>
We just want you to know—if price was the reason you canceled, we're not willing to lose you. That's no issue
for us and we're happy to work out something that fits better with your budget. If price is your primary
concern, please click the link below, and we'll get in touch with some options.
</p>
<a href="https://app.standardnotes.com/?user-request=exit-discount">Apply For A Limited Discount Offer →</a>
<p>
If you canceled for another reason, such as a missing feature, or a feature that wasn't behaving or working as
you expected, please let us know! We build this product for you, and feedback from customers like yourself who
are willing to pay for a product is most crucial for us as we continue to evolve and iterate on Standard
Notes.
</p>
<p>If you have a minute, please fill out this brief exit interview: </p>
<a href="https://standardnotes.typeform.com/to/dX5lzPtm">Short Exit Interview →</a>
<p>
Our team reads every single response, and your feedback will be shared with the relevant department within our
team.
</p>
<p>
If you have any other thoughts or questions, please feel free to reply directly to this email, and a member of
our support team will be in touch with you.
</p>
</div>

View file

@ -1,223 +0,0 @@
import 'reflect-metadata'
import { EmailMessageIdentifier } from '@standardnotes/common'
import { TimerInterface } from '@standardnotes/time'
import { DomainEventFactory } from './DomainEventFactory'
import { PredicateAuthority, PredicateName } from '@standardnotes/predicates'
import { Job } from '../Job/Job'
import { Predicate } from '../Predicate/Predicate'
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 DISCOUNT_APPLY_REQUESTED event', () => {
expect(
createFactory().createDiscountApplyRequestedEvent({
userEmail: 'test@test.te',
discountCode: 'off-10',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: 'test@test.te',
userIdentifierType: 'email',
},
origin: 'scheduler',
},
payload: {
userEmail: 'test@test.te',
discountCode: 'off-10',
},
type: 'DISCOUNT_APPLY_REQUESTED',
})
})
it('should create a DISCOUNT_WITHDRAW_REQUESTED event', () => {
expect(
createFactory().createDiscountWithdrawRequestedEvent({
userEmail: 'test@test.te',
discountCode: 'off-10',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: 'test@test.te',
userIdentifierType: 'email',
},
origin: 'scheduler',
},
payload: {
userEmail: 'test@test.te',
discountCode: 'off-10',
},
type: 'DISCOUNT_WITHDRAW_REQUESTED',
})
})
it('should create a EXIT_DISCOUNT_WITHDRAW_REQUESTED event', () => {
expect(
createFactory().createExitDiscountWithdrawRequestedEvent({
userEmail: 'test@test.te',
discountCode: 'exit-20',
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: 'test@test.te',
userIdentifierType: 'email',
},
origin: 'scheduler',
},
payload: {
userEmail: 'test@test.te',
discountCode: 'exit-20',
},
type: 'EXIT_DISCOUNT_WITHDRAW_REQUESTED',
})
})
it('should create a EMAIL_MESSAGE_REQUESTED event', () => {
expect(
createFactory().createEmailMessageRequestedEvent({
userEmail: 'test@test.te',
messageIdentifier: EmailMessageIdentifier.ENCOURAGE_EMAIL_BACKUPS,
context: {
foo: 'bar',
},
}),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: 'test@test.te',
userIdentifierType: 'email',
},
origin: 'scheduler',
},
payload: {
messageIdentifier: 'ENCOURAGE_EMAIL_BACKUPS',
userEmail: 'test@test.te',
context: {
foo: 'bar',
},
},
type: 'EMAIL_MESSAGE_REQUESTED',
})
})
it('should create a PREDICATE_VERIFICATION_REQUESTED event dedicated for auth', () => {
expect(
createFactory().createPredicateVerificationRequestedEvent(
{
uuid: '1-2-3',
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
} as jest.Mocked<Job>,
{
authority: PredicateAuthority.Auth,
name: PredicateName.EmailBackupsEnabled,
status: 'pending',
} as jest.Mocked<Predicate>,
),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
},
origin: 'scheduler',
target: 'auth',
},
payload: {
predicate: {
authority: 'auth',
jobUuid: '1-2-3',
name: 'email-backups-enabled',
},
},
type: 'PREDICATE_VERIFICATION_REQUESTED',
})
})
it('should create a PREDICATE_VERIFICATION_REQUESTED event dedicated for syncing server', () => {
expect(
createFactory().createPredicateVerificationRequestedEvent(
{
uuid: '1-2-3',
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
} as jest.Mocked<Job>,
{
authority: PredicateAuthority.SyncingServer,
name: PredicateName.EmailBackupsEnabled,
status: 'pending',
} as jest.Mocked<Predicate>,
),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
},
origin: 'scheduler',
target: 'syncing-server',
},
payload: {
predicate: {
authority: 'syncing-server',
jobUuid: '1-2-3',
name: 'email-backups-enabled',
},
},
type: 'PREDICATE_VERIFICATION_REQUESTED',
})
})
it('should create a PREDICATE_VERIFICATION_REQUESTED event dedicated for unknown target', () => {
expect(
createFactory().createPredicateVerificationRequestedEvent(
{
uuid: '1-2-3',
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
} as jest.Mocked<Job>,
{
authority: 'foobar' as PredicateAuthority,
name: PredicateName.EmailBackupsEnabled,
status: 'pending',
} as jest.Mocked<Predicate>,
),
).toEqual({
createdAt: expect.any(Date),
meta: {
correlation: {
userIdentifier: '2-3-4',
userIdentifierType: 'uuid',
},
origin: 'scheduler',
},
payload: {
predicate: {
authority: 'foobar',
jobUuid: '1-2-3',
name: 'email-backups-enabled',
},
},
type: 'PREDICATE_VERIFICATION_REQUESTED',
})
})
})

View file

@ -1,9 +1,8 @@
import { EmailMessageIdentifier } from '@standardnotes/common'
import {
DiscountApplyRequestedEvent,
DiscountWithdrawRequestedEvent,
DomainEventService,
EmailMessageRequestedEvent,
EmailRequestedEvent,
ExitDiscountWithdrawRequestedEvent,
PredicateVerificationRequestedEvent,
} from '@standardnotes/domain-events'
@ -70,13 +69,15 @@ export class DomainEventFactory implements DomainEventFactoryInterface {
}
}
createEmailMessageRequestedEvent(dto: {
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: EmailMessageIdentifier
context: Record<string, unknown>
}): EmailMessageRequestedEvent {
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent {
return {
type: 'EMAIL_MESSAGE_REQUESTED',
type: 'EMAIL_REQUESTED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {

View file

@ -1,8 +1,7 @@
import { EmailMessageIdentifier } from '@standardnotes/common'
import {
DiscountApplyRequestedEvent,
DiscountWithdrawRequestedEvent,
EmailMessageRequestedEvent,
EmailRequestedEvent,
ExitDiscountWithdrawRequestedEvent,
PredicateVerificationRequestedEvent,
} from '@standardnotes/domain-events'
@ -12,11 +11,13 @@ import { Predicate } from '../Predicate/Predicate'
export interface DomainEventFactoryInterface {
createPredicateVerificationRequestedEvent(job: Job, predicate: Predicate): PredicateVerificationRequestedEvent
createEmailMessageRequestedEvent(dto: {
createEmailRequestedEvent(dto: {
userEmail: string
messageIdentifier: EmailMessageIdentifier
context: Record<string, unknown>
}): EmailMessageRequestedEvent
messageIdentifier: string
level: string
body: string
subject: string
}): EmailRequestedEvent
createDiscountApplyRequestedEvent(dto: { userEmail: string; discountCode: string }): DiscountApplyRequestedEvent
createDiscountWithdrawRequestedEvent(dto: { userEmail: string; discountCode: string }): DiscountWithdrawRequestedEvent
createExitDiscountWithdrawRequestedEvent(dto: {

View file

@ -6,6 +6,7 @@ import {
ExitDiscountWithdrawRequestedEvent,
} from '@standardnotes/domain-events'
import { PredicateName } from '@standardnotes/predicates'
import { TimerInterface } from '@standardnotes/time'
import 'reflect-metadata'
import { Logger } from 'winston'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
@ -26,13 +27,17 @@ describe('JobDoneInterpreter', () => {
let domainEventPublisher: DomainEventPublisherInterface
let job: Job
let logger: Logger
let timer: TimerInterface
const createInterpreter = () =>
new JobDoneInterpreter(jobRepository, predicateRepository, domainEventFactory, domainEventPublisher, logger)
new JobDoneInterpreter(jobRepository, predicateRepository, domainEventFactory, domainEventPublisher, timer, logger)
beforeEach(() => {
job = {} as jest.Mocked<Job>
timer = {} as jest.Mocked<TimerInterface>
timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date())
jobRepository = {} as jest.Mocked<JobRepositoryInterface>
jobRepository.findOneByUuid = jest.fn().mockReturnValue(job)
@ -40,7 +45,7 @@ describe('JobDoneInterpreter', () => {
predicateRepository.findByJobUuid = jest.fn().mockReturnValue([])
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailMessageRequestedEvent = jest
domainEventFactory.createEmailRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailMessageRequestedEvent>)
domainEventFactory.createDiscountApplyRequestedEvent = jest
@ -89,11 +94,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).toHaveBeenCalledWith({
context: {},
messageIdentifier: 'ENCOURAGE_EMAIL_BACKUPS',
userEmail: 'test@test.te',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
@ -111,7 +112,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
@ -124,7 +125,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
@ -143,11 +144,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).toHaveBeenCalledWith({
context: { userRegisteredAt: 123 },
messageIdentifier: 'ENCOURAGE_SUBSCRIPTION_PURCHASING',
userEmail: 'test@test.te',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
@ -160,7 +157,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
@ -173,11 +170,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).toHaveBeenCalledWith({
context: {},
messageIdentifier: 'EXIT_INTERVIEW',
userEmail: 'test@test.te',
})
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
@ -190,7 +183,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
@ -295,7 +288,7 @@ describe('JobDoneInterpreter', () => {
await createInterpreter().interpret('1-2-3')
expect(domainEventFactory.createEmailMessageRequestedEvent).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
})

View file

@ -1,8 +1,9 @@
import { EmailMessageIdentifier } from '@standardnotes/common'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { PredicateName } from '@standardnotes/predicates'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import { EmailLevel } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import TYPES from '../../Bootstrap/Types'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
@ -13,6 +14,15 @@ import { Job } from './Job'
import { JobDoneInterpreterInterface } from './JobDoneInterpreterInterface'
import { JobName } from './JobName'
import { JobRepositoryInterface } from './JobRepositoryInterface'
import { getSubject as getExitInterviewSubject, getBody as getExitInterviewBody } from '../Email/ExitInterview'
import {
getSubject as getEncourageEmailBackupsSubject,
getBody as getEncourageEmailBackupsBody,
} from '../Email/EncourageEmailBackups'
import {
getSubject as getEncourageSubscriptionPurchasingSubject,
getBody as getEncourageSubscriptionPurchasingBody,
} from '../Email/EncourageSubscriptionPurchasing'
@injectable()
export class JobDoneInterpreter implements JobDoneInterpreterInterface {
@ -21,6 +31,7 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
@inject(TYPES.PredicateRepository) private predicateRepository: PredicateRepositoryInterface,
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
@ -81,10 +92,12 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
this.logger.debug(`[${job.uuid}]${job.name}: requesting email backup encouragement email.`)
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailMessageRequestedEvent({
this.domainEventFactory.createEmailRequestedEvent({
userEmail: job.userIdentifier,
messageIdentifier: EmailMessageIdentifier.ENCOURAGE_EMAIL_BACKUPS,
context: {},
messageIdentifier: 'ENCOURAGE_EMAIL_BACKUPS',
subject: getEncourageEmailBackupsSubject(),
body: getEncourageEmailBackupsBody(),
level: EmailLevel.LEVELS.System,
}),
)
}
@ -93,12 +106,14 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
this.logger.debug(`[${job.uuid}]${job.name}: requesting subscription purchase encouragement email.`)
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailMessageRequestedEvent({
this.domainEventFactory.createEmailRequestedEvent({
userEmail: job.userIdentifier,
messageIdentifier: EmailMessageIdentifier.ENCOURAGE_SUBSCRIPTION_PURCHASING,
context: {
userRegisteredAt: job.createdAt,
},
messageIdentifier: 'ENCOURAGE_SUBSCRIPTION_PURCHASING',
subject: getEncourageSubscriptionPurchasingSubject(),
body: getEncourageSubscriptionPurchasingBody(
this.timer.convertMicrosecondsToDate(job.createdAt).toLocaleString(),
),
level: EmailLevel.LEVELS.System,
}),
)
}
@ -107,10 +122,12 @@ export class JobDoneInterpreter implements JobDoneInterpreterInterface {
this.logger.debug(`[${job.uuid}]${job.name}: requesting exit interview email.`)
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailMessageRequestedEvent({
this.domainEventFactory.createEmailRequestedEvent({
userEmail: job.userIdentifier,
messageIdentifier: EmailMessageIdentifier.EXIT_INTERVIEW,
context: {},
messageIdentifier: 'EXIT_INTERVIEW',
subject: getExitInterviewSubject(),
body: getExitInterviewBody(),
level: EmailLevel.LEVELS.System,
}),
)
}

View file

@ -2284,6 +2284,7 @@ __metadata:
"@newrelic/winston-enricher": "npm:^4.0.0"
"@sentry/node": "npm:^7.19.0"
"@standardnotes/common": "workspace:*"
"@standardnotes/domain-core": "workspace:^"
"@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*"
"@standardnotes/predicates": "workspace:*"