feat: typesafe query and more UI changes

This commit is contained in:
Rafael Taranto 2025-05-22 11:47:50 +01:00
parent d73bb88f1d
commit 64e358f61c
13 changed files with 1347 additions and 109 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@
packages/server/certs/
packages/server/tests/stress/machines
packages/server/tests/stress/config.json
packages/typesafe-db/lib/

305
package-lock.json generated
View file

@ -10,7 +10,8 @@
"license": "./LICENSE",
"workspaces": [
"packages/server",
"packages/admin-ui"
"packages/admin-ui",
"packages/typesafe-db"
],
"devDependencies": {
"@eslint/css": "^0.7.0",
@ -10688,6 +10689,16 @@
"wrappy": "1"
}
},
"node_modules/diff": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz",
"integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz",
@ -10844,6 +10855,22 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.2.tgz",
"integrity": "sha512-lXpXz2ZE1cea1gL4sz2Ipj8y4PiVjytYr3Ij0SWoms1PGxIv7m2CRKuRuCRtHdVuvM/hNJPMxt5PbhboNC4dPQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/downshift": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/downshift/-/downshift-9.0.9.tgz",
@ -11054,6 +11081,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@ -13465,6 +13502,101 @@
"assert-plus": "^1.0.0"
}
},
"node_modules/git-diff": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/git-diff/-/git-diff-2.0.6.tgz",
"integrity": "sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==",
"dev": true,
"license": "ISC",
"dependencies": {
"chalk": "^2.3.2",
"diff": "^3.5.0",
"loglevel": "^1.6.1",
"shelljs": "^0.8.1",
"shelljs.exec": "^1.1.7"
},
"engines": {
"node": ">= 4.8.0"
}
},
"node_modules/git-diff/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/git-diff/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/git-diff/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/git-diff/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true,
"license": "MIT"
},
"node_modules/git-diff/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/git-diff/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/git-diff/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -14629,6 +14761,16 @@
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
},
"node_modules/interpret": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/io-ts": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz",
@ -16941,6 +17083,120 @@
"node": ">=6"
}
},
"node_modules/kysely": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.2.tgz",
"integrity": "sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/kysely-codegen": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.18.5.tgz",
"integrity": "sha512-bj6DMsXcKo0PrrXUk/fdjFgNC6Pwq+HPBCqhNGuD57gwUJZdci2s2OqhNneQeYpAIWGot7/481WdzTyXrClY2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"cosmiconfig": "^9.0.0",
"dotenv": "^16.5.0",
"dotenv-expand": "^12.0.2",
"git-diff": "^2.0.6",
"micromatch": "^4.0.8",
"minimist": "^1.2.8",
"pluralize": "^8.0.0",
"zod": "^3.24.4"
},
"bin": {
"kysely-codegen": "dist/cli/bin.js"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@libsql/kysely-libsql": ">=0.3.0 <0.5.0",
"@tediousjs/connection-string": ">=0.5.0 <0.6.0",
"better-sqlite3": ">=7.6.2 <8.0.0",
"kysely": ">=0.27.0 <1.0.0",
"kysely-bun-sqlite": ">=0.3.2 <1.0.0",
"kysely-bun-worker": ">=1.2.0 <2.0.0",
"mysql2": ">=2.3.3 <4.0.0",
"pg": ">=8.8.0 <9.0.0",
"tarn": ">=3.0.0 <4.0.0",
"tedious": ">=18.0.0 <20.0.0"
},
"peerDependenciesMeta": {
"@libsql/kysely-libsql": {
"optional": true
},
"@tediousjs/connection-string": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"kysely": {
"optional": false
},
"kysely-bun-sqlite": {
"optional": true
},
"kysely-bun-worker": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"tarn": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/kysely-codegen/node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/kysely-codegen/node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/lamassu-admin": {
"resolved": "packages/admin-ui",
"link": true
@ -21212,6 +21468,18 @@
"node": ">=8.10.0"
}
},
"node_modules/rechoir": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
"integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==",
"dev": true,
"dependencies": {
"resolve": "^1.1.6"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/referrer-policy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz",
@ -22888,6 +23156,34 @@
"node": ">=8"
}
},
"node_modules/shelljs": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz",
"integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"glob": "^7.0.0",
"interpret": "^1.0.0",
"rechoir": "^0.6.2"
},
"bin": {
"shjs": "bin/shjs"
},
"engines": {
"node": ">=4"
}
},
"node_modules/shelljs.exec": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/shelljs.exec/-/shelljs.exec-1.1.8.tgz",
"integrity": "sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/shellwords": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
@ -25966,12 +26262,15 @@
"resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
"node_modules/typesafe-db": {
"resolved": "packages/typesafe-db",
"link": true
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -28778,9 +29077,7 @@
}
},
"packages/typesafe-db": {
"name": "lamassu-typesafe-db",
"version": "11.0.0-beta.0",
"extraneous": true,
"license": "../LICENSE",
"dependencies": {
"kysely": "^0.28.2",

View file

@ -10,7 +10,8 @@
},
"workspaces": [
"packages/server",
"packages/admin-ui"
"packages/admin-ui",
"packages/typesafe-db"
],
"devDependencies": {
"@eslint/css": "^0.7.0",

View file

@ -290,7 +290,6 @@ const CustomerProfile = memo(() => {
const [error, setError] = useState(null)
const [clickedItem, setClickedItem] = useState('overview')
const { id: customerId } = useParams()
console.log(customerId)
const {
data: customerResponse,

View file

@ -1,3 +1,6 @@
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Visibility from '@mui/icons-material/Visibility'
import { format } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useMemo } from 'react'
@ -40,7 +43,6 @@ const CustomersList = ({ data, country, onClick, loading }) => {
...alignRight,
},
{
id: 'totalSpent',
accessorKey: 'totalSpent',
size: 152,
enableColumnFilter: false,
@ -49,17 +51,6 @@ const CustomersList = ({ data, country, onClick, loading }) => {
header: 'Total spent',
...alignRight,
},
{
header: 'Last active',
// accessorKey: 'lastActive',
accessorFn: it => new Date(it.lastActive),
size: 133,
enableColumnFilter: false,
Cell: ({ cell }) =>
(cell.getValue() &&
format('yyyy-MM-dd', new Date(cell.getValue()))) ??
'',
},
{
header: 'Last transaction',
...alignRight,
@ -80,12 +71,34 @@ const CustomersList = ({ data, country, onClick, loading }) => {
)
},
},
{
accessorKey: 'lastActive',
header: 'Last active',
size: 133,
enableColumnFilter: false,
Cell: ({ cell }) =>
(cell.getValue() &&
format('yyyy-MM-dd', new Date(cell.getValue()))) ??
'',
},
{
header: 'Status',
id: 'status',
size: 100,
size: 150,
enableColumnFilter: false,
accessorKey: 'authorizedStatus',
sortingFn: (rowA, rowB) => {
const statusOrder = { success: 0, warning: 1, error: 2 }
const statusA = rowA.original.authorizedStatus.type
const statusB = rowB.original.authorizedStatus.type
if (statusA === statusB) {
return rowA.original.authorizedStatus.label.localeCompare(
rowB.original.authorizedStatus.label,
)
}
return statusOrder[statusA] - statusOrder[statusB]
},
Cell: ({ cell }) => <MainStatus statuses={[cell.getValue()]} />,
},
],
@ -101,13 +114,22 @@ const CustomersList = ({ data, country, onClick, loading }) => {
columnVisibility: {
id: false,
},
sorting: [{ id: 'lastActive', desc: true }],
columnPinning: { right: ['mrt-row-actions'] },
},
state: { isLoading: loading },
getRowId: it => it.id,
muiTableBodyRowProps: ({ row }) => ({
onClick: () => onClick(row),
sx: { cursor: 'pointer' },
}),
enableRowActions: true,
positionActionsColumn: 'last',
renderRowActions: ({ row }) => (
<div>
<Tooltip title="Customer page">
<IconButton aria-label="Go to customer" onClick={() => onClick(row)}>
<Visibility />
</IconButton>
</Tooltip>
</div>
),
})
return (

View file

@ -293,4 +293,31 @@ describe('getAuthorizedStatus', () => {
type: 'error',
})
})
it('should return rejected status for blocked custom info request', () => {
const customer = {
authorizedOverride: null,
isSuspended: false,
customInfoRequests: [
{
infoRequestId: '550e8400-e29b-41d4-a716-446655440000',
override: 'blocked',
},
],
}
const triggers = {
automation: 'manual',
overrides: [],
}
const customRequests = [{ id: '550e8400-e29b-41d4-a716-446655440000' }]
const result = getAuthorizedStatus(customer, triggers, customRequests)
expect(result).toEqual({
label: 'Rejected',
type: 'error',
})
})
})

