mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 23:15:24 +02:00
Merge remote-tracking branch 'upstream/main' into support-more-email-providers
This commit is contained in:
commit
d121681b1f
21 changed files with 123 additions and 163 deletions
|
@ -2,7 +2,8 @@ import NextAuth from 'next-auth'
|
||||||
import type { SessionStrategy, NextAuthOptions } from 'next-auth'
|
import type { SessionStrategy, NextAuthOptions } from 'next-auth'
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { PrismaClient, type Prisma } from '@prisma/client'
|
import { PrismaClient, AuthUserRole } from '@prisma/client'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
import type { SharedType } from '@maybe-finance/shared'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
|
@ -36,6 +37,7 @@ async function validateCredentials(credentials: any): Promise<z.infer<typeof aut
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().optional(),
|
||||||
email: z.string().email({ message: 'Invalid email address.' }),
|
email: z.string().email({ message: 'Invalid email address.' }),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
|
isAdmin: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsed = authSchema.safeParse(credentials)
|
const parsed = authSchema.safeParse(credentials)
|
||||||
|
@ -51,13 +53,15 @@ async function createNewAuthUser(credentials: {
|
||||||
lastName: string
|
lastName: string
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
|
isAdmin: boolean
|
||||||
}): Promise<SharedType.AuthUser> {
|
}): Promise<SharedType.AuthUser> {
|
||||||
const { firstName, lastName, email, password } = credentials
|
const { firstName, lastName, email, password, isAdmin } = credentials
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
throw new Error('Both first name and last name are required.')
|
throw new Error('Both first name and last name are required.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||||
const hashedPassword = await bcrypt.hash(password, 10)
|
const hashedPassword = await bcrypt.hash(password, 10)
|
||||||
return createAuthUser({
|
return createAuthUser({
|
||||||
firstName,
|
firstName,
|
||||||
|
@ -65,6 +69,7 @@ async function createNewAuthUser(credentials: {
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName} ${lastName}`,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
role: isAdmin && isDevelopment ? AuthUserRole.admin : AuthUserRole.user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,10 +99,14 @@ export const authOptions = {
|
||||||
lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' },
|
lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' },
|
||||||
email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' },
|
email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' },
|
||||||
password: { label: 'Password', type: 'password' },
|
password: { label: 'Password', type: 'password' },
|
||||||
|
isAdmin: { label: 'Admin', type: 'checkbox' },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
const { firstName, lastName, email, password } = await validateCredentials(
|
const { firstName, lastName, email, password, isAdmin } = await validateCredentials(
|
||||||
credentials
|
{
|
||||||
|
...credentials,
|
||||||
|
isAdmin: Boolean(credentials?.isAdmin),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const existingUser = await getAuthUserByEmail(email)
|
const existingUser = await getAuthUserByEmail(email)
|
||||||
|
@ -114,7 +123,7 @@ export const authOptions = {
|
||||||
throw new Error('Invalid credentials provided.')
|
throw new Error('Invalid credentials provided.')
|
||||||
}
|
}
|
||||||
|
|
||||||
return createNewAuthUser({ firstName, lastName, email, password })
|
return createNewAuthUser({ firstName, lastName, email, password, isAdmin })
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
@ -126,6 +135,7 @@ export const authOptions = {
|
||||||
token.firstName = authUser.firstName
|
token.firstName = authUser.firstName
|
||||||
token.lastName = authUser.lastName
|
token.lastName = authUser.lastName
|
||||||
token.name = authUser.name
|
token.name = authUser.name
|
||||||
|
token.role = authUser.role
|
||||||
}
|
}
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
|
@ -136,6 +146,7 @@ export const authOptions = {
|
||||||
session.firstName = token.firstName
|
session.firstName = token.firstName
|
||||||
session.lastName = token.lastName
|
session.lastName = token.lastName
|
||||||
session.name = token.name
|
session.name = token.name
|
||||||
|
session.role = token.role
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, type ReactElement } from 'react'
|
import { useState, type ReactElement } from 'react'
|
||||||
import { Input, InputPassword, Button } from '@maybe-finance/design-system'
|
import { Input, InputPassword, Button, Checkbox } from '@maybe-finance/design-system'
|
||||||
import { FullPageLayout } from '@maybe-finance/client/features'
|
import { FullPageLayout } from '@maybe-finance/client/features'
|
||||||
import { signIn, useSession } from 'next-auth/react'
|
import { signIn, useSession } from 'next-auth/react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
@ -15,6 +15,7 @@ export default function RegisterPage() {
|
||||||
const [isValid, setIsValid] = useState(false)
|
const [isValid, setIsValid] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -38,6 +39,7 @@ export default function RegisterPage() {
|
||||||
password,
|
password,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
isAdmin,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -108,6 +110,8 @@ export default function RegisterPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<AuthDevTools isAdmin={isAdmin} setIsAdmin={setIsAdmin} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
|
@ -135,6 +139,28 @@ export default function RegisterPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthDevToolsProps = {
|
||||||
|
isAdmin: boolean
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthDevTools({ isAdmin, setIsAdmin }: AuthDevToolsProps) {
|
||||||
|
return process.env.NODE_ENV === 'development' ? (
|
||||||
|
<div className="my-2 p-2 border border-red-300 rounded-md">
|
||||||
|
<h6 className="flex text-red">
|
||||||
|
Dev Tools <i className="ri-tools-fill ml-1.5" />
|
||||||
|
</h6>
|
||||||
|
<p className="text-sm my-2">
|
||||||
|
This section will NOT show in production and is solely for making testing easier.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox checked={isAdmin} onChange={setIsAdmin} label="Create Admin user?" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
RegisterPage.getLayout = function getLayout(page: ReactElement) {
|
RegisterPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <FullPageLayout>{page}</FullPageLayout>
|
return <FullPageLayout>{page}</FullPageLayout>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { User } from '@prisma/client'
|
import type { User } from '@prisma/client'
|
||||||
import { InvestmentTransactionCategory, Prisma, PrismaClient } from '@prisma/client'
|
import { AssetClass, InvestmentTransactionCategory, Prisma, PrismaClient } from '@prisma/client'
|
||||||
import { createLogger, transports } from 'winston'
|
import { createLogger, transports } from 'winston'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import type {
|
import type {
|
||||||
|
@ -202,19 +202,25 @@ describe('insight service', () => {
|
||||||
holdings: {
|
holdings: {
|
||||||
create: [
|
create: [
|
||||||
{
|
{
|
||||||
security: { create: { symbol: 'AAPL', plaidType: 'equity' } },
|
security: {
|
||||||
|
create: { symbol: 'AAPL', assetClass: AssetClass.stocks },
|
||||||
|
},
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
costBasisUser: 100,
|
costBasisUser: 100,
|
||||||
value: 200,
|
value: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
security: { create: { symbol: 'NFLX', plaidType: 'equity' } },
|
security: {
|
||||||
|
create: { symbol: 'NFLX', assetClass: AssetClass.stocks },
|
||||||
|
},
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
costBasisUser: 200,
|
costBasisUser: 200,
|
||||||
value: 300,
|
value: 300,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
security: { create: { symbol: 'SHOP', plaidType: 'equity' } },
|
security: {
|
||||||
|
create: { symbol: 'SHOP', assetClass: AssetClass.stocks },
|
||||||
|
},
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
costBasisUser: 100,
|
costBasisUser: 100,
|
||||||
value: 50,
|
value: 50,
|
||||||
|
|
|
@ -2,7 +2,6 @@ import type { Subjects } from '@casl/prisma'
|
||||||
import { PrismaAbility, accessibleBy } from '@casl/prisma'
|
import { PrismaAbility, accessibleBy } from '@casl/prisma'
|
||||||
import type { AbilityClass } from '@casl/ability'
|
import type { AbilityClass } from '@casl/ability'
|
||||||
import { AbilityBuilder, ForbiddenError } from '@casl/ability'
|
import { AbilityBuilder, ForbiddenError } from '@casl/ability'
|
||||||
import type { SharedType } from '@maybe-finance/shared'
|
|
||||||
import type {
|
import type {
|
||||||
User,
|
User,
|
||||||
Account,
|
Account,
|
||||||
|
@ -18,6 +17,7 @@ import type {
|
||||||
ProviderInstitution,
|
ProviderInstitution,
|
||||||
Plan,
|
Plan,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
|
import { AuthUserRole } from '@prisma/client'
|
||||||
|
|
||||||
type CRUDActions = 'create' | 'read' | 'update' | 'delete'
|
type CRUDActions = 'create' | 'read' | 'update' | 'delete'
|
||||||
type AppActions = CRUDActions | 'manage'
|
type AppActions = CRUDActions | 'manage'
|
||||||
|
@ -41,13 +41,11 @@ type AppSubjects = PrismaSubjects | 'all'
|
||||||
|
|
||||||
type AppAbility = PrismaAbility<[AppActions, AppSubjects]>
|
type AppAbility = PrismaAbility<[AppActions, AppSubjects]>
|
||||||
|
|
||||||
export default function defineAbilityFor(
|
export default function defineAbilityFor(user: (Pick<User, 'id'> & { role: AuthUserRole }) | null) {
|
||||||
user: (Pick<User, 'id'> & { roles: SharedType.UserRole[] }) | null
|
|
||||||
) {
|
|
||||||
const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass<AppAbility>)
|
const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass<AppAbility>)
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.roles.includes('Admin')) {
|
if (user.role === AuthUserRole.admin) {
|
||||||
can('manage', 'Account')
|
can('manage', 'Account')
|
||||||
can('manage', 'AccountConnection')
|
can('manage', 'AccountConnection')
|
||||||
can('manage', 'Valuation')
|
can('manage', 'Valuation')
|
||||||
|
|
|
@ -300,15 +300,11 @@ async function getCurrentUser(jwt: NonNullable<Request['user']>) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
role: jwt.role,
|
||||||
// TODO: Replace Auth0 concepts with next-auth
|
// TODO: Replace Auth0 concepts with next-auth
|
||||||
roles: [],
|
|
||||||
primaryIdentity: {},
|
primaryIdentity: {},
|
||||||
userMetadata: {},
|
userMetadata: {},
|
||||||
appMetadata: {},
|
appMetadata: {},
|
||||||
// roles: jwt[SharedType.Auth0CustomNamespace.Roles] ?? [],
|
|
||||||
// primaryIdentity: jwt[SharedType.Auth0CustomNamespace.PrimaryIdentity] ?? {},
|
|
||||||
// userMetadata: jwt[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
|
|
||||||
// appMetadata: jwt[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { auth, claimCheck } from 'express-openid-connect'
|
|
||||||
import { createBullBoard } from '@bull-board/api'
|
import { createBullBoard } from '@bull-board/api'
|
||||||
import { BullAdapter } from '@bull-board/api/bullAdapter'
|
import { BullAdapter } from '@bull-board/api/bullAdapter'
|
||||||
import { ExpressAdapter } from '@bull-board/express'
|
import { ExpressAdapter } from '@bull-board/express'
|
||||||
import { AuthUtil, BullQueue } from '@maybe-finance/server/shared'
|
import { BullQueue } from '@maybe-finance/server/shared'
|
||||||
import { SharedType } from '@maybe-finance/shared'
|
|
||||||
import { queueService } from '../lib/endpoint'
|
import { queueService } from '../lib/endpoint'
|
||||||
import env from '../../env'
|
|
||||||
import { validateAuthJwt } from '../middleware'
|
import { validateAuthJwt } from '../middleware'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
|
@ -27,10 +27,10 @@ router.post(
|
||||||
resolve: async ({ ctx }) => {
|
resolve: async ({ ctx }) => {
|
||||||
ctx.ability.throwUnlessCan('update', 'Institution')
|
ctx.ability.throwUnlessCan('update', 'Institution')
|
||||||
|
|
||||||
// Sync all Plaid institutions
|
// Sync all Teller institutions
|
||||||
await ctx.queueService
|
await ctx.queueService
|
||||||
.getQueue('sync-institution')
|
.getQueue('sync-institution')
|
||||||
.addBulk([{ name: 'sync-plaid-institutions', data: {} }])
|
.addBulk([{ name: 'sync-teller-institutions', data: {} }])
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,23 +1,16 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import { useAccountConnectionApi, useInstitutionApi } from '@maybe-finance/client/shared'
|
||||||
useAccountConnectionApi,
|
|
||||||
useInstitutionApi,
|
|
||||||
usePlaidApi,
|
|
||||||
BrowserUtil,
|
|
||||||
} from '@maybe-finance/client/shared'
|
|
||||||
|
|
||||||
export function AccountDevTools() {
|
export function AccountDevTools() {
|
||||||
const { useSandboxQuickAdd } = usePlaidApi()
|
|
||||||
const { useDeleteAllConnections } = useAccountConnectionApi()
|
const { useDeleteAllConnections } = useAccountConnectionApi()
|
||||||
const { useSyncInstitutions, useDeduplicateInstitutions } = useInstitutionApi()
|
const { useSyncInstitutions, useDeduplicateInstitutions } = useInstitutionApi()
|
||||||
|
|
||||||
const sandboxQuickAdd = useSandboxQuickAdd()
|
|
||||||
const deleteAllConnections = useDeleteAllConnections()
|
const deleteAllConnections = useDeleteAllConnections()
|
||||||
const syncInstitutions = useSyncInstitutions()
|
const syncInstitutions = useSyncInstitutions()
|
||||||
const deduplicateInstitutions = useDeduplicateInstitutions()
|
const deduplicateInstitutions = useDeduplicateInstitutions()
|
||||||
|
|
||||||
return process.env.NODE_ENV === 'development' ? (
|
return process.env.NODE_ENV === 'development' ? (
|
||||||
<div className="relative mb-12 mx-2 sm:mx-0 p-4 bg-gray-700 rounded-md">
|
<div className="relative mb-12 mx-2 sm:mx-0 p-4 bg-gray-700 rounded-md z-10">
|
||||||
<h6 className="flex text-red">
|
<h6 className="flex text-red">
|
||||||
Dev Tools <i className="ri-tools-fill ml-1.5" />
|
Dev Tools <i className="ri-tools-fill ml-1.5" />
|
||||||
</h6>
|
</h6>
|
||||||
|
@ -27,12 +20,6 @@ export function AccountDevTools() {
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-sm mt-4">
|
<div className="flex items-center text-sm mt-4">
|
||||||
<p className="font-bold">Actions:</p>
|
<p className="font-bold">Actions:</p>
|
||||||
<button
|
|
||||||
className="underline text-red ml-4"
|
|
||||||
onClick={() => sandboxQuickAdd.mutate()}
|
|
||||||
>
|
|
||||||
Quick add item
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="underline text-red ml-4"
|
className="underline text-red ml-4"
|
||||||
onClick={() => deleteAllConnections.mutate()}
|
onClick={() => deleteAllConnections.mutate()}
|
||||||
|
@ -42,12 +29,6 @@ export function AccountDevTools() {
|
||||||
<Link href="http://localhost:3333/admin/bullmq" className="underline text-red ml-4">
|
<Link href="http://localhost:3333/admin/bullmq" className="underline text-red ml-4">
|
||||||
BullMQ Dashboard
|
BullMQ Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
|
||||||
className="underline text-red ml-4"
|
|
||||||
onClick={() => BrowserUtil.copyToClipboard((window as any).JWT)}
|
|
||||||
>
|
|
||||||
Copy JWT
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="underline text-red ml-4"
|
className="underline text-red ml-4"
|
||||||
onClick={() => syncInstitutions.mutate()}
|
onClick={() => syncInstitutions.mutate()}
|
||||||
|
|
|
@ -57,7 +57,7 @@ export default function AccountTypeSelector({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search for an institution"
|
placeholder="Search for an institution"
|
||||||
fixedLeftOverride={<RiSearchLine className="w-5 h-5" />}
|
fixedLeftOverride={<RiSearchLine className="w-5 h-5" />}
|
||||||
inputClassName="pl-10"
|
inputClassName="pl-11"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
|
@ -641,14 +641,7 @@ export class InsightService implements IInsightService {
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
CASE
|
asset_class
|
||||||
-- plaid
|
|
||||||
WHEN plaid_type IN ('equity', 'etf', 'mutual fund', 'derivative') THEN 'stocks'
|
|
||||||
WHEN plaid_type IN ('fixed income') THEN 'fixed_income'
|
|
||||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
|
||||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
|
||||||
ELSE 'other'
|
|
||||||
END AS "asset_class"
|
|
||||||
FROM
|
FROM
|
||||||
"security"
|
"security"
|
||||||
) s ON s.id = h.security_id
|
) s ON s.id = h.security_id
|
||||||
|
@ -694,14 +687,7 @@ export class InsightService implements IInsightService {
|
||||||
INNER JOIN security s ON s.id = h.security_id
|
INNER JOIN security s ON s.id = h.security_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
asset_class AS "category"
|
||||||
-- plaid
|
|
||||||
WHEN s.plaid_type IN ('equity', 'etf', 'mutual fund', 'derivative') THEN 'stocks'
|
|
||||||
WHEN s.plaid_type IN ('fixed income') THEN 'fixed_income'
|
|
||||||
WHEN s.plaid_type IN ('cash', 'loan') THEN 'cash'
|
|
||||||
WHEN s.plaid_type IN ('cryptocurrency') THEN 'crypto'
|
|
||||||
ELSE 'other'
|
|
||||||
END AS "category"
|
|
||||||
) x ON TRUE
|
) x ON TRUE
|
||||||
WHERE
|
WHERE
|
||||||
h.account_id IN ${accountIds}
|
h.account_id IN ${accountIds}
|
||||||
|
@ -828,28 +814,21 @@ export class InsightService implements IInsightService {
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- investment accounts
|
-- investment accounts
|
||||||
SELECT
|
SELECT
|
||||||
s.asset_type,
|
s.asset_class AS "asset_type",
|
||||||
SUM(h.value) AS "amount"
|
SUM(h.value) AS "amount"
|
||||||
FROM
|
FROM
|
||||||
holdings_enriched h
|
holdings_enriched h
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
CASE
|
asset_class
|
||||||
-- plaid
|
|
||||||
WHEN plaid_type IN ('equity', 'etf', 'mutual fund', 'derivative') THEN 'stocks'
|
|
||||||
WHEN plaid_type IN ('fixed income') THEN 'bonds'
|
|
||||||
WHEN plaid_type IN ('cash', 'loan') THEN 'cash'
|
|
||||||
WHEN plaid_type IN ('cryptocurrency') THEN 'crypto'
|
|
||||||
ELSE 'other'
|
|
||||||
END AS "asset_type"
|
|
||||||
FROM
|
FROM
|
||||||
"security"
|
"security"
|
||||||
) s ON s.id = h.security_id
|
) s ON s.id = h.security_id
|
||||||
WHERE
|
WHERE
|
||||||
h.account_id IN ${pAccountIds}
|
h.account_id IN ${pAccountIds}
|
||||||
GROUP BY
|
GROUP BY
|
||||||
s.asset_type
|
s.asset_class
|
||||||
) x
|
) x
|
||||||
GROUP BY
|
GROUP BY
|
||||||
1
|
1
|
||||||
|
|
|
@ -33,7 +33,7 @@ const PROJECTION_ASSET_PARAMS: {
|
||||||
[type in SharedType.ProjectionAssetType]: [mean: Decimal.Value, stddev: Decimal.Value]
|
[type in SharedType.ProjectionAssetType]: [mean: Decimal.Value, stddev: Decimal.Value]
|
||||||
} = {
|
} = {
|
||||||
stocks: ['0.05', '0.186'],
|
stocks: ['0.05', '0.186'],
|
||||||
bonds: ['0.02', '0.052'],
|
fixed_income: ['0.02', '0.052'],
|
||||||
cash: ['-0.02', '0.05'],
|
cash: ['-0.02', '0.05'],
|
||||||
crypto: ['1.0', '1.0'],
|
crypto: ['1.0', '1.0'],
|
||||||
property: ['0.1', '0.2'],
|
property: ['0.1', '0.2'],
|
||||||
|
|
|
@ -100,10 +100,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
|
|
||||||
const accounts = await this._extractAccounts(accessToken)
|
const accounts = await this._extractAccounts(accessToken)
|
||||||
|
|
||||||
const transactions = await this._extractTransactions(
|
const transactions = await this._extractTransactions(accessToken, accounts)
|
||||||
accessToken,
|
|
||||||
accounts.map((a) => a.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`Extracted Teller data for customer ${user.tellerUserId} accounts=${accounts.length} transactions=${transactions.length}`,
|
`Extracted Teller data for customer ${user.tellerUserId} accounts=${accounts.length} transactions=${transactions.length}`,
|
||||||
|
@ -196,26 +193,26 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _extractTransactions(accessToken: string, accountIds: string[]) {
|
private async _extractTransactions(
|
||||||
|
accessToken: string,
|
||||||
|
tellerAccounts: TellerTypes.GetAccountsResponse
|
||||||
|
) {
|
||||||
const accountTransactions = await Promise.all(
|
const accountTransactions = await Promise.all(
|
||||||
accountIds.map(async (accountId) => {
|
tellerAccounts.map(async (tellerAccount) => {
|
||||||
const account = await this.prisma.account.findFirst({
|
const type = TellerUtil.getType(tellerAccount.type)
|
||||||
where: {
|
const classification = AccountUtil.getClassification(type)
|
||||||
tellerAccountId: accountId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const transactions = await SharedUtil.withRetry(
|
const transactions = await SharedUtil.withRetry(
|
||||||
() =>
|
() =>
|
||||||
this.teller.getTransactions({
|
this.teller.getTransactions({
|
||||||
accountId,
|
accountId: tellerAccount.id,
|
||||||
accessToken,
|
accessToken,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (account!.classification === AccountClassification.asset) {
|
if (classification === AccountClassification.asset) {
|
||||||
transactions.forEach((t) => {
|
transactions.forEach((t) => {
|
||||||
t.amount = String(Number(t.amount) * -1)
|
t.amount = String(Number(t.amount) * -1)
|
||||||
})
|
})
|
||||||
|
@ -277,7 +274,7 @@ export class TellerETL implements IETL<Connection, TellerRawData, TellerData> {
|
||||||
pending = EXCLUDED.pending,
|
pending = EXCLUDED.pending,
|
||||||
merchant_name = EXCLUDED.merchant_name,
|
merchant_name = EXCLUDED.merchant_name,
|
||||||
teller_type = EXCLUDED.teller_type,
|
teller_type = EXCLUDED.teller_type,
|
||||||
teller_category = EXCLUDED.teller_category;
|
teller_category = EXCLUDED.teller_category,
|
||||||
category = EXCLUDED.category;
|
category = EXCLUDED.category;
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
|
@ -70,6 +70,7 @@ export class InMemoryQueueFactory implements IQueueFactory {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ignoreJobNames: string[] = [
|
private readonly ignoreJobNames: string[] = [
|
||||||
'sync-all-securities',
|
'sync-all-securities',
|
||||||
|
'sync-teller-institutions',
|
||||||
'sync-plaid-institutions',
|
'sync-plaid-institutions',
|
||||||
'trial-reminders',
|
'trial-reminders',
|
||||||
'send-email',
|
'send-email',
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
import jwks from 'jwks-rsa'
|
|
||||||
import jwt from 'jsonwebtoken'
|
|
||||||
import { SharedType } from '@maybe-finance/shared'
|
|
||||||
|
|
||||||
export const verifyRoleClaims = (claims, role: SharedType.UserRole) => {
|
|
||||||
const customRoleClaim = claims[SharedType.Auth0CustomNamespace.Roles]
|
|
||||||
|
|
||||||
return customRoleClaim && Array.isArray(customRoleClaim) && customRoleClaim.includes(role)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function validateRS256JWT(
|
|
||||||
token: string,
|
|
||||||
domain: string,
|
|
||||||
audience: string
|
|
||||||
): Promise<{
|
|
||||||
authId: string
|
|
||||||
userMetadata: SharedType.MaybeUserMetadata
|
|
||||||
appMetadata: SharedType.MaybeAppMetadata
|
|
||||||
}> {
|
|
||||||
const jwksClient = jwks({
|
|
||||||
rateLimit: true,
|
|
||||||
jwksUri: `https://${domain}/.well-known/jwks.json`,
|
|
||||||
})
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!token) reject('No token provided')
|
|
||||||
|
|
||||||
const parts = token.split(' ')
|
|
||||||
|
|
||||||
if (!parts || parts.length !== 2) reject('JWT must be in format: Bearer <token>')
|
|
||||||
if (parts[0] !== 'Bearer') reject('JWT must be in format: Bearer <token>')
|
|
||||||
|
|
||||||
const rawToken = parts[1]
|
|
||||||
|
|
||||||
jwt.verify(
|
|
||||||
rawToken,
|
|
||||||
(header, cb) => {
|
|
||||||
jwksClient
|
|
||||||
.getSigningKey(header.kid)
|
|
||||||
.then((key) => cb(null, key.getPublicKey()))
|
|
||||||
.catch((err) => cb(err))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audience,
|
|
||||||
issuer: `https://${domain}/`,
|
|
||||||
algorithms: ['RS256'],
|
|
||||||
},
|
|
||||||
(err, payload) => {
|
|
||||||
if (err) return reject(err)
|
|
||||||
if (typeof payload !== 'object') return reject('payload not an object')
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
authId: payload.sub!,
|
|
||||||
appMetadata: payload[SharedType.Auth0CustomNamespace.AppMetadata] ?? {},
|
|
||||||
userMetadata: payload[SharedType.Auth0CustomNamespace.UserMetadata] ?? {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,4 +1,3 @@
|
||||||
export * as AuthUtil from './auth-utils'
|
|
||||||
export * as DbUtil from './db-utils'
|
export * as DbUtil from './db-utils'
|
||||||
export * as PlaidUtil from './plaid-utils'
|
export * as PlaidUtil from './plaid-utils'
|
||||||
export * as TellerUtil from './teller-utils'
|
export * as TellerUtil from './teller-utils'
|
||||||
|
|
|
@ -56,7 +56,13 @@ export type PlanProjectionResponse = {
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectionAssetType = 'stocks' | 'bonds' | 'cash' | 'crypto' | 'property' | 'other'
|
export type ProjectionAssetType =
|
||||||
|
| 'stocks'
|
||||||
|
| 'fixed_income'
|
||||||
|
| 'cash'
|
||||||
|
| 'crypto'
|
||||||
|
| 'property'
|
||||||
|
| 'other'
|
||||||
export type ProjectionLiabilityType = 'credit' | 'loan' | 'other'
|
export type ProjectionLiabilityType = 'credit' | 'loan' | 'other'
|
||||||
|
|
||||||
export type PlanInsights = {
|
export type PlanInsights = {
|
||||||
|
|
|
@ -170,9 +170,6 @@ export type MaybeUserMetadata = Partial<{
|
||||||
// Maybe's "normalized" Auth0 `user.app_metadata` object
|
// Maybe's "normalized" Auth0 `user.app_metadata` object
|
||||||
export type MaybeAppMetadata = {}
|
export type MaybeAppMetadata = {}
|
||||||
|
|
||||||
// The custom roles we have defined in Auth0
|
|
||||||
export type UserRole = 'Admin' | 'CIUser'
|
|
||||||
|
|
||||||
export type PrimaryAuth0Identity = Partial<{
|
export type PrimaryAuth0Identity = Partial<{
|
||||||
connection: string
|
connection: string
|
||||||
provider: string
|
provider: string
|
||||||
|
@ -183,7 +180,6 @@ export type PrimaryAuth0Identity = Partial<{
|
||||||
export type MaybeCustomClaims = {
|
export type MaybeCustomClaims = {
|
||||||
[Auth0CustomNamespace.Email]?: string | null
|
[Auth0CustomNamespace.Email]?: string | null
|
||||||
[Auth0CustomNamespace.Picture]?: string | null
|
[Auth0CustomNamespace.Picture]?: string | null
|
||||||
[Auth0CustomNamespace.Roles]?: UserRole[]
|
|
||||||
[Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata
|
[Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata
|
||||||
[Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata
|
[Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata
|
||||||
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
|
[Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AssetClass" AS ENUM ('cash', 'crypto', 'fixed_income', 'stocks', 'other');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "security"
|
||||||
|
ADD COLUMN "asset_class" "AssetClass" NOT NULL DEFAULT 'other';
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AuthUserRole" AS ENUM ('user', 'admin', 'ci');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "auth_user" ADD COLUMN "role" "AuthUserRole" NOT NULL DEFAULT 'user';
|
|
@ -243,18 +243,27 @@ model InvestmentTransaction {
|
||||||
@@map("investment_transaction")
|
@@map("investment_transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AssetClass {
|
||||||
|
cash
|
||||||
|
crypto
|
||||||
|
fixed_income
|
||||||
|
stocks
|
||||||
|
other
|
||||||
|
}
|
||||||
|
|
||||||
model Security {
|
model Security {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
name String?
|
name String?
|
||||||
symbol String?
|
symbol String?
|
||||||
cusip String?
|
cusip String?
|
||||||
isin String?
|
isin String?
|
||||||
sharesPerContract Decimal? @map("shares_per_contract") @db.Decimal(36, 18)
|
sharesPerContract Decimal? @map("shares_per_contract") @db.Decimal(36, 18)
|
||||||
currencyCode String @default("USD") @map("currency_code")
|
currencyCode String @default("USD") @map("currency_code")
|
||||||
pricingLastSyncedAt DateTime? @map("pricing_last_synced_at") @db.Timestamptz(6)
|
pricingLastSyncedAt DateTime? @map("pricing_last_synced_at") @db.Timestamptz(6)
|
||||||
isBrokerageCash Boolean @default(false) @map("is_brokerage_cash")
|
isBrokerageCash Boolean @default(false) @map("is_brokerage_cash")
|
||||||
|
assetClass AssetClass @default(other) @map("asset_class")
|
||||||
|
|
||||||
// plaid data
|
// plaid data
|
||||||
plaidSecurityId String? @unique @map("plaid_security_id")
|
plaidSecurityId String? @unique @map("plaid_security_id")
|
||||||
|
@ -564,6 +573,12 @@ model AuthAccount {
|
||||||
@@map("auth_account")
|
@@map("auth_account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuthUserRole {
|
||||||
|
user
|
||||||
|
admin
|
||||||
|
ci
|
||||||
|
}
|
||||||
|
|
||||||
model AuthUser {
|
model AuthUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
|
@ -573,6 +588,7 @@ model AuthUser {
|
||||||
emailVerified DateTime? @map("email_verified")
|
emailVerified DateTime? @map("email_verified")
|
||||||
password String?
|
password String?
|
||||||
image String?
|
image String?
|
||||||
|
role AuthUserRole @default(user)
|
||||||
accounts AuthAccount[]
|
accounts AuthAccount[]
|
||||||
sessions AuthSession[]
|
sessions AuthSession[]
|
||||||
|
|
||||||
|
|
|
@ -20432,4 +20432,4 @@ zod@^3.19.1:
|
||||||
zwitch@^1.0.0:
|
zwitch@^1.0.0:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
||||||
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
|
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
|
Loading…
Add table
Add a link
Reference in a new issue