diff --git a/.bruno/API/api-errors.bru b/.bruno/API/api-errors.bru new file mode 100644 index 0000000..e6b0370 --- /dev/null +++ b/.bruno/API/api-errors.bru @@ -0,0 +1,20 @@ +meta { + name: /api/errors + type: http + seq: 22 +} + +delete { + url: {{base_url}}/api/errors?id=4 + body: none + auth: inherit +} + +params:query { + id: 4 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/client/admin/components/roles_page.tsx b/client/admin/components/roles_page.tsx index eb6a048..887ad02 100644 --- a/client/admin/components/roles_page.tsx +++ b/client/admin/components/roles_page.tsx @@ -2,6 +2,7 @@ import { h } from 'preact' import { useCallback, useEffect, useState } from 'preact/hooks' import { route } from 'preact-router' import rek, { type FetchError } from 'rek' +import type { Selectable } from 'kysely' import { useNotifications } from '../contexts/notifications.tsx' import useItemsReducer from '../hooks/use_items_reducer.ts' @@ -11,12 +12,12 @@ import RolesTable from './roles_table.tsx' import PageHeader from './page_header.tsx' import Section from './section.tsx' -import type { Role } from '../../../server/services/roles/types.ts' +import type { Role } from '../../../shared/types.db.ts' const RolesPage = () => { const { notify } = useNotifications() - const [roles, actions] = useItemsReducer() - const [edit, setEdit] = useState(null) + const [roles, actions] = useItemsReducer>() + const [edit, setEdit] = useState | null | undefined>(null) useEffect(() => { rek('/api/roles' + location.search).then(actions.reset) diff --git a/client/admin/components/users_page.tsx b/client/admin/components/users_page.tsx index 0620dd4..7c54dfe 100644 --- a/client/admin/components/users_page.tsx +++ b/client/admin/components/users_page.tsx @@ -2,16 +2,17 @@ import { h } from 'preact' import { useCallback, useEffect } from 'preact/hooks' import { route } from 'preact-router' import rek from 'rek' +import type { Selectable } from 'kysely' import useItemsReducer from '../hooks/use_items_reducer.ts' import UsersTable from './users_table.tsx' import PageHeader from './page_header.tsx' import Section from './section.tsx' -import type { User } from '../../../server/services/users/types.ts' +import type { User } from '../../../shared/types.db.ts' const UsersPage = () => { - const [users, actions] = useItemsReducer() + const [users, actions] = useItemsReducer>() useEffect(() => { rek('/api/users' + location.search).then(actions.reset) diff --git a/client/admin/components/users_table.tsx b/client/admin/components/users_table.tsx index 03c3217..149a820 100644 --- a/client/admin/components/users_table.tsx +++ b/client/admin/components/users_table.tsx @@ -1,9 +1,13 @@ import { h, type FunctionComponent } from 'preact' import { Table, Th, type onSortByFunction } from './table.tsx' +import type { Selectable } from 'kysely' -import type { User } from '../../../server/services/users/types.ts' +import type { User, Role } from '../../../shared/types.db.ts' -const UsersTable: FunctionComponent<{ users: User[]; onSortBy: onSortByFunction }> = ({ users, onSortBy }) => ( +const UsersTable: FunctionComponent<{ + users: (Selectable & { roles?: Role[] })[] + onSortBy: onSortByFunction +}> = ({ users, onSortBy }) => ( diff --git a/package.json b/package.json index fe48b1b..17d1df8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "fastify-session-redis-store": "^7.1.2", "fastify-type-provider-zod": "^6.1.0", "ioredis": "^5.8.2", - "knex": "^3.1.0", "kysely": "^0.28.9", "lodash": "^4.17.21", "lowline": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 585cdcf..3774b7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: ioredis: specifier: ^5.8.2 version: 5.8.2 - knex: - specifier: ^3.1.0 - version: 3.1.0(pg@8.16.3) kysely: specifier: ^0.28.9 version: 0.28.9 @@ -1167,16 +1164,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.19: - resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} - commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -1224,15 +1214,6 @@ packages: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1360,10 +1341,6 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - esm@3.2.25: - resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} - engines: {node: '>=6'} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1456,17 +1433,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - getopts@2.3.0: - resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} - glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -1546,10 +1516,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - interpret@2.2.0: - resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} - engines: {node: '>= 0.10'} - ioredis@5.8.2: resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} @@ -1578,10 +1544,6 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-date-object@1.1.0: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} @@ -1687,34 +1649,6 @@ packages: engines: {node: '>=6'} hasBin: true - knex@3.1.0: - resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} - engines: {node: '>=16'} - hasBin: true - peerDependencies: - better-sqlite3: '*' - mysql: '*' - mysql2: '*' - pg: '*' - pg-native: '*' - sqlite3: '*' - tedious: '*' - peerDependenciesMeta: - better-sqlite3: - optional: true - mysql: - optional: true - mysql2: - optional: true - pg: - optional: true - pg-native: - optional: true - sqlite3: - optional: true - tedious: - optional: true - kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -1795,9 +1729,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1869,9 +1800,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.1: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} @@ -1882,9 +1810,6 @@ packages: pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} - pg-connection-string@2.6.2: - resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} - pg-connection-string@2.9.1: resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} @@ -2021,10 +1946,6 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - rechoir@0.8.0: - resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} - engines: {node: '>= 10.13.0'} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -2044,15 +1965,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -2229,24 +2141,12 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tarn@3.0.2: - resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} - engines: {node: '>=8.0.0'} - thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} - tildify@2.0.0: - resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} - engines: {node: '>=8'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -3264,12 +3164,8 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.19: {} - colorette@2.0.20: {} - commander@10.0.1: {} - commander@14.0.2: {} commander@7.2.0: {} @@ -3320,10 +3216,6 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -3500,8 +3392,6 @@ snapshots: escape-string-regexp@1.0.5: {} - esm@3.2.25: {} - estree-walker@2.0.2: {} eventemitter3@5.0.1: {} @@ -3608,15 +3498,11 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - getopts@2.3.0: {} - glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -3694,8 +3580,6 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - interpret@2.2.0: {} - ioredis@5.8.2: dependencies: '@ioredis/commands': 1.4.0 @@ -3734,10 +3618,6 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-date-object@1.1.0: dependencies: call-bound: 1.0.4 @@ -3854,27 +3734,6 @@ snapshots: json5@2.2.3: {} - knex@3.1.0(pg@8.16.3): - dependencies: - colorette: 2.0.19 - commander: 10.0.1 - debug: 4.3.4 - escalade: 3.2.0 - esm: 3.2.25 - get-package-type: 0.1.0 - getopts: 2.3.0 - interpret: 2.2.0 - lodash: 4.17.21 - pg-connection-string: 2.6.2 - rechoir: 0.8.0 - resolve-from: 5.0.0 - tarn: 3.0.2 - tildify: 2.0.0 - optionalDependencies: - pg: 8.16.3 - transitivePeerDependencies: - - supports-color - kolorist@1.8.0: {} kysely@0.28.9: {} @@ -3953,8 +3812,6 @@ snapshots: minipass@7.1.2: {} - ms@2.1.2: {} - ms@2.1.3: {} nano-spawn@2.0.0: {} @@ -4020,8 +3877,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@2.0.1: dependencies: lru-cache: 11.2.2 @@ -4032,8 +3887,6 @@ snapshots: pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.6.2: {} - pg-connection-string@2.9.1: {} pg-int8@1.0.1: {} @@ -4153,10 +4006,6 @@ snapshots: real-require@0.2.0: {} - rechoir@0.8.0: - dependencies: - resolve: 1.22.11 - redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -4176,14 +4025,6 @@ snapshots: require-from-string@2.0.2: {} - resolve-from@5.0.0: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -4386,18 +4227,12 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: {} - tarn@3.0.2: {} - thread-stream@3.1.0: dependencies: real-require: 0.2.0 - tildify@2.0.0: {} - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/server/lib/knex.ts b/server/lib/knex.ts deleted file mode 100644 index a97d2ca..0000000 --- a/server/lib/knex.ts +++ /dev/null @@ -1,16 +0,0 @@ -import knex from 'knex' -import env from '../env.ts' - -export default knex({ - client: 'pg', - - connection: { - database: env.PGDATABASE, - host: env.PGHOST, - password: env.PGPASSWORD, - port: env.PGPORT, - user: env.PGUSER, - }, - - acquireConnectionTimeout: 30000, -}) diff --git a/server/lib/knex_helpers.ts b/server/lib/knex_helpers.ts deleted file mode 100644 index be6f3db..0000000 --- a/server/lib/knex_helpers.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { QueryBuilder } from 'knex' -import _ from 'lodash' - -export function convertToReturning(obj: Record) { - return _.map(obj, (value, key) => _.isString(value) && `${value} as ${key}`).filter(_.identity) -} - -export function columnAs(name: string, table: string, transformer = _.snakeCase) { - const transformed = transformer(name) - - if (transformed !== name) { - // knex automagically wraps everything in ", so they are not needed around ${name} - return `${table ? table + '.' : ''}${transformed} as ${name}` - } - - return table ? `${table}.${name}` : name -} - -export function columnBuilder(builder: QueryBuilder, columns: Record, columnNames: string[]) { - for (const columnName of columnNames) { - const column = columns[columnName] - - if (column) { - // @ts-ignore - builder.column(column) - } - } - - return builder -} - -export function where(builder: QueryBuilder, json: Record) { - return _.reduce( - json, - (builder, value, key) => { - if (value === null) { - // @ts-ignore - return builder.whereNull(key) - } else if (Array.isArray(value)) { - // @ts-ignore - return builder.whereIn(key, value) - } - - // @ts-ignore - return builder.where(key, value) - }, - builder, - ) -} - -export const one = (result: ANY) => (result.rows ? result.rows[0] : undefined) - -export const many = (result: ANY) => (result.rows ? result.rows : []) diff --git a/server/lib/knex_rest_queries.ts b/server/lib/knex_rest_queries.ts deleted file mode 100644 index 79ca9a9..0000000 --- a/server/lib/knex_rest_queries.ts +++ /dev/null @@ -1,316 +0,0 @@ -// @ts-nocheck -import { type Knex } from 'knex' -import _ from 'lodash' - -import { where } from './knex_helpers.ts' - -type QueryName = - | 'create' - | 'createMany' - | 'find' - | 'findOne' - | 'findById' - | 'getAll' - | 'paginate' - | 'remove' - | 'removeById' - | 'replace' - | 'update' - -interface Options { - knex: Knex - emitter?: ANY - pick?: QueryName[] - omit?: QueryName[] - columns: string[] - table: string - selects?: Record -} - -export const count = ({ knex, table, columns }: Pick) => - function count(query: Record, client = knex) { - let builder = client.table(table) - - if (!_.isEmpty(query)) { - builder = where(builder, _.pick(query, columns)) - } - - return builder.count().then((count) => parseInt(count[0].count, 10)) - } - -export const create = ({ knex, table, columns, emitter }: Pick) => - function create(json, client = knex) { - return client - .table(table) - .insert(_.pickBy(json, (value, key) => value !== undefined && columns.includes(key))) - .returning('*') - .then((result) => { - if (emitter) { - emitter.emit('db', { table, action: 'create', result }) - } - - return result[0] - }) - } - -export const createMany = ({ - knex, - table, - columns, - emitter, -}: Pick) => - function createMany(collection, client = knex) { - if (!Array.isArray(collection)) { - collection = [collection] - } - - const values = collection.map((item) => - _.pickBy(item, (value, key) => value !== undefined && columns.includes(key)), - ) - - return client - .table(table) - .insert(values) - .returning('*') - .then((result) => { - if (emitter) { - emitter.emit('db', { table, action: 'create', result }) - } - - return result - }) - } - -export const find = ({ - knex, - table, - columns, - allSelects, - defaults = {}, -}: Pick & { allSelects: string[]; defaults: Record }) => - function find(json, select, client: Knex) { - if (!client) { - // TODO figure out if better, eg instanceof, check is possible - if (select && select.andWhereNotBetween) { - client = select - select = null - } else { - client = knex - } - } - - const { offset = defaults.offset, limit = defaults.limit, sort = defaults.sort, ...query } = json - - let builder = client.table(table).select(select ? _.pick(allSelects, select) : allSelects) - - if (!_.isEmpty(query)) { - builder = where(builder, _.pick(query, columns)) - } - - builder = builder.orderBy(sort || 'id', sort?.startsWith('-') ? 'desc' : 'asc') - - if (limit) { - builder = builder.limit(limit) - } - - if (offset) { - builder = builder.offset(offset) - } - - return builder - } - -export const findById = ({ knex, table, allSelects }: Pick & { allSelects: string[] }) => - function findById(id: number | string, select, client: Knex) { - if (!client) { - // TODO figure out if better, eg instanceof, check is possible - if (select && select.andWhereNotBetween) { - client = select - select = null - } else { - client = knex - } - } - - return client - .table(table) - .first(select ? _.pick(allSelects, select) : allSelects) - .where('id', id) - } - -export const findOne = ({ - knex, - table, - columns, - allSelects, -}: Pick & { allSelects: string[] }) => - function findOne(query, select, client: Knex) { - if (!client) { - // TODO figure out if better, eg instanceof, check is possible - if (select && select.andWhereNotBetween) { - client = select - select = null - } else { - client = knex - } - } - - let builder = client.table(table).first(select ? _.pick(allSelects, select) : allSelects) - - if (!_.isEmpty(query)) { - builder = where(builder, _.pick(query, columns)) - } - - if (query?.sort) { - builder = builder.orderBy(query.sort) - } - - return builder - } - -export const getAll = ({ knex, table, allSelects }: Pick & { allSelects: string[] }) => - function getAll(select, client: Knex) { - if (!client) { - // TODO figure out if better, eg instanceof, check is possible - if (select && select.andWhereNotBetween) { - client = select - select = null - } else { - client = knex - } - } - - return client.table(table).select(select ? _.pick(allSelects, select) : allSelects) - } - -export const remove = ({ knex, table, columns, emitter }: Pick) => - function remove(query, client = knex) { - let builder = client.table(table).delete() - - if (!_.isEmpty(query)) { - builder = where(builder, _.pick(query, columns)) - } - - return builder.then((result) => { - if (emitter) { - emitter.emit('db', { table, action: 'delete', result }) - } - - return result - }) - } - -export const removeById = ({ knex, table, emitter }: Pick) => - function removeById(id: number | string, client = knex) { - return client - .table(table) - .delete() - .where('id', id) - .then((result) => { - if (emitter) { - emitter.emit('db', { table, action: 'delete', result }) - } - - return result - }) - } - -// should be used with PUT -export const replace = ({ knex, table, columns, emitter }: Pick) => - function replace(id: number | string, object, client = knex) { - object = columns.reduce((result, key) => { - const value = object[key] - - result[columns[key]] = value === undefined ? null : value - - return result - }, {}) - - return client - .table(table) - .update(object) - .where('id', id) - .returning('*') - .then((result) => { - if (result.length === 0) return null - - if (emitter) { - emitter.emit('db', { table, action: 'update', result }) - } - - return result[0] - }) - } - -// should be used with PATCH -export const update = ({ knex, table, columns, emitter }: Pick) => - function update(id: number | string, object: Record, client = knex) { - object = _.pickBy(object, (value, key) => value !== undefined && columns.includes(key)) - - if (columns.includes('modifiedAt')) { - object.modifiedAt = knex.fn.now() - } - - return client - .table(table) - .update(object) - .where('id', id) - .returning('*') - .then((result) => { - if (result.length === 0) return null - - if (emitter) { - emitter.emit('db', { table, action: 'update', result }) - } - - return result[0] - }) - } - -export const updateMany = ({ knex, table, emitter }: Pick) => - function updateMany(query, object, client = knex) { - const builder = client.table(table).update(typeof object === 'function' ? object(knex) : object) - - return where(builder, query) - .returning('*') - .then((result) => { - if (result.length === 0) return null - - if (emitter) { - emitter.emit('db', { table, action: 'update', result }) - } - - return result[0] - }) - } - -const factories = { - count, - create, - createMany, - find, - findOne, - findById, - getAll, - remove, - removeById, - replace, - update, - updateMany, -} - -const queryNames = Object.keys(factories) - -export default function restQueriesFactory({ knex, emitter, table, columns, selects, omit, pick }: Options) { - const allSelects = selects ? [...columns, selects] : columns - - pick = pick || (_.difference(queryNames, omit || []) as QueryName[]) - - return pick.reduce((result, value) => { - if (factories[value]) { - result[value] = factories[value]({ columns, selects, allSelects, knex, emitter, table }) - } - - return result - }, {}) as Record Promise> -} diff --git a/server/lib/kysely_helpers.ts b/server/lib/kysely_helpers.ts index 98a9cd3..2923a96 100644 --- a/server/lib/kysely_helpers.ts +++ b/server/lib/kysely_helpers.ts @@ -23,10 +23,14 @@ type WhereInput = Partial<{ | null }> -export function applyWhere( - builder: SelectQueryBuilder, +type WhereCapable = { + where(lhs: ReferenceExpression, op: any, rhs: any): Self +} + +export function applyWhere>( + builder: QB, where: WhereInput, -): SelectQueryBuilder { +): QB { return Object.entries(where).reduce((builder, [key, value]) => { const column = key as ReferenceExpression @@ -48,8 +52,8 @@ export function applyWhere( interface PaginationInput { where: WhereInput - limit?: number - offset?: number + limit: number + offset: number sort?: UnambiguousColumn | `-${UnambiguousColumn}` } @@ -57,7 +61,7 @@ export function applyPagination( builder: SelectQueryBuilder, { limit, offset, sort }: PaginationInput, ): SelectQueryBuilder { - let qb = builder + let qb = builder.limit(limit).offset(offset) if (sort) { const columnName = (sort.startsWith('-') ? sort.slice(1) : sort) as UnambiguousColumn @@ -65,14 +69,6 @@ export function applyPagination( qb = qb.orderBy(columnName, columnName === sort ? 'asc' : 'desc') } - if (limit !== undefined) { - qb = qb.limit(limit) - } - - if (offset !== undefined) { - qb = qb.offset(offset) - } - return qb } diff --git a/server/lib/pino_transport_db.ts b/server/lib/pino_transport_db.ts new file mode 100644 index 0000000..68473cc --- /dev/null +++ b/server/lib/pino_transport_db.ts @@ -0,0 +1,27 @@ +import build from 'pino-abstract-transport' + +export default function dbTransport(create) { + return build(async (source) => { + for await (const obj of source) { + // TODO decide how to handle lower log levels + if (obj.level < 50) continue + + try { + create({ + statusCode: obj.err?.status || 500, + type: obj.err?.type, + message: obj.err?.message, + details: obj.err, + stack: obj.err?.stack, + method: obj.req?.method, + path: obj.req?.url, + headers: obj.req?.headers, + reqId: obj.reqId, + createdAt: new Date(obj.time), + }).catch(console.error) + } catch (e) { + console.error(e) + } + } + }) +} diff --git a/server/plugins/auth.ts b/server/plugins/auth.ts index 0eeb525..7a498ab 100644 --- a/server/plugins/auth.ts +++ b/server/plugins/auth.ts @@ -1,5 +1,6 @@ import type { FastifyPluginCallback, FastifyRequest } from 'fastify' import fp from 'fastify-plugin' +import type { Selectable } from 'kysely' import { jsonArrayFrom } from 'kysely/helpers/postgres' import changePassword from './auth/routes/change_password.ts' import resetPassword from './auth/routes/reset_password.ts' @@ -8,11 +9,11 @@ import logout from './auth/routes/logout.ts' import register from './auth/routes/register.ts' import verifyEmail from './auth/routes/verify_email.ts' -import type { User } from '../services/users/types.ts' +import type { User } from '../../shared/types.db.ts' const userPromiseSymbol = Symbol('user') -const serialize = (user: User) => Promise.resolve(user.id) +const serialize = (user: Selectable) => Promise.resolve(user.id) const auth: FastifyPluginCallback<{ prefix?: string }> = (fastify, options, done) => { const prefix = options.prefix || '' diff --git a/server/routes/api.ts b/server/routes/api.ts index 767e29b..1e48466 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -5,6 +5,7 @@ import accounts from './api/accounts.ts' import admissions from './api/admissions.ts' import balances from './api/balances.ts' import entries from './api/entries.ts' +import errors from './api/errors.ts' import financialYears from './api/financial_years.ts' import invites from './api/invites.ts' import invoices from './api/invoices.ts' @@ -22,6 +23,7 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.register(admissions, { prefix: '/admissions' }) fastify.register(balances, { prefix: '/balances' }) fastify.register(entries, { prefix: '/entries' }) + fastify.register(errors, { prefix: '/errors' }) fastify.register(financialYears, { prefix: '/financial-years' }) fastify.register(invites, { prefix: '/invites' }) fastify.register(invoices, { prefix: '/invoices' }) diff --git a/server/routes/api/errors.ts b/server/routes/api/errors.ts new file mode 100644 index 0000000..d8d11bc --- /dev/null +++ b/server/routes/api/errors.ts @@ -0,0 +1,102 @@ +import _ from 'lodash' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' +import { PaginationMetaSchema } from '../../schemas/misc.ts' +import { ErrorSchema } from '../../schemas/db.ts' +import { paginate, applyWhere } from '../../lib/kysely_helpers.ts' + +const errorRoutes: FastifyPluginCallbackZod = (fastify, _options, done) => { + const { db } = fastify + // addParentSchema({ + // $id: 'logged-error', + // type: 'object', + // properties: { + // id: { type: 'integer' }, + // statusCode: { type: 'integer' }, + // type: { type: 'string' }, + // message: { type: 'string' }, + // details: { type: ['object', 'null'] }, + // stack: { type: 'string' }, + // method: { type: 'string' }, + // path: { type: 'string' }, + // headers: { type: ['object', 'null'], additionalProperties: { type: 'string' } }, + // ip: { type: 'string' }, + // reqId: { type: 'string' }, + // createdAt: { type: 'string' }, + // }, + // }) + + // fastify.addHook('onRequest', fastify.auth) + + fastify.route({ + method: 'GET', + url: '/', + schema: { + querystring: z.object({ + statusCode: z.coerce.number(), + sort: ErrorSchema.keyof(), + limit: z.coerce.number().default(40), + offset: z.coerce.number().default(0), + }), + response: { + 200: z.object({ + meta: PaginationMetaSchema, + data: z.array(ErrorSchema), + }), + }, + }, + // @ts-ignore + handler(request) { + const { limit, offset, sort, ...where } = request.query + + const baseQuery = db.selectFrom('error').selectAll() + + return paginate(baseQuery, baseQuery, { where, limit, offset, sort }) + }, + }) + + fastify.route({ + method: 'DELETE', + url: '/', + schema: { + querystring: z.object({ + statusCode: z.coerce.number().optional(), + id: z.union([z.coerce.number(), z.array(z.coerce.number()).optional()]), + }), + }, + handler(request) { + return applyWhere(db.deleteFrom('error'), request.query) + }, + }) + + fastify.route({ + method: 'GET', + url: '/:id', + schema: { + params: z.object({ + id: z.coerce.number(), + }), + }, + handler(request) { + return db.selectFrom('error').selectAll().where('id', '=', request.params.id).executeTakeFirst() + }, + }) + + fastify.route({ + method: 'DELETE', + url: '/:id', + schema: { + params: z.object({ + id: z.coerce.number(), + }), + response: { 204: {} }, + }, + handler(request) { + return db.deleteFrom('error').where('id', '=', request.params.id).execute() + }, + }) + + done() +} + +export default errorRoutes diff --git a/server/routes/api/invoices.ts b/server/routes/api/invoices.ts index be5c353..e37a459 100644 --- a/server/routes/api/invoices.ts +++ b/server/routes/api/invoices.ts @@ -15,7 +15,7 @@ const invoiceRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { year: z.coerce.number().optional(), supplierId: z.coerce.number().optional(), limit: z.coerce.number().default(100), - offset: z.coerce.number().optional(), + offset: z.coerce.number().default(0), sort: z.literal(['supplierId', 'i.id', 'invoiceDate', 'dueDate']).default('i.id'), }), }, diff --git a/server/routes/api/transactions.ts b/server/routes/api/transactions.ts index e21315c..fa0e7a4 100644 --- a/server/routes/api/transactions.ts +++ b/server/routes/api/transactions.ts @@ -14,7 +14,7 @@ const transactionRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { year: z.optional(z.coerce.number()), accountNumber: z.optional(z.coerce.number()), limit: z.coerce.number().default(100), - offset: z.coerce.number().optional(), + offset: z.coerce.number().default(0), sort: z.literal(['accountNumber', 'e.transactionDate', 't.id']).default('t.id'), }), }, diff --git a/server/schemas/db.ts b/server/schemas/db.ts index baf6075..8344cc7 100644 --- a/server/schemas/db.ts +++ b/server/schemas/db.ts @@ -26,6 +26,21 @@ export const AccountBalanceSchema = z.object({ outQuantity: z.number().int().nullable().optional(), }) +export const ErrorSchema = z.object({ + id: z.number().int().optional(), + statusCode: z.number().int().nullable().optional(), + type: z.string().nullable().optional(), + message: z.string().nullable().optional(), + details: z.json().nullable().optional(), + stack: z.string().nullable().optional(), + method: z.string().nullable().optional(), + path: z.string().nullable().optional(), + headers: z.json().nullable().optional(), + ip: z.string().nullable().optional(), + reqId: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), +}) + export const InviteSchema = z.object({ id: z.number().int().optional(), email: z.string(), diff --git a/server/schemas/misc.ts b/server/schemas/misc.ts new file mode 100644 index 0000000..2d72548 --- /dev/null +++ b/server/schemas/misc.ts @@ -0,0 +1,8 @@ +import * as z from 'zod' + +export const PaginationMetaSchema = z.object({ + totalCount: z.number(), + limit: z.number(), + offset: z.number(), + count: z.number(), +}) diff --git a/server/services/errors/queries.ts b/server/services/errors/queries.ts deleted file mode 100644 index ce61555..0000000 --- a/server/services/errors/queries.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type EventEmitter from 'node:events' -import type { Knex } from 'knex' -import RestQueriesFactory from '../../lib/knex_rest_queries.ts' - -const columnNames = [ - 'id', - 'statusCode', - 'type', - 'message', - 'details', - 'stack', - 'method', - 'path', - 'headers', - 'ip', - 'reqId', - 'createdAt', -] - -export default ({ emitter, knex }: { emitter: EventEmitter; knex: Knex }) => { - return RestQueriesFactory({ - knex: knex, - emitter: emitter, - table: 'error', - columns: columnNames, - }) -} diff --git a/server/templates/admin.ts b/server/templates/admin.ts index f175f21..f40e4f8 100644 --- a/server/templates/admin.ts +++ b/server/templates/admin.ts @@ -10,7 +10,7 @@ interface Options { export default ({ css, preload, script, state }: Options) => html` - Carson Admin + BRF Admin