mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'main' into remove-convertKit-usage
This commit is contained in:
commit
5a92fb7bd5
14 changed files with 29 additions and 597 deletions
|
@ -8,7 +8,6 @@ import {
|
|||
OtherAccounts,
|
||||
Profile,
|
||||
OnboardingNavbar,
|
||||
Terms,
|
||||
Welcome,
|
||||
YourMaybe,
|
||||
OnboardingBackground,
|
||||
|
@ -29,8 +28,6 @@ function getStepComponent(stepKey?: string): (props: StepProps) => JSX.Element {
|
|||
return AddFirstAccount
|
||||
case 'accountSelection':
|
||||
return OtherAccounts
|
||||
case 'terms':
|
||||
return Terms
|
||||
case 'maybe':
|
||||
return YourMaybe
|
||||
case 'welcome':
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { ReactElement } from 'react'
|
||||
import { useUserApi, useQueryParam, BrowserUtil } from '@maybe-finance/client/shared'
|
||||
import { useQueryParam } from '@maybe-finance/client/shared'
|
||||
import {
|
||||
AccountSidebar,
|
||||
BillingPreferences,
|
||||
|
@ -8,15 +8,11 @@ import {
|
|||
UserDetails,
|
||||
WithSidebarLayout,
|
||||
} from '@maybe-finance/client/features'
|
||||
import { Button, Tab } from '@maybe-finance/design-system'
|
||||
import { RiAttachmentLine } from 'react-icons/ri'
|
||||
import { Tab } from '@maybe-finance/design-system'
|
||||
import { useRouter } from 'next/router'
|
||||
import Script from 'next/script'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { useNewestAgreements } = useUserApi()
|
||||
const signedAgreements = useNewestAgreements('user')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = ['details', 'notifications', 'security', 'documents', 'billing']
|
||||
|
@ -41,7 +37,6 @@ export default function SettingsPage() {
|
|||
<Tab>Details</Tab>
|
||||
<Tab>Notifications</Tab>
|
||||
<Tab>Security</Tab>
|
||||
<Tab>Documents</Tab>
|
||||
<Tab>Billing</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
|
@ -58,41 +53,6 @@ export default function SettingsPage() {
|
|||
<SecurityPreferences />
|
||||
</div>
|
||||
</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>
|
||||
<BillingPreferences />
|
||||
</Tab.Panel>
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import { Router } from 'express'
|
||||
import axios from 'axios'
|
||||
import type { UnlinkAccountsParamsProvider } from 'auth0'
|
||||
import { keyBy, mapValues, uniqBy } from 'lodash'
|
||||
import { subject } from '@casl/ability'
|
||||
import { z } from 'zod'
|
||||
import { DateUtil, type SharedType } from '@maybe-finance/shared'
|
||||
import endpoint from '../lib/endpoint'
|
||||
import env from '../../env'
|
||||
import crypto from 'crypto'
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
type OnboardingState,
|
||||
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(
|
||||
'/',
|
||||
endpoint.create({
|
||||
|
|
|
@ -113,7 +113,7 @@ export class SharedStack extends Stack {
|
|||
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', {
|
||||
versioned: true,
|
||||
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 './EmailVerification'
|
||||
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 { AxiosInstance } from 'axios'
|
||||
import Axios from 'axios'
|
||||
import type { Agreement } from '@prisma/client'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
@ -184,22 +183,6 @@ const UserApi = (
|
|||
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) {
|
||||
const { data } = await axios.get<SharedType.UserMemberCardDetails>(
|
||||
`/users/card/${memberId ?? ''}`
|
||||
|
@ -401,38 +384,6 @@ export function useUserApi() {
|
|||
...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 = (
|
||||
memberId?: string,
|
||||
options?: Omit<UseQueryOptions<SharedType.UserMemberCardDetails>, 'queryKey' | 'queryFn'>
|
||||
|
@ -461,9 +412,6 @@ export function useUserApi() {
|
|||
useResendEmailVerification,
|
||||
useCreateCheckoutSession,
|
||||
useCreateCustomerPortalSession,
|
||||
useSignAgreements,
|
||||
useSendAgreementsEmail,
|
||||
useNewestAgreements,
|
||||
useMemberCardDetails,
|
||||
useOnboarding,
|
||||
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 './browser-utils'
|
||||
export * from './account-utils'
|
||||
export * from './agreement-utils'
|
||||
export * from './form-utils'
|
||||
export * from './auth-utils'
|
||||
|
|
|
@ -1,24 +1,9 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
// This schema should be kept in sync with templates maintained in the Postmark dashboard
|
||||
export const EmailTemplateSchema = z.discriminatedUnion('alias', [
|
||||
z.object({
|
||||
alias: z.literal('trial-ending'),
|
||||
model: z.object({
|
||||
endDate: z.string(),
|
||||
}),
|
||||
export const EmailTemplateSchema = z.object({
|
||||
alias: z.literal('trial-ending'),
|
||||
model: z.object({
|
||||
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 {
|
||||
AccountCategory,
|
||||
AccountType,
|
||||
Agreement,
|
||||
PrismaClient,
|
||||
SignedAgreement,
|
||||
User,
|
||||
} from '@prisma/client'
|
||||
import type { Logger } from 'winston'
|
||||
import {
|
||||
AuthUtil,
|
||||
type PurgeUserQueue,
|
||||
type S3Service,
|
||||
type SyncUserQueue,
|
||||
} from '@maybe-finance/server/shared'
|
||||
import type { ManagementClient, UnlinkAccountsParamsProvider } from 'auth0'
|
||||
|
@ -18,7 +15,6 @@ import type Stripe from 'stripe'
|
|||
import type { IBalanceSyncStrategyFactory } from '../account-balance'
|
||||
import type { IAccountQueryService } from '../account'
|
||||
import type { SharedType } from '@maybe-finance/shared'
|
||||
import { CopyObjectCommand, MetadataDirective } from '@aws-sdk/client-s3'
|
||||
import { DateTime } from 'luxon'
|
||||
import { DbUtil } from '@maybe-finance/server/shared'
|
||||
import { DateUtil } from '@maybe-finance/shared'
|
||||
|
@ -34,7 +30,6 @@ export type MainOnboardingUser = Pick<
|
|||
onboarding: OnboardingState['main']
|
||||
accountConnections: { accounts: { id: number }[] }[]
|
||||
accounts: { id: number }[]
|
||||
signedAgreements: (SignedAgreement & { agreement: Agreement })[]
|
||||
}
|
||||
|
||||
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) {
|
||||
const {
|
||||
name,
|
||||
|
@ -415,7 +333,6 @@ export class UserService implements IUserService {
|
|||
accountConnections: {
|
||||
select: { accounts: { select: { id: true } } },
|
||||
},
|
||||
signedAgreements: { include: { agreement: true } },
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -479,16 +396,6 @@ export class UserService implements IUserService {
|
|||
.addToGroup('setup')
|
||||
.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
|
||||
.addStep('maybe')
|
||||
.setTitle((_) => "One more thing, what's your maybe?")
|
||||
|
|
|
@ -3,7 +3,6 @@ import type { Identity, User as Auth0UserServer } from 'auth0'
|
|||
import type {
|
||||
AccountCategory,
|
||||
AccountClassification,
|
||||
Agreement,
|
||||
Holding,
|
||||
Prisma,
|
||||
Security,
|
||||
|
@ -254,8 +253,6 @@ export type RiskAnswer = {
|
|||
choiceKey: string
|
||||
}
|
||||
|
||||
export type AgreementWithUrl = Agreement & { url: string }
|
||||
|
||||
/**
|
||||
* main - the fullscreen "takeover" every user must go through
|
||||
* 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";
|
|
@ -448,7 +448,6 @@ model User {
|
|||
accountConnections AccountConnection[]
|
||||
accounts Account[]
|
||||
plans Plan[]
|
||||
signedAgreements SignedAgreement[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
@ -584,35 +583,3 @@ model AuditEvent {
|
|||
|
||||
@@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