From 04e50a3021405bffe016e6ead8acf65db68ae969 Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Tue, 16 Dec 2025 07:26:15 +0100 Subject: [PATCH] WIP more auth work and convert to kysely --- .bruno/BRF/Admissions.bru | 15 ++ .bruno/BRF/Invites.bru | 15 ++ .bruno/BRF/Roles.bru | 15 ++ .bruno/BRF/Users.bru | 15 ++ .bruno/BRF/api-balances.bru | 3 +- .bruno/BRF/api-entries--id.bru | 2 +- .bruno/BRF/api-entries.bru | 2 +- .bruno/BRF/api-financial-years.bru | 2 +- .bruno/BRF/api-invoices--id.bru | 2 +- .bruno/BRF/api-invoices-total-amount.bru | 8 +- .bruno/BRF/api-invoices.bru | 2 +- .bruno/BRF/api-objects--id.bru | 2 +- .bruno/BRF/api-objects.bru | 2 +- .bruno/BRF/api-results--year.bru | 2 +- .bruno/BRF/api-results.bru | 2 +- .bruno/BRF/api-suppliers-merge.bru | 13 +- .bruno/BRF/api-suppliers.bru | 4 +- .bruno/BRF/api-transactions.bru | 10 +- package.json | 8 +- pnpm-lock.yaml | 66 ++++++ server/lib/kysely.ts | 17 ++ server/lib/kysely_helpers.ts | 47 ++++ server/lib/kysely_queries.ts | 78 +++++++ server/plugins/db.ts | 17 ++ server/routes/api.ts | 2 + server/routes/api/roles.ts | 205 +++++++++++------- server/routes/api/suppliers.ts | 50 +++-- server/routes/api/transactions.ts | 43 ++-- server/schemas/db.ts | 22 ++ server/server.ts | 7 + server/tests/suppliers.test.ts | 21 ++ shared/global.d.ts | 3 + shared/types.db.ts | 261 +++++++++++++++++++++++ shared/types.ts | 144 ++++++------- 34 files changed, 903 insertions(+), 204 deletions(-) create mode 100644 .bruno/BRF/Admissions.bru create mode 100644 .bruno/BRF/Invites.bru create mode 100644 .bruno/BRF/Roles.bru create mode 100644 .bruno/BRF/Users.bru create mode 100644 server/lib/kysely.ts create mode 100644 server/lib/kysely_helpers.ts create mode 100644 server/lib/kysely_queries.ts create mode 100644 server/plugins/db.ts create mode 100644 server/schemas/db.ts create mode 100644 server/tests/suppliers.test.ts create mode 100644 shared/types.db.ts diff --git a/.bruno/BRF/Admissions.bru b/.bruno/BRF/Admissions.bru new file mode 100644 index 0000000..d7d6b5a --- /dev/null +++ b/.bruno/BRF/Admissions.bru @@ -0,0 +1,15 @@ +meta { + name: Admissions + type: http + seq: 1 +} + +get { + url: {{base_url}}/api/admissions + body: none + auth: none +} + +headers { + Accept: application/json +} diff --git a/.bruno/BRF/Invites.bru b/.bruno/BRF/Invites.bru new file mode 100644 index 0000000..8ae58be --- /dev/null +++ b/.bruno/BRF/Invites.bru @@ -0,0 +1,15 @@ +meta { + name: Invites + type: http + seq: 8 +} + +get { + url: http://localhost:4040/api/invites + body: none + auth: none +} + +headers { + Accept: application/json +} diff --git a/.bruno/BRF/Roles.bru b/.bruno/BRF/Roles.bru new file mode 100644 index 0000000..af8e2f3 --- /dev/null +++ b/.bruno/BRF/Roles.bru @@ -0,0 +1,15 @@ +meta { + name: Roles + type: http + seq: 17 +} + +get { + url: {{base_url}}/api/roles + body: none + auth: none +} + +headers { + Accept: application/json +} diff --git a/.bruno/BRF/Users.bru b/.bruno/BRF/Users.bru new file mode 100644 index 0000000..0b51731 --- /dev/null +++ b/.bruno/BRF/Users.bru @@ -0,0 +1,15 @@ +meta { + name: Users + type: http + seq: 9 +} + +get { + url: http://localhost:4040/api/users + body: none + auth: none +} + +headers { + Accept: application/json +} diff --git a/.bruno/BRF/api-balances.bru b/.bruno/BRF/api-balances.bru index 66d373f..b5db9cd 100644 --- a/.bruno/BRF/api-balances.bru +++ b/.bruno/BRF/api-balances.bru @@ -1,7 +1,7 @@ meta { name: /api/balances type: http - seq: 15 + seq: 3 } get { @@ -12,4 +12,5 @@ get { settings { encodeUrl: true + timeout: 0 } diff --git a/.bruno/BRF/api-entries--id.bru b/.bruno/BRF/api-entries--id.bru index 15c4da3..7edd593 100644 --- a/.bruno/BRF/api-entries--id.bru +++ b/.bruno/BRF/api-entries--id.bru @@ -1,7 +1,7 @@ meta { name: /api/entries/:id type: http - seq: 4 + seq: 5 } get { diff --git a/.bruno/BRF/api-entries.bru b/.bruno/BRF/api-entries.bru index fd198f2..2991da7 100644 --- a/.bruno/BRF/api-entries.bru +++ b/.bruno/BRF/api-entries.bru @@ -1,7 +1,7 @@ meta { name: /api/entries type: http - seq: 3 + seq: 4 } get { diff --git a/.bruno/BRF/api-financial-years.bru b/.bruno/BRF/api-financial-years.bru index 116a858..9bc7bcb 100644 --- a/.bruno/BRF/api-financial-years.bru +++ b/.bruno/BRF/api-financial-years.bru @@ -1,7 +1,7 @@ meta { name: /api/financial-years type: http - seq: 5 + seq: 6 } get { diff --git a/.bruno/BRF/api-invoices--id.bru b/.bruno/BRF/api-invoices--id.bru index e17cad0..ce3e34a 100644 --- a/.bruno/BRF/api-invoices--id.bru +++ b/.bruno/BRF/api-invoices--id.bru @@ -1,7 +1,7 @@ meta { name: /api/invoices/:id type: http - seq: 8 + seq: 10 } get { diff --git a/.bruno/BRF/api-invoices-total-amount.bru b/.bruno/BRF/api-invoices-total-amount.bru index c6dd80b..beb7a96 100644 --- a/.bruno/BRF/api-invoices-total-amount.bru +++ b/.bruno/BRF/api-invoices-total-amount.bru @@ -5,11 +5,17 @@ meta { } get { - url: {{base_url}}/api/invoices/total-amount + url: {{base_url}}/api/invoices/total-amount?supplier=150 body: none auth: inherit } +params:query { + supplier: 150 + : +} + settings { encodeUrl: true + timeout: 0 } diff --git a/.bruno/BRF/api-invoices.bru b/.bruno/BRF/api-invoices.bru index 6e8aa2b..4969994 100644 --- a/.bruno/BRF/api-invoices.bru +++ b/.bruno/BRF/api-invoices.bru @@ -1,7 +1,7 @@ meta { name: /api/invoices type: http - seq: 8 + seq: 9 } get { diff --git a/.bruno/BRF/api-objects--id.bru b/.bruno/BRF/api-objects--id.bru index d37c795..29af21d 100644 --- a/.bruno/BRF/api-objects--id.bru +++ b/.bruno/BRF/api-objects--id.bru @@ -1,7 +1,7 @@ meta { name: /api/objects/:id type: http - seq: 6 + seq: 7 } get { diff --git a/.bruno/BRF/api-objects.bru b/.bruno/BRF/api-objects.bru index 0d055d2..d202547 100644 --- a/.bruno/BRF/api-objects.bru +++ b/.bruno/BRF/api-objects.bru @@ -1,7 +1,7 @@ meta { name: /api/objects type: http - seq: 7 + seq: 8 } get { diff --git a/.bruno/BRF/api-results--year.bru b/.bruno/BRF/api-results--year.bru index 3d6d0fa..9606dda 100644 --- a/.bruno/BRF/api-results--year.bru +++ b/.bruno/BRF/api-results--year.bru @@ -1,7 +1,7 @@ meta { name: /api/results/:year type: http - seq: 12 + seq: 14 } get { diff --git a/.bruno/BRF/api-results.bru b/.bruno/BRF/api-results.bru index 8aeb3f9..832bc3e 100644 --- a/.bruno/BRF/api-results.bru +++ b/.bruno/BRF/api-results.bru @@ -1,7 +1,7 @@ meta { name: /api/results type: http - seq: 11 + seq: 12 } get { diff --git a/.bruno/BRF/api-suppliers-merge.bru b/.bruno/BRF/api-suppliers-merge.bru index d27c960..1f35d13 100644 --- a/.bruno/BRF/api-suppliers-merge.bru +++ b/.bruno/BRF/api-suppliers-merge.bru @@ -6,10 +6,21 @@ meta { post { url: {{base_url}}/api/suppliers/merge - body: none + body: json auth: inherit } +body:json { + { + "ids": [ 105, 203 ] + } +} + +body:multipart-form { + ids: [ +} + settings { encodeUrl: true + timeout: 0 } diff --git a/.bruno/BRF/api-suppliers.bru b/.bruno/BRF/api-suppliers.bru index b637fb1..48ed2ee 100644 --- a/.bruno/BRF/api-suppliers.bru +++ b/.bruno/BRF/api-suppliers.bru @@ -1,11 +1,11 @@ meta { name: /api/suppliers type: http - seq: 10 + seq: 11 } get { - url: {{base_url}}/ + url: {{base_url}}/api/suppliers body: none auth: inherit } diff --git a/.bruno/BRF/api-transactions.bru b/.bruno/BRF/api-transactions.bru index fccb7c1..3172821 100644 --- a/.bruno/BRF/api-transactions.bru +++ b/.bruno/BRF/api-transactions.bru @@ -1,15 +1,21 @@ meta { name: /api/transactions type: http - seq: 14 + seq: 16 } get { - url: {{base_url}}/api/transactions + url: {{base_url}}/api/transactions?year=2020&accountNumber=4800 body: none auth: inherit } +params:query { + year: 2020 + accountNumber: 4800 +} + settings { encodeUrl: true + timeout: 0 } diff --git a/package.json b/package.json index 0198d98..fe48b1b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test": "pnpm run test:client && pnpm run test:server", "test:client": "node --no-warnings --import=./client/test/jsdom_polyfills.ts --import=./client/test/register_tsx_hook.ts --test ./client/**/*.test.ts{,x}", "test:server": "node --env-file .env.testing --no-warnings --test ./server/**/*.test.ts", - "types": "tsgo --skipLibCheck" + "types": "tsgo --skipLibCheck", + "types:tsc": "tsc --skipLibCheck" }, "dependencies": { "@bmp/console": "^0.1.0", @@ -37,8 +38,10 @@ "fastify": "^5.6.2", "fastify-plugin": "^5.1.0", "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", "mini-qs": "^0.2.0", @@ -48,7 +51,8 @@ "preact": "^10.28.0", "preact-iso": "^2.11.0", "preact-router": "^4.1.2", - "rek": "^0.8.1" + "rek": "^0.8.1", + "zod": "^4.2.1" }, "devDependencies": { "@babel/core": "^7.26.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b36337c..585cdcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,18 @@ importers: fastify-session-redis-store: specifier: ^7.1.2 version: 7.1.2(@fastify/session@11.1.1) + fastify-type-provider-zod: + specifier: ^6.1.0 + version: 6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.2.1) 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 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -86,6 +92,9 @@ importers: rek: specifier: ^0.8.1 version: 0.8.1 + zod: + specifier: ^4.2.1 + version: 4.2.1 devDependencies: '@babel/core': specifier: ^7.26.10 @@ -642,6 +651,9 @@ packages: '@fastify/static@8.3.0': resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + '@fastify/swagger@9.6.1': + resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==} + '@fastify/type-provider-typebox@6.1.0': resolution: {integrity: sha512-k29cOitDRcZhMXVjtRq0+caKxdWoArz7su+dQWGzGWnFG+fSKhevgiZ7nexHWuXOEEQzgJlh6cptIMu69beaTA==} peerDependencies: @@ -1382,6 +1394,14 @@ packages: peerDependencies: '@fastify/session': '>=10' + fastify-type-provider-zod@6.1.0: + resolution: {integrity: sha512-Sl19VZFSX4W/+AFl3hkL5YgWk3eDXZ4XYOdrq94HunK+o7GQBCAqgk7+3gPXoWkF0bNxOiIgfnFGJJ3i9a2BtQ==} + peerDependencies: + '@fastify/swagger': '>=9.5.1' + fastify: ^5.5.0 + openapi-types: ^12.1.3 + zod: '>=4.1.5' + fastify@5.6.2: resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} @@ -1655,6 +1675,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1694,6 +1718,10 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + kysely@0.28.9: + resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} + engines: {node: '>=20.0.0'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -1818,6 +1846,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + oxlint@1.29.0: resolution: {integrity: sha512-YqUVUhTYDqazV2qu3QSQn/H4Z1OP+fTnedgZWDk1/lDZxGfR0b1MqRVaEm3rRjBMLHP0zXlriIWUx+DD6UMaPA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2384,6 +2415,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + snapshots: '@acemir/cssom@0.9.23': {} @@ -2785,6 +2819,16 @@ snapshots: fastq: 1.19.1 glob: 11.1.0 + '@fastify/swagger@9.6.1': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.1 + transitivePeerDependencies: + - supports-color + '@fastify/type-provider-typebox@6.1.0(typebox@1.0.55)': dependencies: typebox: 1.0.55 @@ -3487,6 +3531,14 @@ snapshots: dependencies: '@fastify/session': 11.1.1 + fastify-type-provider-zod@6.1.0(@fastify/swagger@9.6.1)(fastify@5.6.2)(openapi-types@12.1.3)(zod@4.2.1): + dependencies: + '@fastify/error': 4.2.0 + '@fastify/swagger': 9.6.1 + fastify: 5.6.2 + openapi-types: 12.1.3 + zod: 4.2.1 + fastify@5.6.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -3790,6 +3842,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@1.0.0: {} json5@2.2.3: {} @@ -3817,6 +3877,8 @@ snapshots: kolorist@1.8.0: {} + kysely@0.28.9: {} + light-my-request@6.6.0: dependencies: cookie: 1.0.2 @@ -3937,6 +3999,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi-types@12.1.3: {} + oxlint@1.29.0: optionalDependencies: '@oxlint/darwin-arm64': 1.29.0 @@ -4470,3 +4534,5 @@ snapshots: yallist@3.1.1: {} yaml@2.8.1: {} + + zod@4.2.1: {} diff --git a/server/lib/kysely.ts b/server/lib/kysely.ts new file mode 100644 index 0000000..ec61cc9 --- /dev/null +++ b/server/lib/kysely.ts @@ -0,0 +1,17 @@ +// @ts-ignore +import { Pool } from 'pg' +import { Kysely, PostgresDialect } from 'kysely' +import env from '../env.ts' +import type { DB } from '../../shared/types.db.ts' + +const dialect = new PostgresDialect({ + pool: new Pool({ + database: env.PGDATABASE, + host: env.PGHOST, + password: env.PGPASSWORD, + user: env.PGUSER, + port: env.PGPORT, + }), +}) + +export default new Kysely({ dialect }) diff --git a/server/lib/kysely_helpers.ts b/server/lib/kysely_helpers.ts new file mode 100644 index 0000000..f8d7e7c --- /dev/null +++ b/server/lib/kysely_helpers.ts @@ -0,0 +1,47 @@ +// import type { QueryBuilder } from 'knex' +import type { SelectQueryBuilder } from 'kysely' +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: SelectQueryBuilder, json: Record) { + return _.reduce( + json, + (builder, value, key) => { + if (value === null) { + return builder.where(key, 'is', null) + } else if (Array.isArray(value)) { + return builder.where(key, 'in', value) + } + + return builder.where(key, '=', value) + }, + builder, + ) +} diff --git a/server/lib/kysely_queries.ts b/server/lib/kysely_queries.ts new file mode 100644 index 0000000..2d0543b --- /dev/null +++ b/server/lib/kysely_queries.ts @@ -0,0 +1,78 @@ +// @ts-nocheck +// import { type Knex } from 'kysely' +// import _ from 'lodash' + +// import { where } from './kysely_helpers.ts' + +// type QueryName = +// | 'create' +// | 'createMany' +// | 'find' +// | 'findOne' +// | 'findById' +// | 'getAll' +// | 'paginate' +// | 'remove' +// | 'removeById' +// | 'replace' +// | 'update' + +// interface Options { +// kysely: Knex +// emitter?: ANY +// pick?: QueryName[] +// omit?: QueryName[] +// columns: string[] +// table: string +// selects?: Record +// } + +// export const count = ({ kysely, table, columns }: Pick) => +// function count(query: Record, client = kysely) { +// 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 find = ({ + kysely, + 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 = kysely + } + } + + 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 + } diff --git a/server/plugins/db.ts b/server/plugins/db.ts new file mode 100644 index 0000000..f7fea86 --- /dev/null +++ b/server/plugins/db.ts @@ -0,0 +1,17 @@ +import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import type { Kysely } from 'kysely' +import type { DB } from '../../shared/types.db.ts' +import fp from 'fastify-plugin' + +const dbPlugin: FastifyPluginCallbackTypebox<{ kysely: Kysely }> = (fastify, { kysely }, done) => { + fastify.decorate('db', kysely) + + fastify.addHook('onClose', () => kysely.destroy()) + + done() +} + +export default fp(dbPlugin, { + fastify: '5.x', + name: 'dbPlugin', +}) diff --git a/server/routes/api.ts b/server/routes/api.ts index 0da626c..ce31e3c 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -10,6 +10,7 @@ import journals from './api/journals.ts' import objects from './api/objects.ts' import process from './api/process.ts' import results from './api/results.ts' +import roles from './api/roles.ts' import suppliers from './api/suppliers.ts' import transactions from './api/transactions.ts' @@ -23,6 +24,7 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.register(objects, { prefix: '/objects' }) fastify.register(process, { prefix: '/process' }) fastify.register(results, { prefix: '/results' }) + fastify.register(roles, { prefix: '/roles' }) fastify.register(suppliers, { prefix: '/suppliers' }) fastify.register(transactions, { prefix: '/transactions' }) diff --git a/server/routes/api/roles.ts b/server/routes/api/roles.ts index bb3b7b7..0fa45b6 100644 --- a/server/routes/api/roles.ts +++ b/server/routes/api/roles.ts @@ -1,102 +1,151 @@ -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' -import knex from '../../lib/knex.ts' -import Queries from '../../services/roles/queries.ts' +import _ from 'lodash' +import * as z from 'zod' +import { type FastifyPluginCallbackZod } from 'fastify-type-provider-zod' +// import knex from '../../lib/knex.ts' +// import Queries from '../../services/roles/queries.ts' -export const RoleFullSchema = Type.Object({ - id: Type.Number(), - name: Type.String(), - createdAt: Type.String(), - createdById: Type.Number(), - modifiedAt: Type.String(), - modifiedById: Type.Number(), -}) +import { RoleSchema } from '../../schemas/db.ts' -export const RoleSchema = Type.Pick(RoleFullSchema, ['id', 'name']) +// export const RoleFullSchema = Type.Object({ +// id: Type.Number(), +// name: Type.String(), +// createdAt: Type.String(), +// createdById: Type.Number(), +// modifiedAt: Type.String(), +// modifiedById: Type.Number(), +// }) -export const RoleVariableSchema = Type.Pick(RoleFullSchema, ['name', 'createdById']) +// console.log(RoleFullSchema) -const rolesPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) => { - const queries = Queries({ knex }) +// export const RoleSchema = Type.Pick(RoleFullSchema, ['id', 'name']) - fastify.addHook('onRequest', fastify.auth) +// export const RoleVariableSchema = Type.Pick(RoleFullSchema, ['name', 'createdById']) + +const rolesPlugin: FastifyPluginCallbackZod = (fastify, _options, done) => { + // const queries = Queries({ knex }) + const { db } = fastify + + // fastify.addHook('onRequest', fastify.auth) fastify.route({ url: '/', method: 'GET', schema: { - querystring: RoleFullSchema, + querystring: RoleSchema.partial().extend({ + limit: z.number().optional(), + sort: z.keyof(RoleSchema).optional(), + offset: z.number().optional(), + }), response: { - 200: Type.Array(RoleFullSchema), + 200: z.array(RoleSchema), }, }, handler(request) { - return queries.find(request.query) + // if (!client) { + // // TODO figure out if better, eg instanceof, check is possible + // if (select && select.andWhereNotBetween) { + // client = select + // select = null + // } else { + // client = kysely + // } + // } + + const { offset, limit, sort, ...query } = request.query + + let builder = db.selectFrom('role').selectAll() + + // if (!_.isEmpty(query)) { + // builder = where(builder, _.pick(query, columns)) + // } + + for (const [key, value] of Object.entries(query)) { + if (value === null) { + builder = builder.where(key, 'is', null) + } else if (Array.isArray(value)) { + builder = builder.where(key, 'in', value) + } else { + builder = builder.where(key, '=', value) + } + } + + builder = builder.orderBy(sort || 'id', sort?.startsWith('-') ? 'desc' : 'asc') + + if (limit) { + builder = builder.limit(limit) + } + + if (offset) { + builder = builder.offset(offset) + } + + return builder.execute() }, }) - fastify.route({ - url: '/', - method: 'POST', - schema: { - body: RoleVariableSchema, - response: { - 201: RoleFullSchema, - }, - }, - async handler(request, reply) { - const newRole = request.session.userId - ? { - ...request.body, - createdById: request.session.userId, - } - : request.body + // fastify.route({ + // url: '/', + // method: 'POST', + // schema: { + // body: RoleVariableSchema, + // response: { + // 201: RoleFullSchema, + // }, + // }, + // async handler(request, reply) { + // const newRole = request.session.userId + // ? { + // ...request.body, + // createdById: request.session.userId, + // } + // : request.body - return queries.create(newRole).then((row) => { - return reply.header('Location', `${request.url}/${row.id}`).status(201).send(row) - }) - }, - }) + // return queries.create(newRole).then((row) => { + // return reply.header('Location', `${request.url}/${row.id}`).status(201).send(row) + // }) + // }, + // }) - fastify.route({ - url: '/:id', - method: 'DELETE', - schema: { - params: Type.Object({ - id: Type.Number(), - }), - response: { - 204: {}, - 404: {}, - }, - }, - handler(request, reply) { - return queries.removeById(request.params.id).then((count) => reply.status(count > 0 ? 204 : 404).send()) - }, - }) + // fastify.route({ + // url: '/:id', + // method: 'DELETE', + // schema: { + // params: Type.Object({ + // id: Type.Number(), + // }), + // response: { + // 204: {}, + // 404: {}, + // }, + // }, + // handler(request, reply) { + // return queries.removeById(request.params.id).then((count) => reply.status(count > 0 ? 204 : 404).send()) + // }, + // }) - fastify.route({ - url: '/:id', - method: 'PATCH', - schema: { - params: Type.Object({ - id: Type.Number(), - }), - body: RoleVariableSchema, - response: { - 204: {}, - }, - }, - async handler(request) { - const patch = request.session.userId - ? { - ...request.body, - modifiedById: request.session.userId, - } - : request.body + // fastify.route({ + // url: '/:id', + // method: 'PATCH', + // schema: { + // params: Type.Object({ + // id: Type.Number(), + // }), + // body: RoleVariableSchema, + // response: { + // 204: {}, + // }, + // }, + // async handler(request) { + // const patch = request.session.userId + // ? { + // ...request.body, + // modifiedById: request.session.userId, + // } + // : request.body - return queries.update(request.params.id, patch) - }, - }) + // return queries.update(request.params.id, patch) + // }, + // }) done() } diff --git a/server/routes/api/suppliers.ts b/server/routes/api/suppliers.ts index 28ff853..5d2a57b 100644 --- a/server/routes/api/suppliers.ts +++ b/server/routes/api/suppliers.ts @@ -1,13 +1,21 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' -import knex from '../../lib/knex.ts' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' +import z from 'zod' +import { SupplierSchema } from '../../schemas/db.ts' + +const supplierRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { + const { db } = fastify -const journalRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', - async handler() { - return knex('supplier').select('*').orderBy('name') + schema: { + response: { + 200: z.array(SupplierSchema), + }, + }, + handler() { + return db.selectFrom('supplier').selectAll().orderBy('name').execute() }, }) @@ -15,12 +23,13 @@ const journalRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:id', method: 'GET', schema: { - params: Type.Object({ - id: Type.Number(), - }), + params: z.object({ id: z.number() }), + response: { + 200: SupplierSchema, + }, }, - async handler(req) { - return knex('supplier').first('*').where('id', req.params.id) + handler(req) { + return db.selectFrom('supplier').selectAll().where('id', '=', req.params.id).executeTakeFirst() }, }) @@ -28,20 +37,23 @@ const journalRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/merge', method: 'POST', schema: { - body: Type.Object({ - ids: Type.Array(Type.Number()), + body: z.object({ + ids: z.array(z.number()), }), }, async handler(req) { - const suppliers = await knex('supplier').select('*').whereIn('id', req.body.ids) + const suppliers = await db.selectFrom('supplier').selectAll().where('id', 'in', req.body.ids).execute() - const trx = await knex.transaction() + const trx = await db.startTransaction().execute() - await trx('invoice').update('supplierId', req.body.ids[0]).whereIn('supplierId', req.body.ids.slice(1)) - await trx('supplier').delete().whereIn('id', req.body.ids.slice(1)) - // 556744-4301 + await trx + .updateTable('invoice') + .set('supplierId', req.body.ids[0]) + .where('supplierId', 'in', req.body.ids.slice(1)) + .execute() + await trx.deleteFrom('supplier').where('id', 'in', req.body.ids.slice(1)).execute() - trx.commit() + await trx.commit().execute() return suppliers }, @@ -50,4 +62,4 @@ const journalRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { done() } -export default journalRoutes +export default supplierRoutes diff --git a/server/routes/api/transactions.ts b/server/routes/api/transactions.ts index 05b81b3..4cee604 100644 --- a/server/routes/api/transactions.ts +++ b/server/routes/api/transactions.ts @@ -1,25 +1,32 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' -import knex from '../../lib/knex.ts' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import StatusError from '../../lib/status_error.ts' -const transactionRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const transactionRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { + const { db } = fastify + fastify.route({ url: '/', method: 'GET', schema: { - querystring: Type.Object({ - year: Type.Optional(Type.Number()), - accountNumber: Type.Optional(Type.Number()), + querystring: z.object({ + year: z.optional(z.coerce.number()), + accountNumber: z.optional(z.coerce.number()), }), }, async handler(req) { const query: { financialYearId?: number; accountNumber?: number } = {} if (req.query.year) { - const year = await knex('financialYear').first('*').where('year', req.query.year) + const year = await db + .selectFrom('financialYear') + .selectAll() + .where('year', '=', req.query.year) + .executeTakeFirst() if (!year) throw new StatusError(404, `Year ${req.query.year} not found.`) + query.financialYearId = year.id } @@ -27,18 +34,20 @@ const transactionRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { query.accountNumber = req.query.accountNumber } - return knex('transaction AS t') - .select( + return db + .selectFrom('transaction as t') + .innerJoin('entry as e', 't.entryId', 'e.id') + .select([ 't.accountNumber', 'e.transactionDate', 't.entryId', 't.amount', 't.description', 't.invoiceId', - 'e.description AS entryDescription', - ) - .innerJoin('entry AS e', 't.entryId', 'e.id') - .where(query) + 'e.description as entryDescription', + ]) + .where((eb) => eb.and(query)) + .execute() }, }) @@ -46,12 +55,12 @@ const transactionRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:id', method: 'GET', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), }, - async handler(req) { - return knex('transaction').first('*').where('id', req.params.id) + handler(req) { + return db.selectFrom('transaction').selectAll().where('id', '=', req.params.id).execute() }, }) diff --git a/server/schemas/db.ts b/server/schemas/db.ts new file mode 100644 index 0000000..69cc675 --- /dev/null +++ b/server/schemas/db.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +export const RoleSchema = z.object({ + id: z.number().int().optional(), + name: z.string(), + createdAt: z.string().optional(), + createdById: z.number().int().nullable().optional(), + modifiedAt: z.string().nullable().optional(), + modifiedById: z.number().int().nullable().optional(), +}) + +export const SupplierSchema = z.object({ + id: z.number().int().optional(), + name: z.string().nullable().optional(), + supplierTypeId: z.number().int(), + taxId: z.string().nullable().optional(), +}) + +export const SupplierTypeSchema = z.object({ + id: z.number().int().optional(), + name: z.string(), +}) diff --git a/server/server.ts b/server/server.ts index b9b27db..c2334b9 100644 --- a/server/server.ts +++ b/server/server.ts @@ -3,12 +3,15 @@ import fcookie from '@fastify/cookie' import fsession from '@fastify/session' import fstatic from '@fastify/static' import RedisStore from 'fastify-session-redis-store' +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod' import { Redis } from 'ioredis' +import kysely from './lib/kysely.ts' import StatusError from './lib/status_error.ts' import env from './env.ts' import ErrorHandler from './handlers/error.ts' import authPlugin from './plugins/auth.ts' +import dbPlugin from './plugins/db.ts' import vitePlugin from './plugins/vite.ts' import apiRoutes from './routes/api.ts' import templateAdmin from './templates/admin.ts' @@ -17,6 +20,9 @@ import templatePublic from './templates/public.ts' export default async (options?: FastifyServerOptions) => { const server = fastify(options) + server.setValidatorCompiler(validatorCompiler) + server.setSerializerCompiler(serializerCompiler) + server.register(fcookie) server.register(fsession, { cookie: { @@ -27,6 +33,7 @@ export default async (options?: FastifyServerOptions) => { secret: env.SESSION_SECRET, store: env.NODE_ENV !== 'testing' ? new RedisStore({ client: new Redis(env.REDIS_HOST) }) : undefined, }) + server.register(dbPlugin, { kysely }) server.setNotFoundHandler(() => { throw new StatusError(404) diff --git a/server/tests/suppliers.test.ts b/server/tests/suppliers.test.ts new file mode 100644 index 0000000..be213db --- /dev/null +++ b/server/tests/suppliers.test.ts @@ -0,0 +1,21 @@ +import { test, type TestContext } from 'node:test' + +import supplierPlugin from '../routes/api/suppliers.ts' +import fastify from 'fastify' + +test('/api/suppliers', async (t: TestContext) => { + const server = fastify() + + // server.decorate('auth', (_request, _reply, done) => done()) + + server.register(supplierPlugin, { prefix: '/api/suppliers' }) + + const res = await server.inject({ + method: 'GET', + url: '/api/suppliers', + }) + + t.assert.equal(res.statusCode, 200) + + await server.close() +}) diff --git a/shared/global.d.ts b/shared/global.d.ts index fbbf231..a325a95 100644 --- a/shared/global.d.ts +++ b/shared/global.d.ts @@ -1,5 +1,7 @@ import 'fastify' import type { onRequestHookHandler } from 'fastify' +import { DB } from './types.db.ts' +import type { Kysely } from 'kysely' import { ViteDevServer } from 'vite' @@ -16,6 +18,7 @@ declare module 'fastify' { interface FastifyInstance { auth: onRequestHookHandler + db: Kysely devServer: ViteDevServer } diff --git a/shared/types.db.ts b/shared/types.db.ts new file mode 100644 index 0000000..c799be3 --- /dev/null +++ b/shared/types.db.ts @@ -0,0 +1,261 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from 'kysely' + +export type Generated = + T extends ColumnType ? ColumnType : ColumnType + +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 Numeric = ColumnType + +export type Timestamp = ColumnType + +export interface Account { + description: string + financialYearId: number + id: Generated + number: number + sru: number | null +} + +export interface AccountBalance { + accountNumber: number + financialYearId: number + in: Generated + inQuantity: number | null + out: Generated + outQuantity: number | null +} + +export interface Admission { + createdAt: Generated + createdById: number + id: Generated + modifiedAt: Timestamp | null + modifiedById: number | null + regex: string +} + +export interface AdmissionsRoles { + admissionId: number + roleId: number +} + +export interface AliasesToSupplier { + alias: string + id: Generated + supplierId: number +} + +export interface Dimension { + id: Generated + name: string | null + number: number +} + +export interface EmailToken { + cancelledAt: Timestamp | null + consumedAt: Timestamp | null + createdAt: Generated + email: string + id: Generated + token: string + userId: number +} + +export interface Entry { + description: string | null + entryDate: Timestamp + financialYearId: number + id: Generated + journalId: number + number: number + signature: string | null + transactionDate: Timestamp +} + +export interface Error { + createdAt: Timestamp | null + details: Json | null + headers: Json | null + id: Generated + ip: string | null + message: string | null + method: string | null + path: string | null + reqId: string | null + stack: string | null + statusCode: number | null + type: string | null +} + +export interface File { + filename: string + id: Generated +} + +export interface FilesToInvoice { + fileId: number + invoiceId: number +} + +export interface FinancialYear { + endDate: Timestamp + id: Generated + startDate: Timestamp + year: number +} + +export interface Invite { + consumedAt: Timestamp | null + consumedById: number | null + createdAt: Generated + createdById: number | null + email: string + id: Generated + modifiedAt: Timestamp | null + modifiedById: number | null + token: string +} + +export interface InvitesRoles { + invitedId: number + roleId: number +} + +export interface Invoice { + amount: Numeric | null + dueDate: Timestamp | null + financialYearId: number | null + fiskenNumber: number | null + id: Generated + invoiceDate: Timestamp | null + invoiceNumber: string | null + ocr: string | null + phmNumber: number | null + supplierId: number +} + +export interface Journal { + description: string | null + id: Generated + identifier: string +} + +export interface Object { + dimensionId: number + id: Generated + name: string | null + number: number +} + +export interface PasswordToken { + cancelledAt: Timestamp | null + consumedAt: Timestamp | null + createdAt: Generated + id: Generated + token: string + userId: number +} + +export interface Role { + createdAt: Generated + createdById: number | null + id: Generated + modifiedAt: Timestamp | null + modifiedById: number | null + name: string +} + +export interface Supplier { + id: Generated + name: string | null + supplierTypeId: number + taxId: string | null +} + +export interface SupplierType { + id: Generated + name: string +} + +export interface Transaction { + accountNumber: number + amount: Numeric + description: string | null + entryId: number + id: Generated + invoiceId: number | null + objectId: number | null + quantity: Numeric | null + signature: string | null + transactionDate: Timestamp | null +} + +export interface TransactionsToObjects { + objectId: number + transactionId: number +} + +export interface User { + bannedAt: Timestamp | null + bannedById: number | null + blockedAt: Timestamp | null + blockedById: number | null + createdAt: Generated + email: string + emailVerifiedAt: Timestamp | null + id: Generated + lastActivityAt: Timestamp | null + lastLoginAt: Timestamp | null + lastLoginAttemptAt: Timestamp | null + loginAttempts: Generated + password: string +} + +export interface UsersRoles { + roleId: number + userId: number +} + +export interface DB { + account: Account + accountBalance: AccountBalance + admission: Admission + admissions_roles: AdmissionsRoles + aliasesToSupplier: AliasesToSupplier + dimension: Dimension + emailToken: EmailToken + entry: Entry + error: Error + file: File + filesToInvoice: FilesToInvoice + financialYear: FinancialYear + invite: Invite + invites_roles: InvitesRoles + invoice: Invoice + journal: Journal + object: Object + passwordToken: PasswordToken + role: Role + supplier: Supplier + supplierType: SupplierType + transaction: Transaction + transactionsToObjects: TransactionsToObjects + user: User + users_roles: UsersRoles +} diff --git a/shared/types.ts b/shared/types.ts index 849d5b0..41bf6e4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,85 +1,85 @@ -export type Account = { - id: number - number: number - description: string -} +// export type Account = { +// id: number +// number: number +// description: string +// } -export type Balance = { - accountNumber: string - description: string -} & Record +// export type Balance = { +// accountNumber: string +// description: string +// } & Record -export interface Entry { - id: number - journal: string - number: number - amount: number - description: string - transactionDate: string - entryDate: string - transactions: { - accountNumber: number - description: string - amount: number - }[] -} +// export interface Entry { +// id: number +// journal: string +// number: number +// amount: number +// description: string +// transactionDate: string +// entryDate: string +// transactions: { +// accountNumber: number +// description: string +// amount: number +// }[] +// } -export type FinancialYear = { - year: number - startDate: string - endDate: string -} +// export type FinancialYear = { +// year: number +// startDate: string +// endDate: string +// } -export type Invoice = { - id: number - fiskenNumber?: number - phmNumber?: number - invoiceDate: string - dueDate: string - invoiceNumber: number - amount: number - files?: { filename: string }[] - transactions?: { - accountNumber: number - amount: number - description: number - entryId: number - }[] -} +// export type Invoice = { +// id: number +// fiskenNumber?: number +// phmNumber?: number +// invoiceDate: string +// dueDate: string +// invoiceNumber: number +// amount: number +// files?: { filename: string }[] +// transactions?: { +// accountNumber: number +// amount: number +// description: number +// entryId: number +// }[] +// } -export type Journal = { - id: number - identifier: string -} +// export type Journal = { +// id: number +// identifier: string +// } -export type Object = { - id: number - dimensionName: string - name: string -} +// export type Object = { +// id: number +// dimensionName: string +// name: string +// } -export type Result = { - accountNumber: number - description?: string -} & Record +// export type Result = { +// accountNumber: number +// description?: string +// } & Record -export type Supplier = { - id: number - name: string -} +// export type Supplier = { +// id: number +// name: string +// } -export interface Transaction { - accountNumber: number - description: string - amount: number - entryId: number -} +// export interface Transaction { +// accountNumber: number +// description: string +// amount: number +// entryId: number +// } -export interface TransactionFull extends Transaction { - transactionDate: string - invoiceId: number - entryDescription: string -} +// export interface TransactionFull extends Transaction { +// transactionDate: string +// invoiceId: number +// entryDescription: string +// } export interface Route { path: string