View file

@ -8,7 +8,6 @@ const fs = require('fs')
const util = require('util')
const db = require('./db')
const anonymous = require('../lib/constants').anonymousCustomer
const complianceOverrides = require('./compliance_overrides')
const writeFile = util.promisify(fs.writeFile)
const notifierQueries = require('./notifier/queries')
@ -17,6 +16,7 @@ const sms = require('./sms')
const settingsLoader = require('./new-settings-loader')
const logger = require('./logger')
const externalCompliance = require('./compliance-external')
const { getCustomerList } = require('typesafe-db/lib/customers')
const { APPROVED, RETRY } = require('./plugins/compliance/consts')
@ -489,88 +489,8 @@ function getSlimCustomerByIdBatch(ids) {
return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
}
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
/**
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
*/
function getCustomersList(
phone = null,
name = null,
address = null,
id = null,
email = null,
) {
const passableErrorCodes = _.map(
Pgp.as.text,
TX_PASSTHROUGH_ERROR_CODES,
).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, email, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, GREATEST(created, last_transaction, last_data_provided, last_auth_attempt) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
c.suspended_until > NOW() AS is_suspended,
c.front_camera_path, c.front_camera_override,
c.phone, c.email, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, c.last_auth_attempt,
GREATEST(c.phone_at, c.email_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided,
c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
LEFT OUTER JOIN (
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
) cf GROUP BY cf.customer_id
) ccf ON c.id = ccf.customer_id
LEFT OUTER JOIN (
SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
GROUP BY customer_notes.customer_id
) cn ON c.id = cn.customer_id
WHERE c.id != $2
) AS cl WHERE rn = 1
AND ($4 IS NULL OR phone = $4)
AND ($5 IS NULL OR CONCAT(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $5 OR id_card_data::json->>'firstName' = $5 OR id_card_data::json->>'lastName' = $5)
AND ($6 IS NULL OR id_card_data::json->>'address' = $6)
AND ($7 IS NULL OR id_card_data::json->>'documentNumber' = $7)
AND ($8 IS NULL OR email = $8)
ORDER BY last_active DESC
limit $3`
return db
.any(sql, [
passableErrorCodes,
anonymous.uuid,
null,
phone,
name,
address,
id,
email,
])
.then(customers =>
Promise.all(
_.map(
customer => getCustomInfoRequestsData(customer).then(camelizeDeep),
customers,
),
),
)
function getCustomersList() {
return getCustomerList({ withCustomInfoRequest: true })
}
/**

View file

@ -17,8 +17,7 @@ const resolvers = {
isAnonymous: parent => parent.customerId === anonymous.uuid,
},
Query: {
customers: (...[, { phone, email, name, address, id }]) =>
customers.getCustomersList(phone, name, address, id, email),
customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) =>
customers.getCustomerById(customerId).then(addLastUsedMachineName),
},

View file

@ -0,0 +1,20 @@
{
"name": "typesafe-db",
"version": "11.0.0-beta.0",
"license": "../LICENSE",
"type": "module",
"devDependencies": {
"kysely-codegen": "^0.18.5",
"typescript": "^5.8.3"
},
"scripts": {
"build": "tsc",
"start": "tsc --watch",
"clean": "rm -rf lib",
"generate-types": "kysely-codegen --camel-case --out-file ./src/types/types.d.ts"
},
"dependencies": {
"kysely": "^0.28.2",
"pg": "^8.16.0"
}
}

View file

@ -0,0 +1,159 @@
import db from './db.js'
import { ExpressionBuilder, sql } from 'kysely'
import { Customers, DB } from './types/types.js'
import { jsonObjectFrom } from 'kysely/helpers/postgres'
type CustomerEB = ExpressionBuilder<DB & { c: Customers }, 'c'>
const TX_PASSTHROUGH_ERROR_CODES = [
'operatorCancel',
'scoreThresholdReached',
'walletScoringError',
]
function transactionUnion(eb: CustomerEB) {
return eb
.selectFrom('cashInTxs')
.select([
'created',
'fiat',
'fiatCode',
'errorCode',
eb.val('cashIn').as('txClass'),
])
.where(({ eb, and, or, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]),
]),
)
.unionAll(
eb
.selectFrom('cashOutTxs')
.select([
'created',
'fiat',
'fiatCode',
'errorCode',
eb.val('cashOut').as('txClass'),
])
.where(({ eb, and, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
eb('confirmedAt', 'is not', null),
]),
),
)
}
function joinLatestTx(eb: CustomerEB) {
return eb
.selectFrom(eb =>
transactionUnion(eb).orderBy('created', 'desc').limit(1).as('lastTx'),
)
.select(['fiatCode', 'fiat', 'txClass', 'created'])
.as('lastTx')
}
function joinTxsTotals(eb: CustomerEB) {
return eb
.selectFrom(eb => transactionUnion(eb).as('combinedTxs'))
.select([
eb => eb.fn.coalesce(eb.fn.countAll(), eb.val(0)).as('totalTxs'),
eb =>
eb.fn
.coalesce(
eb.fn.sum(
eb
.case()
.when(
eb.or([
eb('combinedTxs.errorCode', 'is', null),
eb(
'combinedTxs.errorCode',
'not in',
TX_PASSTHROUGH_ERROR_CODES,
),
]),
)
.then(eb.ref('combinedTxs.fiat'))
.else(0)
.end(),
),
eb.val(0),
)
.as('totalSpent'),
])
.as('txStats')
}
interface GetCustomerListOptions {
withCustomInfoRequest: boolean
}
const defaultOptions: GetCustomerListOptions = {
withCustomInfoRequest: false,
}
// TODO left join lateral is having issues deriving type
function getCustomerList(
options: GetCustomerListOptions = defaultOptions,
): Promise<any[]> {
return db
.selectFrom('customers as c')
.leftJoinLateral(joinTxsTotals, join => join.onTrue())
.leftJoinLateral(joinLatestTx, join => join.onTrue())
.select(({ eb, fn, val, ref }) => [
'id',
'authorizedOverride',
'frontCameraPath',
'frontCameraOverride',
'idCardPhotoPath',
'idCardPhotoOverride',
'idCardData',
'idCardDataOverride',
'email',
'usSsn',
'usSsnOverride',
'sanctions',
'sanctionsOverride',
'txStats.totalSpent',
'txStats.totalTxs',
ref('lastTx.fiatCode').as('lastTxFiatCode'),
ref('lastTx.fiat').as('lastTxFiat'),
ref('lastTx.txClass').as('lastTxClass'),
fn<Date>('GREATEST', [
'c.created',
// 'lastTx.created',
'c.phoneAt',
'c.emailAt',
'c.idCardDataAt',
'c.frontCameraAt',
'c.idCardPhotoAt',
'c.usSsnAt',
'c.lastAuthAttempt',
]).as('lastActive'),
eb('c.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
fn<number>('GREATEST', [
val(0),
fn<number>('date_part', [
val('day'),
eb('c.suspendedUntil', '-', fn<Date>('NOW', [])),
]),
]).as('daysSuspended'),
])
.$if(options.withCustomInfoRequest, qb =>
qb.select(({ eb, ref }) =>
jsonObjectFrom(
eb
.selectFrom('customersCustomInfoRequests')
.selectAll()
.where('customerId', '=', ref('c.id')),
).as('customInfoRequestData'),
),
)
.orderBy('lastActive', 'desc')
.execute()
}
export { getCustomerList }

View file

@ -0,0 +1,24 @@
import { DB } from './types/types.js'
import { Pool } from 'pg'
import { Kysely, PostgresDialect, CamelCasePlugin } from 'kysely'
import { PSQL_URL } from 'lamassu-server/lib/constants.js'
const dialect = new PostgresDialect({
pool: new Pool({
connectionString: PSQL_URL,
max: 5,
}),
})
export default new Kysely<DB>({
dialect,
plugins: [new CamelCasePlugin()],
log(event) {
if (event.level === 'query') {
console.log('Query:', event.query.sql)
console.log('Parameters:', event.query.parameters)
console.log('Duration:', event.queryDurationMillis + 'ms')
console.log('---')
}
},
})

View file

@ -0,0 +1,745 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/
import type { ColumnType } from 'kysely'
export type AuthTokenType = 'reset_password' | 'reset_twofa'
export type CashUnit =
| 'cashbox'
| 'cassette1'
| 'cassette2'
| 'cassette3'
| 'cassette4'
| 'recycler1'
| 'recycler2'
| 'recycler3'
| 'recycler4'
| 'recycler5'
| 'recycler6'
export type CashUnitOperationType =
| 'cash-box-empty'
| 'cash-box-refill'
| 'cash-cassette-1-count-change'
| 'cash-cassette-1-empty'
| 'cash-cassette-1-refill'
| 'cash-cassette-2-count-change'
| 'cash-cassette-2-empty'
| 'cash-cassette-2-refill'
| 'cash-cassette-3-count-change'
| 'cash-cassette-3-empty'
| 'cash-cassette-3-refill'
| 'cash-cassette-4-count-change'
| 'cash-cassette-4-empty'
| 'cash-cassette-4-refill'
| 'cash-recycler-1-count-change'
| 'cash-recycler-1-empty'
| 'cash-recycler-1-refill'
| 'cash-recycler-2-count-change'
| 'cash-recycler-2-empty'
| 'cash-recycler-2-refill'
| 'cash-recycler-3-count-change'
| 'cash-recycler-3-empty'
| 'cash-recycler-3-refill'
| 'cash-recycler-4-count-change'
| 'cash-recycler-4-empty'
| 'cash-recycler-4-refill'
| 'cash-recycler-5-count-change'
| 'cash-recycler-5-empty'
| 'cash-recycler-5-refill'
| 'cash-recycler-6-count-change'
| 'cash-recycler-6-empty'
| 'cash-recycler-6-refill'
export type ComplianceType =
| 'authorized'
| 'front_camera'
| 'hard_limit'
| 'id_card_data'
| 'id_card_photo'
| 'sanctions'
| 'sms'
| 'us_ssn'
export type DiscountSource = 'individualDiscount' | 'promoCode'
export type ExternalComplianceStatus =
| 'APPROVED'
| 'PENDING'
| 'REJECTED'
| 'RETRY'
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>
export type Json = JsonValue
export type JsonArray = JsonValue[]
export type JsonObject = {
[x: string]: JsonValue | undefined
}
export type JsonPrimitive = boolean | number | string | null
export type JsonValue = JsonArray | JsonObject | JsonPrimitive
export type NotificationType =
| 'compliance'
| 'cryptoBalance'
| 'error'
| 'fiatBalance'
| 'highValueTransaction'
| 'security'
| 'transaction'
export type Numeric = ColumnType<string, number | string, number | string>
export type Role = 'superuser' | 'user'
export type SmsNoticeEvent =
| 'cash_out_dispense_ready'
| 'sms_code'
| 'sms_receipt'
export type StatusStage =
| 'authorized'
| 'confirmed'
| 'instant'
| 'insufficientFunds'
| 'notSeen'
| 'published'
| 'rejected'
export type Timestamp = ColumnType<Date, Date | string, Date | string>
export type TradeType = 'buy' | 'sell'
export type TransactionBatchStatus = 'failed' | 'open' | 'ready' | 'sent'
export type VerificationType = 'automatic' | 'blocked' | 'verified'
export interface AuthTokens {
expire: Generated<Timestamp>
token: string
type: AuthTokenType
userId: string | null
}
export interface Bills {
cashboxBatchId: string | null
cashInFee: Numeric
cashInTxsId: string
created: Generated<Timestamp>
cryptoCode: Generated<string | null>
destinationUnit: Generated<CashUnit>
deviceTime: Int8
fiat: number
fiatCode: string
id: string
legacy: Generated<boolean | null>
}
export interface Blacklist {
address: string
blacklistMessageId: Generated<string>
}
export interface BlacklistMessages {
allowToggle: Generated<boolean>
content: string
id: string
label: string
}
export interface CashInActions {
action: string
created: Generated<Timestamp>
error: string | null
errorCode: string | null
id: Generated<number>
txHash: string | null
txId: string
}
export interface CashInTxs {
batched: Generated<boolean>
batchId: string | null
batchTime: Timestamp | null
cashInFee: Numeric
commissionPercentage: Generated<Numeric | null>
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
customerId: Generated<string | null>
deviceId: string
discount: number | null
discountSource: DiscountSource | null
email: string | null
error: string | null
errorCode: string | null
fee: Int8 | null
fiat: Numeric
fiatCode: string
id: string
isPaperWallet: Generated<boolean | null>
minimumTx: number
operatorCompleted: Generated<boolean>
phone: string | null
rawTickerPrice: Generated<Numeric | null>
send: Generated<boolean>
sendConfirmed: Generated<boolean>
sendPending: Generated<boolean>
sendTime: Timestamp | null
termsAccepted: Generated<boolean>
timedout: Generated<boolean>
toAddress: string
txCustomerPhotoAt: Timestamp | null
txCustomerPhotoPath: string | null
txHash: string | null
txVersion: number
walletScore: number | null
}
export interface CashinTxTrades {
tradeId: Generated<number>
txId: string
}
export interface CashOutActions {
action: string
created: Generated<Timestamp>
denomination1: number | null
denomination2: number | null
denomination3: number | null
denomination4: number | null
denominationRecycler1: number | null
denominationRecycler2: number | null
denominationRecycler3: number | null
denominationRecycler4: number | null
denominationRecycler5: number | null
denominationRecycler6: number | null
deviceId: Generated<string>
deviceTime: Int8 | null
dispensed1: number | null
dispensed2: number | null
dispensed3: number | null
dispensed4: number | null
dispensedRecycler1: number | null
dispensedRecycler2: number | null
dispensedRecycler3: number | null
dispensedRecycler4: number | null
dispensedRecycler5: number | null
dispensedRecycler6: number | null
error: string | null
errorCode: string | null
id: Generated<number>
layer2Address: string | null
provisioned1: number | null
provisioned2: number | null
provisioned3: number | null
provisioned4: number | null
provisionedRecycler1: number | null
provisionedRecycler2: number | null
provisionedRecycler3: number | null
provisionedRecycler4: number | null
provisionedRecycler5: number | null
provisionedRecycler6: number | null
redeem: Generated<boolean>
rejected1: number | null
rejected2: number | null
rejected3: number | null
rejected4: number | null
rejectedRecycler1: number | null
rejectedRecycler2: number | null
rejectedRecycler3: number | null
rejectedRecycler4: number | null
rejectedRecycler5: number | null
rejectedRecycler6: number | null
toAddress: string | null
txHash: string | null
txId: string
}
export interface CashOutTxs {
commissionPercentage: Generated<Numeric | null>
confirmedAt: Timestamp | null
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
customerId: Generated<string | null>
denomination1: number | null
denomination2: number | null
denomination3: number | null
denomination4: number | null
denominationRecycler1: number | null
denominationRecycler2: number | null
denominationRecycler3: number | null
denominationRecycler4: number | null
denominationRecycler5: number | null
denominationRecycler6: number | null
deviceId: string
discount: number | null
discountSource: DiscountSource | null
dispense: Generated<boolean>
dispenseConfirmed: Generated<boolean | null>
email: string | null
error: string | null
errorCode: string | null
fiat: Numeric
fiatCode: string
fixedFee: Generated<Numeric>
hdIndex: Generated<number | null>
id: string
layer2Address: string | null
notified: Generated<boolean>
phone: string | null
provisioned1: number | null
provisioned2: number | null
provisioned3: number | null
provisioned4: number | null
provisionedRecycler1: number | null
provisionedRecycler2: number | null
provisionedRecycler3: number | null
provisionedRecycler4: number | null
provisionedRecycler5: number | null
provisionedRecycler6: number | null
publishedAt: Timestamp | null
rawTickerPrice: Generated<Numeric | null>
receivedCryptoAtoms: Generated<Numeric | null>
redeem: Generated<boolean>
status: Generated<StatusStage>
swept: Generated<boolean>
termsAccepted: Generated<boolean>
timedout: Generated<boolean>
toAddress: string
txCustomerPhotoAt: Timestamp | null
txCustomerPhotoPath: string | null
txVersion: number
walletScore: number | null
}
export interface CashoutTxTrades {
tradeId: Generated<number>
txId: string
}
export interface CashUnitOperation {
billCountOverride: number | null
created: Generated<Timestamp>
deviceId: string | null
id: string
operationType: CashUnitOperationType
performedBy: string | null
}
export interface ComplianceOverrides {
complianceType: ComplianceType
customerId: string | null
id: string
overrideAt: Timestamp
overrideBy: string | null
verification: VerificationType
}
export interface Coupons {
code: string
discount: number
id: string
softDeleted: Generated<boolean | null>
}
export interface CustomerCustomFieldPairs {
customerId: string
customFieldId: string
value: string
}
export interface CustomerExternalCompliance {
customerId: string
externalId: string
lastKnownStatus: ExternalComplianceStatus | null
lastUpdated: Generated<Timestamp>
service: string
}
export interface CustomerNotes {
content: Generated<string>
created: Generated<Timestamp>
customerId: string
id: string
lastEditedAt: Timestamp | null
lastEditedBy: string | null
title: Generated<string>
}
export interface Customers {
address: string | null
authorizedAt: Timestamp | null
authorizedOverride: Generated<VerificationType>
authorizedOverrideAt: Timestamp | null
authorizedOverrideBy: string | null
created: Generated<Timestamp>
email: string | null
emailAt: Timestamp | null
frontCameraAt: Timestamp | null
frontCameraOverride: Generated<VerificationType>
frontCameraOverrideAt: Timestamp | null
frontCameraOverrideBy: string | null
frontCameraPath: string | null
id: string
idCardData: Json | null
idCardDataAt: Timestamp | null
idCardDataExpiration: Timestamp | null
idCardDataNumber: string | null
idCardDataOverride: Generated<VerificationType>
idCardDataOverrideAt: Timestamp | null
idCardDataOverrideBy: string | null
idCardDataRaw: string | null
idCardPhotoAt: Timestamp | null
idCardPhotoOverride: Generated<VerificationType>
idCardPhotoOverrideAt: Timestamp | null
idCardPhotoOverrideBy: string | null
idCardPhotoPath: string | null
isTestCustomer: Generated<boolean>
lastAuthAttempt: Timestamp | null
lastUsedMachine: string | null
name: string | null
phone: string | null
phoneAt: Timestamp | null
phoneOverride: Generated<VerificationType>
phoneOverrideAt: Timestamp | null
phoneOverrideBy: string | null
sanctions: boolean | null
sanctionsAt: Timestamp | null
sanctionsOverride: Generated<VerificationType>
sanctionsOverrideAt: Timestamp | null
sanctionsOverrideBy: string | null
smsOverride: Generated<VerificationType>
smsOverrideAt: Timestamp | null
smsOverrideBy: string | null
subscriberInfo: Json | null
subscriberInfoAt: Timestamp | null
subscriberInfoBy: string | null
suspendedUntil: Timestamp | null
usSsn: string | null
usSsnAt: Timestamp | null
usSsnOverride: Generated<VerificationType>
usSsnOverrideAt: Timestamp | null
usSsnOverrideBy: string | null
}
export interface CustomersCustomInfoRequests {
customerData: Json
customerId: string
infoRequestId: string
override: Generated<VerificationType>
overrideAt: Timestamp | null
overrideBy: string | null
}
export interface CustomFieldDefinitions {
active: Generated<boolean | null>
id: string
label: string
}
export interface CustomInfoRequests {
customRequest: Json | null
enabled: Generated<boolean>
id: string
}
export interface Devices {
cassette1: Generated<number>
cassette2: Generated<number>
cassette3: Generated<number>
cassette4: Generated<number>
created: Generated<Timestamp>
deviceId: string
diagnosticsFrontUpdatedAt: Timestamp | null
diagnosticsScanUpdatedAt: Timestamp | null
diagnosticsTimestamp: Timestamp | null
display: Generated<boolean>
lastOnline: Generated<Timestamp>
location: Generated<Json>
model: string | null
name: string
numberOfCassettes: Generated<number>
numberOfRecyclers: Generated<number>
paired: Generated<boolean>
recycler1: Generated<number>
recycler2: Generated<number>
recycler3: Generated<number>
recycler4: Generated<number>
recycler5: Generated<number>
recycler6: Generated<number>
userConfigId: number | null
version: string | null
}
export interface EditedCustomerData {
created: Generated<Timestamp>
customerId: string
frontCameraAt: Timestamp | null
frontCameraBy: string | null
frontCameraPath: string | null
idCardData: Json | null
idCardDataAt: Timestamp | null
idCardDataBy: string | null
idCardPhotoAt: Timestamp | null
idCardPhotoBy: string | null
idCardPhotoPath: string | null
name: string | null
nameAt: Timestamp | null
nameBy: string | null
subscriberInfo: Json | null
subscriberInfoAt: Timestamp | null
subscriberInfoBy: string | null
usSsn: string | null
usSsnAt: Timestamp | null
usSsnBy: string | null
}
export interface EmptyUnitBills {
cashboxBatchId: string | null
created: Generated<Timestamp>
deviceId: string
fiat: number
fiatCode: string
id: string
}
export interface HardwareCredentials {
created: Generated<Timestamp | null>
data: Json
id: string
lastUsed: Generated<Timestamp | null>
userId: string
}
export interface IndividualDiscounts {
customerId: string
discount: number
id: string
softDeleted: Generated<boolean | null>
}
export interface Logs {
deviceId: string | null
id: string
logLevel: string | null
message: string | null
serial: Generated<number>
serverTimestamp: Generated<Timestamp>
timestamp: Timestamp | null
}
export interface MachineEvents {
created: Generated<Timestamp>
deviceId: string
deviceTime: Timestamp | null
eventType: string
id: string
note: string | null
}
export interface MachineNetworkHeartbeat {
averagePacketLoss: Numeric
averageResponseTime: Numeric
created: Generated<Timestamp>
deviceId: string
id: string
}
export interface MachineNetworkPerformance {
created: Generated<Timestamp>
deviceId: string
downloadSpeed: Numeric
}
export interface MachinePings {
deviceId: string
deviceTime: Timestamp
updated: Generated<Timestamp>
}
export interface Migrations {
data: Json
id: Generated<number>
}
export interface Notifications {
created: Generated<Timestamp>
detail: Json | null
id: string
message: string
read: Generated<boolean>
type: NotificationType
valid: Generated<boolean>
}
export interface OperatorIds {
id: Generated<number>
operatorId: string
service: string
}
export interface PairingTokens {
created: Generated<Timestamp>
id: Generated<number>
name: string
token: string | null
}
export interface SanctionsLogs {
created: Generated<Timestamp>
customerId: string
deviceId: string
id: string
sanctionedAliasFullName: string
sanctionedAliasId: string | null
sanctionedId: string
}
export interface ServerLogs {
deviceId: string | null
id: string
logLevel: string | null
message: string | null
meta: Json | null
timestamp: Generated<Timestamp | null>
}
export interface SmsNotices {
allowToggle: Generated<boolean>
created: Generated<Timestamp>
enabled: Generated<boolean>
event: SmsNoticeEvent
id: string
message: string
messageName: string
}
export interface Trades {
created: Generated<Timestamp>
cryptoAtoms: Numeric
cryptoCode: string
error: string | null
fiatCode: string
id: Generated<number>
type: TradeType
}
export interface TransactionBatches {
closedAt: Timestamp | null
createdAt: Generated<Timestamp>
cryptoCode: string
errorMessage: string | null
id: string
status: Generated<TransactionBatchStatus>
}
export interface UnpairedDevices {
deviceId: string
id: string
model: string | null
name: string | null
paired: Timestamp
unpaired: Timestamp
}
export interface UserConfig {
created: Generated<Timestamp>
data: Json
id: Generated<number>
schemaVersion: Generated<number>
type: string
valid: boolean
}
export interface UserRegisterTokens {
expire: Generated<Timestamp>
role: Generated<Role | null>
token: string
useFido: Generated<boolean | null>
username: string
}
export interface Users {
created: Generated<Timestamp>
enabled: Generated<boolean | null>
id: string
lastAccessed: Generated<Timestamp>
lastAccessedAddress: string | null
lastAccessedFrom: string | null
password: string | null
role: Generated<Role>
tempTwofaCode: string | null
twofaCode: string | null
username: string
}
export interface UserSessions {
expire: Timestamp
sess: Json
sid: string
}
export interface DB {
authTokens: AuthTokens
bills: Bills
blacklist: Blacklist
blacklistMessages: BlacklistMessages
cashInActions: CashInActions
cashInTxs: CashInTxs
cashinTxTrades: CashinTxTrades
cashOutActions: CashOutActions
cashOutTxs: CashOutTxs
cashoutTxTrades: CashoutTxTrades
cashUnitOperation: CashUnitOperation
complianceOverrides: ComplianceOverrides
coupons: Coupons
customerCustomFieldPairs: CustomerCustomFieldPairs
customerExternalCompliance: CustomerExternalCompliance
customerNotes: CustomerNotes
customers: Customers
customersCustomInfoRequests: CustomersCustomInfoRequests
customFieldDefinitions: CustomFieldDefinitions
customInfoRequests: CustomInfoRequests
devices: Devices
editedCustomerData: EditedCustomerData
emptyUnitBills: EmptyUnitBills
hardwareCredentials: HardwareCredentials
individualDiscounts: IndividualDiscounts
logs: Logs
machineEvents: MachineEvents
machineNetworkHeartbeat: MachineNetworkHeartbeat
machineNetworkPerformance: MachineNetworkPerformance
machinePings: MachinePings
migrations: Migrations
notifications: Notifications
operatorIds: OperatorIds
pairingTokens: PairingTokens
sanctionsLogs: SanctionsLogs
serverLogs: ServerLogs
smsNotices: SmsNotices
trades: Trades
transactionBatches: TransactionBatches
unpairedDevices: UnpairedDevices
userConfig: UserConfig
userRegisterTokens: UserRegisterTokens
users: Users
userSessions: UserSessions
}

View file

@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 22",
"_version": "22.0.0",
"compilerOptions": {
"lib": ["es2023"],
"module": "nodenext",
"target": "es2022",
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node16",
"outDir": "./lib",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["lib", "node_modules"]
}