mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Merge pull request #33 from MichaelDeBoey/remove-agreement-code
feat: remove agreement code
This commit is contained in:
commit
44c751af8f
14 changed files with 29 additions and 597 deletions
|
@ -8,7 +8,6 @@ import {
|
||||||
OtherAccounts,
|
OtherAccounts,
|
||||||
Profile,
|
Profile,
|
||||||
OnboardingNavbar,
|
OnboardingNavbar,
|
||||||
Terms,
|
|
||||||
Welcome,
|
Welcome,
|
||||||
YourMaybe,
|
YourMaybe,
|
||||||
OnboardingBackground,
|
OnboardingBackground,
|
||||||
|
@ -29,8 +28,6 @@ function getStepComponent(stepKey?: string): (props: StepProps) => JSX.Element {
|
||||||
return AddFirstAccount
|
return AddFirstAccount
|
||||||
case 'accountSelection':
|
case 'accountSelection':
|
||||||
return OtherAccounts
|
return OtherAccounts
|
||||||
case 'terms':
|
|
||||||
return Terms
|
|
||||||
case 'maybe':
|
case 'maybe':
|
||||||
return YourMaybe
|
return YourMaybe
|
||||||
case 'welcome':
|
case 'welcome':
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useUserApi, useQueryParam, BrowserUtil } from '@maybe-finance/client/shared'
|
import { useQueryParam } from '@maybe-finance/client/shared'
|
||||||
import {
|
import {
|
||||||
AccountSidebar,
|
AccountSidebar,
|
||||||
BillingPreferences,
|
BillingPreferences,
|
||||||
|
@ -8,15 +8,11 @@ import {
|
||||||
UserDetails,
|
UserDetails,
|
||||||
WithSidebarLayout,
|
WithSidebarLayout,
|
||||||
} from '@maybe-finance/client/features'
|
} from '@maybe-finance/client/features'
|
||||||
import { Button, Tab } from '@maybe-finance/design-system'
|
import { Tab } from '@maybe-finance/design-system'
|
||||||
import { RiAttachmentLine } from 'react-icons/ri'
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { useNewestAgreements } = useUserApi()
|
|
||||||
const signedAgreements = useNewestAgreements('user')
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const tabs = ['details', 'notifications', 'security', 'documents', 'billing']
|
const tabs = ['details', 'notifications', 'security', 'documents', 'billing']
|
||||||
|
@ -41,7 +37,6 @@ export default function SettingsPage() {
|
||||||
<Tab>Details</Tab>
|
<Tab>Details</Tab>
|
||||||
<Tab>Notifications</Tab>
|
<Tab>Notifications</Tab>
|
||||||
<Tab>Security</Tab>
|
<Tab>Security</Tab>
|
||||||
<Tab>Documents</Tab>
|
|
||||||
<Tab>Billing</Tab>
|
<Tab>Billing</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
|
@ -58,41 +53,6 @@ export default function SettingsPage() {
|
||||||
<SecurityPreferences />
|
<SecurityPreferences />
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
|
||||||
<Tab.Panel>
|
|
||||||
{signedAgreements.data ? (
|
|
||||||
<div className="max-w-lg border border-gray-600 px-4 py-3 rounded text-base">
|
|
||||||
<ul>
|
|
||||||
{signedAgreements.data.map((agreement) => (
|
|
||||||
<li
|
|
||||||
key={agreement.id}
|
|
||||||
className="flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex w-0 flex-1 items-center">
|
|
||||||
<RiAttachmentLine
|
|
||||||
className="h-4 w-4 shrink-0 text-gray-100"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 w-0 flex-1 truncate text-gray-25">
|
|
||||||
{BrowserUtil.agreementName(agreement.type)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
variant="link"
|
|
||||||
target="_blank"
|
|
||||||
href={agreement.url}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-50">No documents found</p>
|
|
||||||
)}
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
<BillingPreferences />
|
<BillingPreferences />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { UnlinkAccountsParamsProvider } from 'auth0'
|
import type { UnlinkAccountsParamsProvider } from 'auth0'
|
||||||
import { keyBy, mapValues, uniqBy } from 'lodash'
|
|
||||||
import { subject } from '@casl/ability'
|
import { subject } from '@casl/ability'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
||||||
import endpoint from '../lib/endpoint'
|
import endpoint from '../lib/endpoint'
|
||||||
import env from '../../env'
|
import env from '../../env'
|
||||||
import crypto from 'crypto'
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import {
|
import {
|
||||||
type OnboardingState,
|
type OnboardingState,
|
||||||
type RegisteredStep,
|
type RegisteredStep,
|
||||||
|
@ -462,152 +459,6 @@ router.post(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the latest public version of each agreement
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
'/agreements/newest',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({ type: z.enum(['public', 'user']) }),
|
|
||||||
resolve: async ({ ctx, input }) => {
|
|
||||||
const agreements = await (input.type === 'user'
|
|
||||||
? ctx.userService.getSignedAgreements(ctx.user!.id)
|
|
||||||
: ctx.userService.getNewestAgreements())
|
|
||||||
|
|
||||||
return agreements.map((agreement) => ({
|
|
||||||
...agreement,
|
|
||||||
url: `${env.NX_CDN_URL}/${agreement.src}`,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/agreements/sign',
|
|
||||||
endpoint.create({
|
|
||||||
input: z.object({ agreementIds: z.number().array().length(5) }),
|
|
||||||
resolve: async ({ ctx, input }) => {
|
|
||||||
return ctx.userService.signAgreements(ctx.user!.id, input.agreementIds, ctx.s3)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Idempotent, admin-only route that should be run each time we update a legal agreement
|
|
||||||
*
|
|
||||||
* - Sends email notifications to users when agreements are updated
|
|
||||||
* - Records acknowledgement in S3
|
|
||||||
* - Bumps all successful users to latest agreement set in DB
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
'/agreements/notify-email',
|
|
||||||
endpoint.create({
|
|
||||||
resolve: async ({ ctx }) => {
|
|
||||||
ctx.ability.throwUnlessCan('manage', 'User')
|
|
||||||
|
|
||||||
const outdatedAgreements = await ctx.prisma.$queryRaw<
|
|
||||||
{
|
|
||||||
email: string
|
|
||||||
first_name: string
|
|
||||||
user_id: number
|
|
||||||
current_agreement_id: number
|
|
||||||
newest_agreement_id: number
|
|
||||||
}[]
|
|
||||||
>`
|
|
||||||
WITH signed_agreements AS (
|
|
||||||
SELECT DISTINCT ON (sa.user_id, a.type)
|
|
||||||
u.email,
|
|
||||||
u.first_name,
|
|
||||||
sa.user_id,
|
|
||||||
sa.agreement_id AS current_agreement_id,
|
|
||||||
na.id AS newest_agreement_id
|
|
||||||
FROM signed_agreement sa
|
|
||||||
LEFT JOIN "user" u ON u.id = sa.user_id
|
|
||||||
LEFT JOIN agreement a ON a.id = sa.agreement_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT DISTINCT ON (a.type)
|
|
||||||
a.id, a.type
|
|
||||||
FROM agreement a
|
|
||||||
WHERE a.active
|
|
||||||
ORDER BY a.type, a.revision DESC
|
|
||||||
) na ON na.type = a.type
|
|
||||||
ORDER BY sa.user_id, a.type, a.revision DESC
|
|
||||||
)
|
|
||||||
SELECT *
|
|
||||||
FROM signed_agreements
|
|
||||||
WHERE current_agreement_id <> newest_agreement_id;
|
|
||||||
`
|
|
||||||
|
|
||||||
if (!outdatedAgreements.length) {
|
|
||||||
ctx.logger.info('All users have signed latest agreements, skipping email')
|
|
||||||
return {
|
|
||||||
updatedAgreementCount: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.logger.info(`Updating ${outdatedAgreements.length} outdated agreements`)
|
|
||||||
|
|
||||||
const newestAgreements = (await ctx.userService.getNewestAgreements()).map((a) => ({
|
|
||||||
...a,
|
|
||||||
url: `${env.NX_CDN_URL}/${a.src}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Only send 1 email per user that will cover all 4 agreements
|
|
||||||
const uniqueAgreements = uniqBy(outdatedAgreements, 'user_id')
|
|
||||||
|
|
||||||
// Send users a templated update email notifying them of document change
|
|
||||||
const batchResponse = await ctx.emailService.sendTemplate(
|
|
||||||
uniqueAgreements.map((agreement) => ({
|
|
||||||
to: agreement.email,
|
|
||||||
template: {
|
|
||||||
alias: 'agreements-update',
|
|
||||||
model: {
|
|
||||||
name: agreement.first_name ?? '',
|
|
||||||
urls: mapValues(keyBy(newestAgreements, 'type'), (a) => a.url),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save audit records of email sends
|
|
||||||
ctx.logger.info(`Agreement update emails sent`, batchResponse)
|
|
||||||
|
|
||||||
const Body = Buffer.from(JSON.stringify(batchResponse))
|
|
||||||
const Key = `private/agreements/email-receipts/${DateTime.now().toISO()}-agreements-update-email-receipt.txt`
|
|
||||||
await ctx.s3.upload({
|
|
||||||
bucketKey: 'private',
|
|
||||||
Key,
|
|
||||||
Body,
|
|
||||||
ContentMD5: crypto.createHash('md5').update(Body).digest('base64'),
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx.logger.info(`Agreement email receipt uploaded to S3 key=${Key}`)
|
|
||||||
|
|
||||||
// Find all successful emails and create new agreement signatures for each
|
|
||||||
const successfulUpdateEmails = batchResponse
|
|
||||||
.filter((result) => result.ErrorCode === 0 && result.To)
|
|
||||||
.map((v) => v.To!)
|
|
||||||
|
|
||||||
const agreementsToAcknowledge = outdatedAgreements.filter(
|
|
||||||
(oa) => successfulUpdateEmails.find((email) => email === oa.email) != null
|
|
||||||
)
|
|
||||||
|
|
||||||
// Our signature record pointer in DB
|
|
||||||
await ctx.prisma.signedAgreement.createMany({
|
|
||||||
data: agreementsToAcknowledge.map((oa) => ({
|
|
||||||
userId: oa.user_id,
|
|
||||||
agreementId: oa.newest_agreement_id,
|
|
||||||
src: Key,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedAgreementCount: successfulUpdateEmails.length,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
'/',
|
'/',
|
||||||
endpoint.create({
|
endpoint.create({
|
||||||
|
|
|
@ -113,7 +113,7 @@ export class SharedStack extends Stack {
|
||||||
removalPolicy: RemovalPolicy.RETAIN,
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
})
|
})
|
||||||
|
|
||||||
// WORM compliant bucket to store CDN assets such as client agreements, AMA uploads
|
// WORM compliant bucket to store CDN assets such as AMA uploads
|
||||||
const privateBucket = new Bucket(this, 'Assets', {
|
const privateBucket = new Bucket(this, 'Assets', {
|
||||||
versioned: true,
|
versioned: true,
|
||||||
removalPolicy: RemovalPolicy.RETAIN,
|
removalPolicy: RemovalPolicy.RETAIN,
|
||||||
|
|
|
@ -1,181 +0,0 @@
|
||||||
import type { AnchorHTMLAttributes } from 'react'
|
|
||||||
import type { StepProps } from '../StepProps'
|
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
|
||||||
import { Button, Checkbox, LoadingSpinner } from '@maybe-finance/design-system'
|
|
||||||
import { BrowserUtil, useUserApi } from '@maybe-finance/client/shared'
|
|
||||||
import keyBy from 'lodash/keyBy'
|
|
||||||
import type { AgreementType } from '@prisma/client'
|
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import { AiOutlineLoading3Quarters } from 'react-icons/ai'
|
|
||||||
|
|
||||||
export function Terms({ title, onNext }: StepProps) {
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { isValid, isSubmitting },
|
|
||||||
} = useForm<{ agree: boolean }>({
|
|
||||||
mode: 'onChange',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { useSignAgreements, useNewestAgreements } = useUserApi()
|
|
||||||
|
|
||||||
const newestAgreements = useNewestAgreements('public')
|
|
||||||
const signAgreements = useSignAgreements()
|
|
||||||
|
|
||||||
if (newestAgreements.error) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col text-center items-center justify-center max-w-screen-md mx-auto">
|
|
||||||
<h3>Something went wrong.</h3>
|
|
||||||
<p className="text-gray-50">
|
|
||||||
We were unable to load the advisory agreements and cannot continue without
|
|
||||||
these. Please contact us and we will get this fixed ASAP!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newestAgreements.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fee, form_crs, form_adv_2a, form_adv_2b, privacy_policy } = keyBy(
|
|
||||||
newestAgreements.data,
|
|
||||||
'type'
|
|
||||||
) as Record<AgreementType, SharedType.AgreementWithUrl>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="w-full max-w-[464px] mx-auto"
|
|
||||||
onSubmit={handleSubmit(async (data) => {
|
|
||||||
if (!data.agree) throw new Error('User must accept agreement to continue')
|
|
||||||
if (!newestAgreements.data) throw new Error('Unable to sign agreements')
|
|
||||||
|
|
||||||
await signAgreements.mutateAsync(newestAgreements.data.map((a) => a.id))
|
|
||||||
await onNext()
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<h3 className="text-center">{title}</h3>
|
|
||||||
<div className="text-base text-gray-50">
|
|
||||||
<p className="mt-4">
|
|
||||||
Please have a look at the documents linked below and agree to the following
|
|
||||||
terms.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="agree"
|
|
||||||
rules={{ required: true }}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onChange={(checked) => field.onChange(checked)}
|
|
||||||
wrapperClassName="!items-start mt-2"
|
|
||||||
label={
|
|
||||||
<div className="-mt-1">
|
|
||||||
By checking this box you:
|
|
||||||
<ul
|
|
||||||
className="list-disc list-outside ml-[1.5em]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
Agree to our{' '}
|
|
||||||
<ExternalLink href={fee.url}>
|
|
||||||
{BrowserUtil.agreementName('fee')}
|
|
||||||
</ExternalLink>
|
|
||||||
<p className="text-gray-100 italic">
|
|
||||||
This is an agreement that memorializes our
|
|
||||||
advisory relationship, describes our services
|
|
||||||
and fees, and explains our various rights and
|
|
||||||
responsibilities.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Consent to electronic delivery of communications
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Acknowledge you have received a copy of:
|
|
||||||
<ul className="list-disc list-outside ml-[1.5em]">
|
|
||||||
<li>
|
|
||||||
<ExternalLink href={form_crs.url}>
|
|
||||||
{BrowserUtil.agreementName('form_crs')}
|
|
||||||
</ExternalLink>
|
|
||||||
<p className="text-gray-100 italic">
|
|
||||||
Also known as the "Relationship
|
|
||||||
Summary", this is a two-page summary of
|
|
||||||
our services, compensation, conflicts of
|
|
||||||
interest, and any applicable legal or
|
|
||||||
disciplinary history.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ExternalLink href={form_adv_2a.url}>
|
|
||||||
{BrowserUtil.agreementName(
|
|
||||||
'form_adv_2a'
|
|
||||||
)}
|
|
||||||
</ExternalLink>
|
|
||||||
<p className="text-gray-100 italic">
|
|
||||||
Also referred to as the "Brochure", this
|
|
||||||
is a narrative brochure about our firm.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ExternalLink href={form_adv_2b.url}>
|
|
||||||
{BrowserUtil.agreementName(
|
|
||||||
'form_adv_2b'
|
|
||||||
)}
|
|
||||||
</ExternalLink>
|
|
||||||
<p className="text-gray-100 italic">
|
|
||||||
Also referred to as the "Brochure
|
|
||||||
Supplement", this is a narrative
|
|
||||||
brochure about our investment
|
|
||||||
professionals.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ExternalLink href={privacy_policy.url}>
|
|
||||||
{BrowserUtil.agreementName(
|
|
||||||
'privacy_policy'
|
|
||||||
)}
|
|
||||||
</ExternalLink>
|
|
||||||
<p className="text-gray-100 italic">
|
|
||||||
Explains how we safeguard your
|
|
||||||
information and data.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className={classNames('mt-5', isSubmitting && 'animate-pulse')}
|
|
||||||
fullWidth
|
|
||||||
disabled={!isValid}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
{isSubmitting && (
|
|
||||||
<AiOutlineLoading3Quarters className="ml-2 w-5 h-5 animate-spin" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExternalLink({ children, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement>): JSX.Element {
|
|
||||||
return (
|
|
||||||
<a rel="noreferrer" target="_blank" className="text-cyan underline" {...rest}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,4 +1,3 @@
|
||||||
export * from './AddFirstAccount'
|
export * from './AddFirstAccount'
|
||||||
export * from './EmailVerification'
|
export * from './EmailVerification'
|
||||||
export * from './OtherAccounts'
|
export * from './OtherAccounts'
|
||||||
export * from './Terms'
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import type { SharedType } from '@maybe-finance/shared'
|
||||||
import type { Auth0ContextInterface } from '@auth0/auth0-react'
|
import type { Auth0ContextInterface } from '@auth0/auth0-react'
|
||||||
import type { AxiosInstance } from 'axios'
|
import type { AxiosInstance } from 'axios'
|
||||||
import Axios from 'axios'
|
import Axios from 'axios'
|
||||||
import type { Agreement } from '@prisma/client'
|
|
||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
@ -184,22 +183,6 @@ const UserApi = (
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNewestAgreements(type: 'public' | 'user') {
|
|
||||||
const { data } = await axios.get('/users/agreements/newest', { params: { type } })
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async signAgreements(input: Agreement['id'][]) {
|
|
||||||
const { data } = await axios.post('/users/agreements/sign', { agreementIds: input })
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dev, Admin only (see AMA dev menu)
|
|
||||||
async sendAgreementUpdateEmails() {
|
|
||||||
const { data } = await axios.post('/users/agreements/notify-email')
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMemberCardDetails(memberId?: string) {
|
async getMemberCardDetails(memberId?: string) {
|
||||||
const { data } = await axios.get<SharedType.UserMemberCardDetails>(
|
const { data } = await axios.get<SharedType.UserMemberCardDetails>(
|
||||||
`/users/card/${memberId ?? ''}`
|
`/users/card/${memberId ?? ''}`
|
||||||
|
@ -401,38 +384,6 @@ export function useUserApi() {
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const useSignAgreements = (options?: UseMutationOptions<Agreement['id'][], unknown, any>) =>
|
|
||||||
useMutation(api.signAgreements, {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries(['users'])
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
Sentry.captureException(err)
|
|
||||||
toast.error(
|
|
||||||
'Something went wrong while acknowledging agreements. Please try again.'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
const useSendAgreementsEmail = (options?: UseMutationOptions<any, unknown, any>) =>
|
|
||||||
useMutation(api.sendAgreementUpdateEmails, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(`Sent ${data.updatedAgreementCount} emails`)
|
|
||||||
queryClient.invalidateQueries(['users'])
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
Sentry.captureException(err)
|
|
||||||
toast.error('Something went wrong while sending agreement update emails.')
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
const useNewestAgreements = (
|
|
||||||
type: 'public' | 'user',
|
|
||||||
options?: Omit<UseQueryOptions<SharedType.AgreementWithUrl[]>, 'queryKey' | 'queryFn'>
|
|
||||||
) => useQuery(['users', 'agreements', 'newest'], () => api.getNewestAgreements(type), options)
|
|
||||||
|
|
||||||
const useMemberCardDetails = (
|
const useMemberCardDetails = (
|
||||||
memberId?: string,
|
memberId?: string,
|
||||||
options?: Omit<UseQueryOptions<SharedType.UserMemberCardDetails>, 'queryKey' | 'queryFn'>
|
options?: Omit<UseQueryOptions<SharedType.UserMemberCardDetails>, 'queryKey' | 'queryFn'>
|
||||||
|
@ -461,9 +412,6 @@ export function useUserApi() {
|
||||||
useResendEmailVerification,
|
useResendEmailVerification,
|
||||||
useCreateCheckoutSession,
|
useCreateCheckoutSession,
|
||||||
useCreateCustomerPortalSession,
|
useCreateCustomerPortalSession,
|
||||||
useSignAgreements,
|
|
||||||
useSendAgreementsEmail,
|
|
||||||
useNewestAgreements,
|
|
||||||
useMemberCardDetails,
|
useMemberCardDetails,
|
||||||
useOnboarding,
|
useOnboarding,
|
||||||
useUpdateOnboarding,
|
useUpdateOnboarding,
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import type { AgreementType } from '@prisma/client'
|
|
||||||
|
|
||||||
export function agreementName(type: AgreementType): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'fee':
|
|
||||||
return 'Limited Scope Advisory Agreement'
|
|
||||||
case 'form_adv_2a':
|
|
||||||
return 'Form ADV Part 2A'
|
|
||||||
case 'form_adv_2b':
|
|
||||||
return 'Form ADV Part 2B'
|
|
||||||
case 'form_crs':
|
|
||||||
return 'Form CRS'
|
|
||||||
case 'privacy_policy':
|
|
||||||
return 'Advisor Privacy Policy'
|
|
||||||
default:
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
export * from './image-loaders'
|
export * from './image-loaders'
|
||||||
export * from './browser-utils'
|
export * from './browser-utils'
|
||||||
export * from './account-utils'
|
export * from './account-utils'
|
||||||
export * from './agreement-utils'
|
|
||||||
export * from './form-utils'
|
export * from './form-utils'
|
||||||
export * from './auth-utils'
|
export * from './auth-utils'
|
||||||
|
|
|
@ -1,24 +1,9 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
// This schema should be kept in sync with templates maintained in the Postmark dashboard
|
// This schema should be kept in sync with templates maintained in the Postmark dashboard
|
||||||
export const EmailTemplateSchema = z.discriminatedUnion('alias', [
|
export const EmailTemplateSchema = z.object({
|
||||||
z.object({
|
|
||||||
alias: z.literal('trial-ending'),
|
alias: z.literal('trial-ending'),
|
||||||
model: z.object({
|
model: z.object({
|
||||||
endDate: z.string(),
|
endDate: z.string(),
|
||||||
}),
|
}),
|
||||||
}),
|
})
|
||||||
z.object({
|
|
||||||
alias: z.literal('agreements-update'),
|
|
||||||
model: z.object({
|
|
||||||
name: z.string(),
|
|
||||||
urls: z.object({
|
|
||||||
fee: z.string(),
|
|
||||||
form_adv_2a: z.string(),
|
|
||||||
form_adv_2b: z.string(),
|
|
||||||
form_crs: z.string(),
|
|
||||||
privacy_policy: z.string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
import type {
|
import type {
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountType,
|
AccountType,
|
||||||
Agreement,
|
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
SignedAgreement,
|
|
||||||
User,
|
User,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
import type { Logger } from 'winston'
|
import type { Logger } from 'winston'
|
||||||
import {
|
import {
|
||||||
AuthUtil,
|
AuthUtil,
|
||||||
type PurgeUserQueue,
|
type PurgeUserQueue,
|
||||||
type S3Service,
|
|
||||||
type SyncUserQueue,
|
type SyncUserQueue,
|
||||||
} from '@maybe-finance/server/shared'
|
} from '@maybe-finance/server/shared'
|
||||||
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
|
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
|
||||||
|
@ -18,7 +15,6 @@ import type Stripe from 'stripe'
|
||||||
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
||||||
import type { IAccountQueryService } from '../account'
|
import type { IAccountQueryService } from '../account'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import { CopyObjectCommand, MetadataDirective } from '@aws-sdk/client-s3'
|
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { DbUtil } from '@maybe-finance/server/shared'
|
import { DbUtil } from '@maybe-finance/server/shared'
|
||||||
import { DateUtil } from '@maybe-finance/shared'
|
import { DateUtil } from '@maybe-finance/shared'
|
||||||
|
@ -34,7 +30,6 @@ export type MainOnboardingUser = Pick<
|
||||||
onboarding: OnboardingState['main']
|
onboarding: OnboardingState['main']
|
||||||
accountConnections: { accounts: { id: number }[] }[]
|
accountConnections: { accounts: { id: number }[] }[]
|
||||||
accounts: { id: number }[]
|
accounts: { id: number }[]
|
||||||
signedAgreements: (SignedAgreement & { agreement: Agreement })[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarOnboardingUser = {
|
export type SidebarOnboardingUser = {
|
||||||
|
@ -278,83 +273,6 @@ export class UserService implements IUserService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSignedAgreements(userId: User['id']) {
|
|
||||||
return this.prisma.agreement.findMany({
|
|
||||||
distinct: 'type',
|
|
||||||
where: { signers: { some: { userId } } },
|
|
||||||
orderBy: { revision: 'desc' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNewestAgreements() {
|
|
||||||
const agreements = await this.prisma.agreement.findMany({
|
|
||||||
distinct: 'type',
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { revision: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (agreements.length < 5) throw new Error('Failed to fetch all required agreements')
|
|
||||||
|
|
||||||
return agreements
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should run serially to ensure audit record is saved prior to updating DB
|
|
||||||
async signAgreements(userId: User['id'], agreementIds: Agreement['id'][], s3: S3Service) {
|
|
||||||
// Agreement versions are stored over public CDN and copied (for audit) to an object-locked private bucket
|
|
||||||
const fromBucket = s3.buckets['public']
|
|
||||||
const toBucket = s3.buckets['private']
|
|
||||||
|
|
||||||
const agreements = await this.prisma.agreement.findMany({
|
|
||||||
where: { id: { in: agreementIds } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const feeAgreement = agreements.find((a) => a.type === 'fee')
|
|
||||||
if (!feeAgreement) throw new Error('Fee agreement must be present for signing')
|
|
||||||
|
|
||||||
const Key = `private/agreements/signed/uid-${userId}/${feeAgreement.src.split('/').at(-1)}`
|
|
||||||
|
|
||||||
// Store the fee agreement in S3 for audit
|
|
||||||
await s3.cli.send(
|
|
||||||
new CopyObjectCommand({
|
|
||||||
Bucket: toBucket,
|
|
||||||
Key,
|
|
||||||
CopySource: `/${fromBucket}/${feeAgreement.src}`,
|
|
||||||
Metadata: {
|
|
||||||
// Correlated database record id
|
|
||||||
'agreement-id': feeAgreement.id.toString(),
|
|
||||||
'agreement-revision': DateUtil.dateToStr(feeAgreement.revision),
|
|
||||||
'user-id': userId.toString(),
|
|
||||||
},
|
|
||||||
MetadataDirective: MetadataDirective.REPLACE,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
this.logger.info(
|
|
||||||
`Successfully uploaded S3 audit record for fee agreement user=${userId} revision=${DateUtil.dateToStr(
|
|
||||||
feeAgreement.revision
|
|
||||||
)}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Make idempotent for easier testing
|
|
||||||
const signatures = await Promise.all(
|
|
||||||
Object.entries(agreements).map(([agreementType, agreement]) =>
|
|
||||||
this.prisma.signedAgreement.upsert({
|
|
||||||
where: { userId_agreementId: { userId, agreementId: agreement.id } },
|
|
||||||
create: {
|
|
||||||
src: agreementType === 'fee' ? Key : undefined,
|
|
||||||
userId,
|
|
||||||
agreementId: agreement.id,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
this.logger.info(`Successfully signed agreements user=${userId}`)
|
|
||||||
|
|
||||||
return signatures
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMemberCard(memberId: string, clientUrl: string) {
|
async getMemberCard(memberId: string, clientUrl: string) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
@ -415,7 +333,6 @@ export class UserService implements IUserService {
|
||||||
accountConnections: {
|
accountConnections: {
|
||||||
select: { accounts: { select: { id: true } } },
|
select: { accounts: { select: { id: true } } },
|
||||||
},
|
},
|
||||||
signedAgreements: { include: { agreement: true } },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -479,16 +396,6 @@ export class UserService implements IUserService {
|
||||||
.addToGroup('setup')
|
.addToGroup('setup')
|
||||||
.completeIf(markedComplete)
|
.completeIf(markedComplete)
|
||||||
|
|
||||||
onboarding
|
|
||||||
.addStep('terms')
|
|
||||||
.setTitle((_) => 'Finally, some agreements')
|
|
||||||
.addToGroup('setup')
|
|
||||||
.completeIf((user) => {
|
|
||||||
// All 5 agreements should be signed and Fee agreement should have S3 audit record
|
|
||||||
const feeAgreement = user.signedAgreements.find((sa) => sa.agreement.type === 'fee')
|
|
||||||
return user.signedAgreements.length >= 5 && feeAgreement?.agreement?.src != null
|
|
||||||
})
|
|
||||||
|
|
||||||
onboarding
|
onboarding
|
||||||
.addStep('maybe')
|
.addStep('maybe')
|
||||||
.setTitle((_) => "One more thing, what's your maybe?")
|
.setTitle((_) => "One more thing, what's your maybe?")
|
||||||
|
|
|
@ -3,7 +3,6 @@ import type { Identity, User as Auth0UserServer } from 'auth0'
|
||||||
import type {
|
import type {
|
||||||
AccountCategory,
|
AccountCategory,
|
||||||
AccountClassification,
|
AccountClassification,
|
||||||
Agreement,
|
|
||||||
Holding,
|
Holding,
|
||||||
Prisma,
|
Prisma,
|
||||||
Security,
|
Security,
|
||||||
|
@ -254,8 +253,6 @@ export type RiskAnswer = {
|
||||||
choiceKey: string
|
choiceKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgreementWithUrl = Agreement & { url: string }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* main - the fullscreen "takeover" every user must go through
|
* main - the fullscreen "takeover" every user must go through
|
||||||
* sidebar - the post-onboarding sidebar for connecting accounts
|
* sidebar - the post-onboarding sidebar for connecting accounts
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `agreement` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `signed_agreement` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "signed_agreement" DROP CONSTRAINT "signed_agreement_agreement_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "signed_agreement" DROP CONSTRAINT "signed_agreement_user_id_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "agreement";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "signed_agreement";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "AgreementType";
|
|
@ -451,7 +451,6 @@ model User {
|
||||||
accountConnections AccountConnection[]
|
accountConnections AccountConnection[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
plans Plan[]
|
plans Plan[]
|
||||||
signedAgreements SignedAgreement[]
|
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
@ -587,35 +586,3 @@ model AuditEvent {
|
||||||
|
|
||||||
@@map("audit_event")
|
@@map("audit_event")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AgreementType {
|
|
||||||
fee
|
|
||||||
form_adv_2a
|
|
||||||
form_adv_2b
|
|
||||||
form_crs
|
|
||||||
privacy_policy
|
|
||||||
}
|
|
||||||
|
|
||||||
model Agreement {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
type AgreementType
|
|
||||||
revision DateTime @db.Date
|
|
||||||
src String @unique
|
|
||||||
active Boolean @default(false) // if true and is latest version, Client will serve to users
|
|
||||||
signers SignedAgreement[]
|
|
||||||
|
|
||||||
@@unique([type, revision, active])
|
|
||||||
@@map("agreement")
|
|
||||||
}
|
|
||||||
|
|
||||||
model SignedAgreement {
|
|
||||||
signedAt DateTime @default(now()) @map("signed_at") @db.Timestamptz(6)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
userId Int @map("user_id")
|
|
||||||
agreement Agreement @relation(fields: [agreementId], references: [id], onDelete: Cascade)
|
|
||||||
agreementId Int @map("agreement_id")
|
|
||||||
src String? // Not all agreements require S3 audit record
|
|
||||||
|
|
||||||
@@id([userId, agreementId])
|
|
||||||
@@map("signed_agreement")
|
|
||||||
}
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue