diff --git a/apps/client/pages/onboarding.tsx b/apps/client/pages/onboarding.tsx index 3959f2a3..3254dabc 100644 --- a/apps/client/pages/onboarding.tsx +++ b/apps/client/pages/onboarding.tsx @@ -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': diff --git a/apps/client/pages/settings.tsx b/apps/client/pages/settings.tsx index ec9507ab..b511d426 100644 --- a/apps/client/pages/settings.tsx +++ b/apps/client/pages/settings.tsx @@ -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() { Details Notifications Security - Documents Billing @@ -58,41 +53,6 @@ export default function SettingsPage() { - - - {signedAgreements.data ? ( - - - {signedAgreements.data.map((agreement) => ( - - - - - {BrowserUtil.agreementName(agreement.type)} - - - - View - - - ))} - - - ) : ( - No documents found - )} - diff --git a/apps/server/src/app/routes/users.router.ts b/apps/server/src/app/routes/users.router.ts index 52ec5f7d..15b80f16 100644 --- a/apps/server/src/app/routes/users.router.ts +++ b/apps/server/src/app/routes/users.router.ts @@ -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({ diff --git a/aws/maybe-app/lib/stacks/shared-stack.ts b/aws/maybe-app/lib/stacks/shared-stack.ts index 845a171c..dc848049 100644 --- a/aws/maybe-app/lib/stacks/shared-stack.ts +++ b/aws/maybe-app/lib/stacks/shared-stack.ts @@ -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, diff --git a/libs/client/features/src/onboarding/steps/setup/Terms.tsx b/libs/client/features/src/onboarding/steps/setup/Terms.tsx deleted file mode 100644 index ed29d5ec..00000000 --- a/libs/client/features/src/onboarding/steps/setup/Terms.tsx +++ /dev/null @@ -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 ( - - Something went wrong. - - We were unable to load the advisory agreements and cannot continue without - these. Please contact us and we will get this fixed ASAP! - - - ) - } - - if (newestAgreements.isLoading) { - return ( - - - - ) - } - - const { fee, form_crs, form_adv_2a, form_adv_2b, privacy_policy } = keyBy( - newestAgreements.data, - 'type' - ) as Record - - return ( - { - 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() - })} - > - {title} - - - Please have a look at the documents linked below and agree to the following - terms. - - - ( - field.onChange(checked)} - wrapperClassName="!items-start mt-2" - label={ - - By checking this box you: - e.stopPropagation()} - > - - Agree to our{' '} - - {BrowserUtil.agreementName('fee')} - - - This is an agreement that memorializes our - advisory relationship, describes our services - and fees, and explains our various rights and - responsibilities. - - - - Consent to electronic delivery of communications - - - Acknowledge you have received a copy of: - - - - {BrowserUtil.agreementName('form_crs')} - - - 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. - - - - - {BrowserUtil.agreementName( - 'form_adv_2a' - )} - - - Also referred to as the "Brochure", this - is a narrative brochure about our firm. - - - - - {BrowserUtil.agreementName( - 'form_adv_2b' - )} - - - Also referred to as the "Brochure - Supplement", this is a narrative - brochure about our investment - professionals. - - - - - {BrowserUtil.agreementName( - 'privacy_policy' - )} - - - Explains how we safeguard your - information and data. - - - - - - - } - /> - )} - /> - - - - Continue - {isSubmitting && ( - - )} - - - ) -} - -function ExternalLink({ children, ...rest }: AnchorHTMLAttributes): JSX.Element { - return ( - - {children} - - ) -} diff --git a/libs/client/features/src/onboarding/steps/setup/index.ts b/libs/client/features/src/onboarding/steps/setup/index.ts index 19788f4b..9bd78f2a 100644 --- a/libs/client/features/src/onboarding/steps/setup/index.ts +++ b/libs/client/features/src/onboarding/steps/setup/index.ts @@ -1,4 +1,3 @@ export * from './AddFirstAccount' export * from './EmailVerification' export * from './OtherAccounts' -export * from './Terms' diff --git a/libs/client/shared/src/api/useUserApi.ts b/libs/client/shared/src/api/useUserApi.ts index 0c357f9a..83148bcd 100644 --- a/libs/client/shared/src/api/useUserApi.ts +++ b/libs/client/shared/src/api/useUserApi.ts @@ -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( `/users/card/${memberId ?? ''}` @@ -401,38 +384,6 @@ export function useUserApi() { ...options, }) - const useSignAgreements = (options?: UseMutationOptions) => - 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) => - 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, 'queryKey' | 'queryFn'> - ) => useQuery(['users', 'agreements', 'newest'], () => api.getNewestAgreements(type), options) - const useMemberCardDetails = ( memberId?: string, options?: Omit, 'queryKey' | 'queryFn'> @@ -461,9 +412,6 @@ export function useUserApi() { useResendEmailVerification, useCreateCheckoutSession, useCreateCustomerPortalSession, - useSignAgreements, - useSendAgreementsEmail, - useNewestAgreements, useMemberCardDetails, useOnboarding, useUpdateOnboarding, diff --git a/libs/client/shared/src/utils/agreement-utils.ts b/libs/client/shared/src/utils/agreement-utils.ts deleted file mode 100644 index 4b8d3a5f..00000000 --- a/libs/client/shared/src/utils/agreement-utils.ts +++ /dev/null @@ -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 - } -} diff --git a/libs/client/shared/src/utils/index.ts b/libs/client/shared/src/utils/index.ts index 8d7a415c..ea672173 100644 --- a/libs/client/shared/src/utils/index.ts +++ b/libs/client/shared/src/utils/index.ts @@ -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' diff --git a/libs/server/features/src/email/email.schema.ts b/libs/server/features/src/email/email.schema.ts index 8854eef0..9c8a3af7 100644 --- a/libs/server/features/src/email/email.schema.ts +++ b/libs/server/features/src/email/email.schema.ts @@ -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(), - }), - }), - }), -]) +}) diff --git a/libs/server/features/src/user/user.service.ts b/libs/server/features/src/user/user.service.ts index 812d6d15..710d9a73 100644 --- a/libs/server/features/src/user/user.service.ts +++ b/libs/server/features/src/user/user.service.ts @@ -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?") diff --git a/libs/shared/src/types/user-types.ts b/libs/shared/src/types/user-types.ts index b26c63d8..ed291293 100644 --- a/libs/shared/src/types/user-types.ts +++ b/libs/shared/src/types/user-types.ts @@ -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 diff --git a/prisma/migrations/20240112000538_remove_agreement_code/migration.sql b/prisma/migrations/20240112000538_remove_agreement_code/migration.sql new file mode 100644 index 00000000..a19e5a26 --- /dev/null +++ b/prisma/migrations/20240112000538_remove_agreement_code/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 775b7ebc..04d7d478 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -451,7 +451,6 @@ model User { accountConnections AccountConnection[] accounts Account[] plans Plan[] - signedAgreements SignedAgreement[] @@map("user") } @@ -587,35 +586,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") -}
No documents found
- We were unable to load the advisory agreements and cannot continue without - these. Please contact us and we will get this fixed ASAP! -
- Please have a look at the documents linked below and agree to the following - terms. -
- This is an agreement that memorializes our - advisory relationship, describes our services - and fees, and explains our various rights and - responsibilities. -
- 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. -
- Also referred to as the "Brochure", this - is a narrative brochure about our firm. -
- Also referred to as the "Brochure - Supplement", this is a narrative - brochure about our investment - professionals. -
- Explains how we safeguard your - information and data. -