1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Merge pull request #33 from MichaelDeBoey/remove-agreement-code

feat: remove agreement code
This commit is contained in:
Josh Pigford 2024-01-11 18:11:18 -06:00 committed by GitHub
commit 44c751af8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 29 additions and 597 deletions

View file

@ -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':

View file

@ -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>

View file

@ -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({

View file

@ -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,

View file

@ -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>
)
}

View file

@ -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'

View file

@ -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,

View file

@ -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
}
}

View file

@ -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'

View file

@ -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(),
}),
}),
}),
])

View file

@ -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?")

View file

@ -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

View file

@ -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";

View file

@ -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")
}