From 9d9ee1b4ce6fb78e7df4901a89d36d3a1718ded8 Mon Sep 17 00:00:00 2001 From: Linus Miller Date: Tue, 16 Dec 2025 23:04:38 +0100 Subject: [PATCH] WIP more auth work and convert to kysely and zod --- .bruno/{BRF => API}/api-accounts.bru | 0 .../Admissions.bru => API/api-admissions.bru} | 2 +- .bruno/{BRF => API}/api-balances.bru | 0 .bruno/{BRF => API}/api-entries--id.bru | 0 .bruno/{BRF => API}/api-entries.bru | 0 .bruno/{BRF => API}/api-financial-years.bru | 0 .../{BRF/Invites.bru => API/api-invites.bru} | 6 +- .bruno/{BRF => API}/api-invoices--id.bru | 2 +- .../api-invoices-total-amount.bru | 2 +- .bruno/{BRF => API}/api-invoices.bru | 2 +- .bruno/{BRF => API}/api-objects--id.bru | 2 +- .bruno/{BRF => API}/api-objects.bru | 2 +- .bruno/{BRF => API}/api-results--year.bru | 0 .bruno/{BRF => API}/api-results.bru | 2 +- .bruno/{BRF/Roles.bru => API/api-roles.bru} | 4 +- .bruno/{BRF => API}/api-suppliers-merge.bru | 2 +- .bruno/{BRF => API}/api-suppliers.bru | 2 +- .bruno/{BRF => API}/api-transactions.bru | 2 +- .bruno/{BRF/Users.bru => API/api-users.bru} | 4 +- .bruno/API/folder.bru | 8 + .bruno/Auth/Login.bru | 18 ++ .bruno/Auth/Logout.bru | 11 + .bruno/{BRF => }/bruno.json | 0 .bruno/{BRF => }/collection.bru | 0 client/admin/components/admission_form.tsx | 6 +- client/admin/components/git_log.tsx | 37 --- client/admin/components/invite_form.tsx | 5 +- client/admin/components/role_form.tsx | 9 +- client/admin/components/start_page.tsx | 8 - docker/postgres/01-auth_schema.sql | 6 +- server/config/site.ts | 6 +- server/env.ts | 1 - server/lib/kysely_helpers.ts | 117 ++++++---- server/plugins/auth/routes/change_password.ts | 12 +- server/plugins/auth/routes/login.ts | 14 +- server/plugins/auth/routes/logout.ts | 5 +- server/plugins/auth/routes/register.ts | 14 +- server/plugins/auth/routes/reset_password.ts | 8 +- server/plugins/auth/routes/verify_email.ts | 10 +- server/routes/api.ts | 4 + server/routes/api/accounts.ts | 17 +- server/routes/api/admissions.ts | 156 ++++++++----- server/routes/api/balances.ts | 4 +- server/routes/api/entries.ts | 15 +- server/routes/api/financial_years.ts | 4 +- server/routes/api/invites.ts | 123 ++++++---- server/routes/api/invoices.ts | 29 +-- server/routes/api/journals.ts | 4 +- server/routes/api/objects.ts | 9 +- server/routes/api/process.ts | 87 +++---- server/routes/api/results.ts | 9 +- server/routes/api/roles.test.ts | 11 - server/routes/api/roles.ts | 215 ++++++++---------- server/routes/api/suppliers.ts | 2 +- server/routes/api/transactions.ts | 46 ++-- server/routes/api/users.ts | 4 +- server/schemas/db.ts | 55 ++++- server/tests/admissions.test.ts | 27 +++ shared/global.d.ts | 2 +- shared/types.db.ts | 2 +- 60 files changed, 647 insertions(+), 507 deletions(-) rename .bruno/{BRF => API}/api-accounts.bru (100%) rename .bruno/{BRF/Admissions.bru => API/api-admissions.bru} (85%) rename .bruno/{BRF => API}/api-balances.bru (100%) rename .bruno/{BRF => API}/api-entries--id.bru (100%) rename .bruno/{BRF => API}/api-entries.bru (100%) rename .bruno/{BRF => API}/api-financial-years.bru (100%) rename .bruno/{BRF/Invites.bru => API/api-invites.bru} (59%) rename .bruno/{BRF => API}/api-invoices--id.bru (95%) rename .bruno/{BRF => API}/api-invoices-total-amount.bru (95%) rename .bruno/{BRF => API}/api-invoices.bru (94%) rename .bruno/{BRF => API}/api-objects--id.bru (94%) rename .bruno/{BRF => API}/api-objects.bru (94%) rename .bruno/{BRF => API}/api-results--year.bru (100%) rename .bruno/{BRF => API}/api-results.bru (94%) rename .bruno/{BRF/Roles.bru => API/api-roles.bru} (81%) rename .bruno/{BRF => API}/api-suppliers-merge.bru (96%) rename .bruno/{BRF => API}/api-suppliers.bru (94%) rename .bruno/{BRF => API}/api-transactions.bru (96%) rename .bruno/{BRF/Users.bru => API/api-users.bru} (82%) create mode 100644 .bruno/API/folder.bru create mode 100644 .bruno/Auth/Login.bru create mode 100644 .bruno/Auth/Logout.bru rename .bruno/{BRF => }/bruno.json (100%) rename .bruno/{BRF => }/collection.bru (100%) delete mode 100644 client/admin/components/git_log.tsx delete mode 100644 server/routes/api/roles.test.ts create mode 100644 server/tests/admissions.test.ts diff --git a/.bruno/BRF/api-accounts.bru b/.bruno/API/api-accounts.bru similarity index 100% rename from .bruno/BRF/api-accounts.bru rename to .bruno/API/api-accounts.bru diff --git a/.bruno/BRF/Admissions.bru b/.bruno/API/api-admissions.bru similarity index 85% rename from .bruno/BRF/Admissions.bru rename to .bruno/API/api-admissions.bru index d7d6b5a..a0dfea9 100644 --- a/.bruno/BRF/Admissions.bru +++ b/.bruno/API/api-admissions.bru @@ -1,5 +1,5 @@ meta { - name: Admissions + name: /api/admissions type: http seq: 1 } diff --git a/.bruno/BRF/api-balances.bru b/.bruno/API/api-balances.bru similarity index 100% rename from .bruno/BRF/api-balances.bru rename to .bruno/API/api-balances.bru diff --git a/.bruno/BRF/api-entries--id.bru b/.bruno/API/api-entries--id.bru similarity index 100% rename from .bruno/BRF/api-entries--id.bru rename to .bruno/API/api-entries--id.bru diff --git a/.bruno/BRF/api-entries.bru b/.bruno/API/api-entries.bru similarity index 100% rename from .bruno/BRF/api-entries.bru rename to .bruno/API/api-entries.bru diff --git a/.bruno/BRF/api-financial-years.bru b/.bruno/API/api-financial-years.bru similarity index 100% rename from .bruno/BRF/api-financial-years.bru rename to .bruno/API/api-financial-years.bru diff --git a/.bruno/BRF/Invites.bru b/.bruno/API/api-invites.bru similarity index 59% rename from .bruno/BRF/Invites.bru rename to .bruno/API/api-invites.bru index 8ae58be..bb782c4 100644 --- a/.bruno/BRF/Invites.bru +++ b/.bruno/API/api-invites.bru @@ -1,11 +1,11 @@ meta { - name: Invites + name: /api/invites type: http - seq: 8 + seq: 10 } get { - url: http://localhost:4040/api/invites + url: {{base_url}}/api/invites body: none auth: none } diff --git a/.bruno/BRF/api-invoices--id.bru b/.bruno/API/api-invoices--id.bru similarity index 95% rename from .bruno/BRF/api-invoices--id.bru rename to .bruno/API/api-invoices--id.bru index ce3e34a..e17cad0 100644 --- a/.bruno/BRF/api-invoices--id.bru +++ b/.bruno/API/api-invoices--id.bru @@ -1,7 +1,7 @@ meta { name: /api/invoices/:id type: http - seq: 10 + seq: 8 } get { diff --git a/.bruno/BRF/api-invoices-total-amount.bru b/.bruno/API/api-invoices-total-amount.bru similarity index 95% rename from .bruno/BRF/api-invoices-total-amount.bru rename to .bruno/API/api-invoices-total-amount.bru index beb7a96..314bf7e 100644 --- a/.bruno/BRF/api-invoices-total-amount.bru +++ b/.bruno/API/api-invoices-total-amount.bru @@ -1,7 +1,7 @@ meta { name: /api/invoices/total-amount type: http - seq: 12 + seq: 9 } get { diff --git a/.bruno/BRF/api-invoices.bru b/.bruno/API/api-invoices.bru similarity index 94% rename from .bruno/BRF/api-invoices.bru rename to .bruno/API/api-invoices.bru index 4969994..5d020b2 100644 --- a/.bruno/BRF/api-invoices.bru +++ b/.bruno/API/api-invoices.bru @@ -1,7 +1,7 @@ meta { name: /api/invoices type: http - seq: 9 + seq: 7 } get { diff --git a/.bruno/BRF/api-objects--id.bru b/.bruno/API/api-objects--id.bru similarity index 94% rename from .bruno/BRF/api-objects--id.bru rename to .bruno/API/api-objects--id.bru index 29af21d..7d84c0e 100644 --- a/.bruno/BRF/api-objects--id.bru +++ b/.bruno/API/api-objects--id.bru @@ -1,7 +1,7 @@ meta { name: /api/objects/:id type: http - seq: 7 + seq: 12 } get { diff --git a/.bruno/BRF/api-objects.bru b/.bruno/API/api-objects.bru similarity index 94% rename from .bruno/BRF/api-objects.bru rename to .bruno/API/api-objects.bru index d202547..8e0f817 100644 --- a/.bruno/BRF/api-objects.bru +++ b/.bruno/API/api-objects.bru @@ -1,7 +1,7 @@ meta { name: /api/objects type: http - seq: 8 + seq: 11 } get { diff --git a/.bruno/BRF/api-results--year.bru b/.bruno/API/api-results--year.bru similarity index 100% rename from .bruno/BRF/api-results--year.bru rename to .bruno/API/api-results--year.bru diff --git a/.bruno/BRF/api-results.bru b/.bruno/API/api-results.bru similarity index 94% rename from .bruno/BRF/api-results.bru rename to .bruno/API/api-results.bru index 832bc3e..7ac44da 100644 --- a/.bruno/BRF/api-results.bru +++ b/.bruno/API/api-results.bru @@ -1,7 +1,7 @@ meta { name: /api/results type: http - seq: 12 + seq: 13 } get { diff --git a/.bruno/BRF/Roles.bru b/.bruno/API/api-roles.bru similarity index 81% rename from .bruno/BRF/Roles.bru rename to .bruno/API/api-roles.bru index af8e2f3..171a0ac 100644 --- a/.bruno/BRF/Roles.bru +++ b/.bruno/API/api-roles.bru @@ -1,7 +1,7 @@ meta { - name: Roles + name: /api/roles type: http - seq: 17 + seq: 15 } get { diff --git a/.bruno/BRF/api-suppliers-merge.bru b/.bruno/API/api-suppliers-merge.bru similarity index 96% rename from .bruno/BRF/api-suppliers-merge.bru rename to .bruno/API/api-suppliers-merge.bru index 1f35d13..5233499 100644 --- a/.bruno/BRF/api-suppliers-merge.bru +++ b/.bruno/API/api-suppliers-merge.bru @@ -1,7 +1,7 @@ meta { name: /api/suppliers/merge type: http - seq: 13 + seq: 17 } post { diff --git a/.bruno/BRF/api-suppliers.bru b/.bruno/API/api-suppliers.bru similarity index 94% rename from .bruno/BRF/api-suppliers.bru rename to .bruno/API/api-suppliers.bru index 48ed2ee..80d5b0f 100644 --- a/.bruno/BRF/api-suppliers.bru +++ b/.bruno/API/api-suppliers.bru @@ -1,7 +1,7 @@ meta { name: /api/suppliers type: http - seq: 11 + seq: 16 } get { diff --git a/.bruno/BRF/api-transactions.bru b/.bruno/API/api-transactions.bru similarity index 96% rename from .bruno/BRF/api-transactions.bru rename to .bruno/API/api-transactions.bru index 3172821..45059df 100644 --- a/.bruno/BRF/api-transactions.bru +++ b/.bruno/API/api-transactions.bru @@ -1,7 +1,7 @@ meta { name: /api/transactions type: http - seq: 16 + seq: 18 } get { diff --git a/.bruno/BRF/Users.bru b/.bruno/API/api-users.bru similarity index 82% rename from .bruno/BRF/Users.bru rename to .bruno/API/api-users.bru index 0b51731..216f3e7 100644 --- a/.bruno/BRF/Users.bru +++ b/.bruno/API/api-users.bru @@ -1,7 +1,7 @@ meta { - name: Users + name: /api/users type: http - seq: 9 + seq: 19 } get { diff --git a/.bruno/API/folder.bru b/.bruno/API/folder.bru new file mode 100644 index 0000000..39e91da --- /dev/null +++ b/.bruno/API/folder.bru @@ -0,0 +1,8 @@ +meta { + name: API + seq: 2 +} + +auth { + mode: inherit +} diff --git a/.bruno/Auth/Login.bru b/.bruno/Auth/Login.bru new file mode 100644 index 0000000..fc98630 --- /dev/null +++ b/.bruno/Auth/Login.bru @@ -0,0 +1,18 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{base_url}}/auth/login + body: json + auth: none +} + +body:json { + { + "email": "linus.miller@bitmill.io", + "password": "rasmus" + } +} diff --git a/.bruno/Auth/Logout.bru b/.bruno/Auth/Logout.bru new file mode 100644 index 0000000..930d1c3 --- /dev/null +++ b/.bruno/Auth/Logout.bru @@ -0,0 +1,11 @@ +meta { + name: Logout + type: http + seq: 2 +} + +get { + url: {{base_url}}/auth/logout + body: none + auth: none +} diff --git a/.bruno/BRF/bruno.json b/.bruno/bruno.json similarity index 100% rename from .bruno/BRF/bruno.json rename to .bruno/bruno.json diff --git a/.bruno/BRF/collection.bru b/.bruno/collection.bru similarity index 100% rename from .bruno/BRF/collection.bru rename to .bruno/collection.bru diff --git a/client/admin/components/admission_form.tsx b/client/admin/components/admission_form.tsx index eb93d0b..65c80a6 100644 --- a/client/admin/components/admission_form.tsx +++ b/client/admin/components/admission_form.tsx @@ -25,9 +25,11 @@ const AdmissionForm: FunctionComponent<{ actions.pending() try { + const form = e.currentTarget + const result = await rek[admission ? 'patch' : 'post']( `/api/admissions${admission ? '/' + admission.id : ''}`, - serializeForm(e.currentTarget), + serializeForm(form), ) actions.success() @@ -35,7 +37,7 @@ const AdmissionForm: FunctionComponent<{ if (admission) { onUpdate?.(result) } else { - e.currentTarget.reset() + form.reset() onCreate?.(result) } } catch (err) { diff --git a/client/admin/components/git_log.tsx b/client/admin/components/git_log.tsx deleted file mode 100644 index 04d844f..0000000 --- a/client/admin/components/git_log.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { h, type FunctionComponent } from 'preact' -import { useEffect, useState } from 'preact/hooks' -import rek from 'rek' -import { Table, Td } from './table.tsx' - -const GitLog: FunctionComponent = () => { - const [commits, setCommits] = useState(null) - - useEffect(() => { - rek('/api/git-log').then(setCommits) - }, []) - - return ( - commits && ( - - - {commits.map((commit) => ( - - - - - - - ))} - -
{new Date(commit.date).toLocaleString('sv-SE')}{commit.author}{commit.message} - ( - - {commit.hash.slice(0, 6)} - - ) -
- ) - ) -} - -export default GitLog diff --git a/client/admin/components/invite_form.tsx b/client/admin/components/invite_form.tsx index a6ea099..4f1d905 100644 --- a/client/admin/components/invite_form.tsx +++ b/client/admin/components/invite_form.tsx @@ -18,9 +18,10 @@ const InviteForm: FunctionComponent<{ onCreate?: ANY; roles?: ANY[] }> = ({ onCr actions.pending() try { - const result = await rek.post('/api/invites', serializeForm(e.currentTarget)) + const form = e.currentTarget + const result = await rek.post('/api/invites', serializeForm(form)) - e.currentTarget.reset() + form.reset() actions.success() onCreate?.(result) } catch (err) { diff --git a/client/admin/components/role_form.tsx b/client/admin/components/role_form.tsx index 649ae79..0e5ad2e 100644 --- a/client/admin/components/role_form.tsx +++ b/client/admin/components/role_form.tsx @@ -23,17 +23,16 @@ const RoleForm: FunctionComponent<{ role?: ANY; onCancel?: ANY; onCreate?: ANY; actions.pending() try { - const result = await rek[role ? 'patch' : 'post']( - `/api/roles${role ? '/' + role.id : ''}`, - serializeForm(e.currentTarget), - ) + const form = e.currentTarget + + const result = await rek[role ? 'patch' : 'post'](`/api/roles${role ? '/' + role.id : ''}`, serializeForm(form)) actions.success() if (role) { onUpdate?.(result) } else { - e.currentTarget.reset() + form.reset() onCreate?.(result) } } catch (err) { diff --git a/client/admin/components/start_page.tsx b/client/admin/components/start_page.tsx index 3cd22c3..1a1d99c 100644 --- a/client/admin/components/start_page.tsx +++ b/client/admin/components/start_page.tsx @@ -1,7 +1,6 @@ import { h } from 'preact' import PageHeader from './page_header.tsx' import Row from './row.tsx' -import GitLog from './git_log.tsx' import Process from './process.tsx' import Section from './section.tsx' @@ -16,13 +15,6 @@ const StartPage = () => ( -
- Latest Commits - - - -
-
Process diff --git a/docker/postgres/01-auth_schema.sql b/docker/postgres/01-auth_schema.sql index a6689d7..d45f326 100644 --- a/docker/postgres/01-auth_schema.sql +++ b/docker/postgres/01-auth_schema.sql @@ -159,7 +159,7 @@ ALTER SEQUENCE public.invite_id_seq OWNED BY public.invite.id; -- CREATE TABLE public.invites_roles ( - "invitedId" integer NOT NULL, + "inviteId" integer NOT NULL, "roleId" integer NOT NULL ); @@ -401,7 +401,7 @@ ALTER TABLE ONLY public.invite -- ALTER TABLE ONLY public.invites_roles - ADD CONSTRAINT invites_roles_pkey PRIMARY KEY ("invitedId", "roleId"); + ADD CONSTRAINT invites_roles_pkey PRIMARY KEY ("inviteId", "roleId"); -- @@ -577,7 +577,7 @@ ALTER TABLE ONLY public.invite -- ALTER TABLE ONLY public.invites_roles - ADD CONSTRAINT "invites_roles_inviteId_fkey" FOREIGN KEY ("invitedId") REFERENCES public.invite(id) ON UPDATE CASCADE ON DELETE CASCADE; + ADD CONSTRAINT "invites_roles_inviteId_fkey" FOREIGN KEY ("inviteId") REFERENCES public.invite(id) ON UPDATE CASCADE ON DELETE CASCADE; -- diff --git a/server/config/site.ts b/server/config/site.ts index 2705b84..a7e1376 100644 --- a/server/config/site.ts +++ b/server/config/site.ts @@ -1,7 +1,7 @@ import _ from 'lodash' import env from '../env.ts' -const domain = 'startbit.bitmill.io' +const domain = 'brf.lkm.nu' type SiteConfig = { title: string @@ -17,8 +17,8 @@ type SiteConfig = { } const defaults: SiteConfig = { - title: 'startbit', - name: 'startbit,', + title: 'BRF', + name: 'brf', port: null, hostname: null, domain, diff --git a/server/env.ts b/server/env.ts index e1cb66f..6c7e375 100644 --- a/server/env.ts +++ b/server/env.ts @@ -53,7 +53,6 @@ export default read( 'SESSION_SECRET', ] as const, { - MAILGUN_API_KEY: 'this is not a real key', PGPASSWORD: null, PGPORT: null, PGUSER: null, diff --git a/server/lib/kysely_helpers.ts b/server/lib/kysely_helpers.ts index f8d7e7c..fac2401 100644 --- a/server/lib/kysely_helpers.ts +++ b/server/lib/kysely_helpers.ts @@ -1,47 +1,80 @@ -// import type { QueryBuilder } from 'knex' -import type { SelectQueryBuilder } from 'kysely' -import _ from 'lodash' +import { type SelectQueryBuilder, type ReferenceExpression, type OperandValueExpression, sql } from 'kysely' -// export function convertToReturning(obj: Record) { -// return _.map(obj, (value, key) => _.isString(value) && `${value} as ${key}`).filter(_.identity) -// } +type WhereInput = Partial<{ + [C in keyof DB[TB]]: + | OperandValueExpression + | readonly OperandValueExpression[] + | null +}> -// export function columnAs(name: string, table: string, transformer = _.snakeCase) { -// const transformed = transformer(name) +export function applyWhere( + builder: SelectQueryBuilder, + where: WhereInput, +): SelectQueryBuilder { + return Object.entries(where).reduce((builder, [key, value]) => { + const column = key as ReferenceExpression -// if (transformed !== name) { -// // knex automagically wraps everything in ", so they are not needed around ${name} -// return `${table ? table + '.' : ''}${transformed} as ${name}` -// } + if (value === null) { + return builder.where(column, 'is', null) + } else if (Array.isArray(value)) { + return builder.where(column, 'in', value) + } -// 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, - ) + return builder.where(column, '=', value) + }, builder) +} + +interface PaginationInput { + where: WhereInput + limit?: number + offset?: number + orderBy?: { + column: ReferenceExpression + direction?: 'asc' | 'desc' + } +} + +export function applyPagination( + builder: SelectQueryBuilder, + { limit, offset, orderBy }: PaginationInput, +): SelectQueryBuilder { + let qb = builder + + if (orderBy) { + qb = qb.orderBy(orderBy.column, orderBy.direction ?? 'asc') + } + + if (limit !== undefined) { + qb = qb.limit(limit) + } + + if (offset !== undefined) { + qb = qb.offset(offset) + } + + return qb +} + +export async function paginate( + baseDataQuery: SelectQueryBuilder, + baseCountQuery: SelectQueryBuilder, + input: PaginationInput, +) { + const dataQuery = applyPagination(input.where ? applyWhere(baseDataQuery, input.where) : baseDataQuery, input) + + let countQuery = baseCountQuery.select(sql`count(*)::int`.as('totalCount')) + + if (input.where) { + countQuery = applyWhere(countQuery, input.where) + } + + const [countRow, items] = await Promise.all([countQuery.executeTakeFirstOrThrow(), dataQuery.execute()]) + + return { + items, + totalCount: (countRow as { totalCount: number }).totalCount, + limit: input.limit, + offset: input.offset, + count: items.length, + } } diff --git a/server/plugins/auth/routes/change_password.ts b/server/plugins/auth/routes/change_password.ts index ee59bf2..1cc96e1 100644 --- a/server/plugins/auth/routes/change_password.ts +++ b/server/plugins/auth/routes/change_password.ts @@ -1,5 +1,5 @@ import type { RouteHandler } from 'fastify' -import { Type, type Static } from '@fastify/type-provider-typebox' +import * as z from 'zod' import config from '../../../config.ts' import knex from '../../../lib/knex.ts' import StatusError from '../../../lib/status_error.ts' @@ -8,10 +8,10 @@ import { hashPassword } from '../helpers.ts' import { StatusErrorSchema } from '../../../schemas/status_error.ts' const { errors, timeouts } = config.auth -const BodySchema = Type.Object({ - email: Type.String({ format: 'email' }), - password: Type.String(), - token: Type.String(), +const BodySchema = z.object({ + email: z.email(), + password: z.string(), + token: z.string(), }) const ResponseSchema = { @@ -19,7 +19,7 @@ const ResponseSchema = { '4XX': StatusErrorSchema, } -const changePassword: RouteHandler<{ Body: Static }> = async function changePassword( +const changePassword: RouteHandler<{ Body: z.infer }> = async function changePassword( request, reply, ) { diff --git a/server/plugins/auth/routes/login.ts b/server/plugins/auth/routes/login.ts index 371e093..02b69a7 100644 --- a/server/plugins/auth/routes/login.ts +++ b/server/plugins/auth/routes/login.ts @@ -1,6 +1,6 @@ import _ from 'lodash' +import * as z from 'zod' import type { RouteHandler } from 'fastify' -import { Type, type Static } from '@fastify/type-provider-typebox' import config from '../../../config.ts' import UserQueries from '../../../services/users/queries.ts' import emitter from '../../../lib/emitter.ts' @@ -12,14 +12,14 @@ import { verifyPassword } from '../helpers.ts' const userQueries = UserQueries({ knex, emitter }) const { errors, maxLoginAttempts, requireVerification } = config.auth -const BodySchema = Type.Object({ - email: Type.String(), - password: Type.String(), - remember: Type.Optional(Type.Boolean()), +const BodySchema = z.object({ + email: z.string(), + password: z.string(), + remember: z.boolean().optional(), }) const ResponseSchema = { - 200: Type.Object({ + 200: z.object({ id: { type: 'integer' }, email: { type: 'string' }, password: { type: 'string' }, @@ -31,7 +31,7 @@ const ResponseSchema = { '4XX': StatusErrorSchema, } -const login: RouteHandler<{ Body: Static }> = async function (request, reply) { +const login: RouteHandler<{ Body: z.infer }> = async function (request, reply) { try { const user = await userQueries.findOne({ email: request.body.email }) diff --git a/server/plugins/auth/routes/logout.ts b/server/plugins/auth/routes/logout.ts index 5ff4d12..998daa7 100644 --- a/server/plugins/auth/routes/logout.ts +++ b/server/plugins/auth/routes/logout.ts @@ -1,7 +1,6 @@ -import type { RouteHandler } from 'fastify' +import type { FastifySchema, RouteHandler } from 'fastify' -/** @type {import('fastify').FastifySchema} */ -const schema = { +const schema: FastifySchema = { response: { '3XX': {}, }, diff --git a/server/plugins/auth/routes/register.ts b/server/plugins/auth/routes/register.ts index a2c78b0..50e733e 100644 --- a/server/plugins/auth/routes/register.ts +++ b/server/plugins/auth/routes/register.ts @@ -1,5 +1,5 @@ import _ from 'lodash' -import { Type, type Static } from '@fastify/type-provider-typebox' +import * as z from 'zod' import type { RouteHandler } from 'fastify' import config from '../../../config.ts' import emitter from '../../../lib/emitter.ts' @@ -20,11 +20,11 @@ const userQueries = UserQueries({ knex, emitter }) const { errors, timeouts } = config.auth -const BodySchema = Type.Object({ - email: Type.String({ format: 'email' }), - password: Type.String(), - inviteEmail: Type.Optional(Type.String({ format: 'email' })), - inviteToken: Type.Optional(Type.String()), +const BodySchema = z.object({ + email: z.email(), + password: z.string(), + inviteEmail: z.email().optional(), + inviteToken: z.string().optional(), // required: config.auth.requireInvite ? ['email', 'password', 'inviteEmail', 'inviteToken'] : ['email', 'password'], }) @@ -44,7 +44,7 @@ const ResponseSchema = { '4XX': { $ref: 'status-error' }, } -const register: RouteHandler<{ Body: Static }> = async function (request, reply) { +const register: RouteHandler<{ Body: z.infer }> = async function (request, reply) { try { // TODO validate and ensure same as confirm const email = request.body.email.trim().toLowerCase() diff --git a/server/plugins/auth/routes/reset_password.ts b/server/plugins/auth/routes/reset_password.ts index 973c801..7b151a0 100644 --- a/server/plugins/auth/routes/reset_password.ts +++ b/server/plugins/auth/routes/reset_password.ts @@ -1,5 +1,5 @@ import type { RouteHandler } from 'fastify' -import { Type, type Static } from '@fastify/type-provider-typebox' +import * as z from 'zod' import config from '../../../config.ts' import knex from '../../../lib/knex.ts' import sendMail from '../../../lib/send_mail.ts' @@ -11,8 +11,8 @@ import forgotPasswordTemplate from '../../../templates/emails/forgot_password.ts const { errors } = config.auth -const BodySchema = Type.Object({ - email: Type.String({ format: 'email' }), +const BodySchema = z.object({ + email: z.email(), }) const ResponseSchema = { @@ -20,7 +20,7 @@ const ResponseSchema = { '4XX': StatusErrorSchema, } -const forgotPassword: RouteHandler<{ Body: Static }> = async function (request, reply) { +const forgotPassword: RouteHandler<{ Body: z.infer }> = async function (request, reply) { const { email } = request.body try { diff --git a/server/plugins/auth/routes/verify_email.ts b/server/plugins/auth/routes/verify_email.ts index e714014..5400be4 100644 --- a/server/plugins/auth/routes/verify_email.ts +++ b/server/plugins/auth/routes/verify_email.ts @@ -1,15 +1,15 @@ import type { RouteHandler } from 'fastify' +import * as z from 'zod' import config from '../../../config.ts' import knex from '../../../lib/knex.ts' import StatusError from '../../../lib/status_error.ts' import { StatusErrorSchema } from '../../../schemas/status_error.ts' -import { Type, type Static } from '@fastify/type-provider-typebox' const { errors, timeouts } = config.auth -const QuerystringSchema = Type.Object({ - email: Type.String({ format: 'email' }), - token: Type.String(), +const QuerystringSchema = z.object({ + email: z.email(), + token: z.string(), }) const ResponseSchema = { @@ -17,7 +17,7 @@ const ResponseSchema = { '4XX': StatusErrorSchema, } -const verifyEmail: RouteHandler<{ Querystring: Static }> = async function (request, reply) { +const verifyEmail: RouteHandler<{ Querystring: z.infer }> = async function (request, reply) { try { const { token, email } = request.query diff --git a/server/routes/api.ts b/server/routes/api.ts index ce31e3c..9af3c39 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -2,9 +2,11 @@ import _ from 'lodash' import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' 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 financialYears from './api/financial_years.ts' +import invites from './api/invites.ts' import invoices from './api/invoices.ts' import journals from './api/journals.ts' import objects from './api/objects.ts' @@ -16,9 +18,11 @@ import transactions from './api/transactions.ts' const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.register(accounts, { prefix: '/accounts' }) + fastify.register(admissions, { prefix: '/admissions' }) fastify.register(balances, { prefix: '/balances' }) fastify.register(entries, { prefix: '/entries' }) fastify.register(financialYears, { prefix: '/financial-years' }) + fastify.register(invites, { prefix: '/invites' }) fastify.register(invoices, { prefix: '/invoices' }) fastify.register(journals, { prefix: '/journals' }) fastify.register(objects, { prefix: '/objects' }) diff --git a/server/routes/api/accounts.ts b/server/routes/api/accounts.ts index 406be01..10cd045 100644 --- a/server/routes/api/accounts.ts +++ b/server/routes/api/accounts.ts @@ -1,13 +1,22 @@ import _ from 'lodash' -import { 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 { AccountSchema } from '../../schemas/db.ts' + +const accountRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { + const { db } = fastify -const accountRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', + schema: { + response: { + 200: z.array(AccountSchema), + }, + }, handler() { - return knex('account').select('*').orderBy('number') + return db.selectFrom('account').selectAll().orderBy('number').execute() }, }) diff --git a/server/routes/api/admissions.ts b/server/routes/api/admissions.ts index 24462bd..87d8f86 100644 --- a/server/routes/api/admissions.ts +++ b/server/routes/api/admissions.ts @@ -1,30 +1,12 @@ -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' -import emitter from '../../lib/emitter.ts' -import knex from '../../lib/knex.ts' -import Queries from '../../services/admissions/queries.ts' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' +import { sql } from 'kysely' +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres' -import { RoleSchema } from './roles.ts' +import { AdmissionSchema, RoleSchema } from '../../schemas/db.ts' -export const AdmissionSchema = Type.Object({ - id: Type.Number(), - regex: Type.String(), - roles: Type.Array(RoleSchema), - createdAt: Type.String({ format: 'date-time' }), - createdById: { type: 'integer' }, - createdBy: { type: 'object', properties: { id: { type: 'integer' }, email: { type: 'string' } } }, - modifiedAt: [Type.String({ format: 'date-time' }), Type.Null()], - modifiedById: [Type.Number, Type.Null()], -}) - -export const AdmissionVariableSchema = Type.Object({ - regex: Type.String(), - roles: Type.Array(Type.Number()), - createdById: Type.Optional(Type.Number()), - modifiedById: Type.Optional(Type.Number()), -}) - -const admissionsPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) => { - const queries = Queries({ emitter, knex }) +const admissionsPlugin: FastifyPluginCallbackZod = (fastify, _options, done) => { + const { db } = fastify fastify.addHook('onRequest', fastify.auth) @@ -33,11 +15,33 @@ const admissionsPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) method: 'GET', schema: { response: { - 200: { type: 'array', items: { $ref: 'admission' } }, + 200: z.array( + AdmissionSchema.extend({ + roles: z.array(RoleSchema.pick({ id: true, name: true })), + }), + ), }, }, - handler(request) { - return queries.find(request.query) + handler() { + return db + .selectFrom('admission as a') + .selectAll() + .select((eb) => [ + jsonObjectFrom(eb.selectFrom('user as u').select(['u.id', 'u.email']).whereRef('u.id', '=', 'a.createdById')) + .$notNull() + .as('createdBy'), + jsonObjectFrom( + eb.selectFrom('user as u').select(['u.id', 'u.email']).whereRef('u.id', '=', 'a.modifiedById'), + ).as('modifiedBy'), + jsonArrayFrom( + eb + .selectFrom('role as r') + .innerJoin('admissions_roles as ra', 'ra.roleId', 'r.id') + .select(['r.id', 'r.name']) + .whereRef('ra.admissionId', '=', 'a.id'), + ).as('roles'), + ]) + .execute() }, }) @@ -45,24 +49,39 @@ const admissionsPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) url: '/', method: 'POST', schema: { - body: AdmissionVariableSchema, + body: AdmissionSchema.pick({ regex: true }).extend({ + roles: z.array(z.coerce.number()), + }), response: { 201: AdmissionSchema, }, }, - async handler(request, reply) { - let body = request.body + async handler(request) { + const { roles, ...admissionProps } = request.body - if (request.session?.userId) { - body = { - ...request.body, - createdById: request.session.userId, - } + const trx = await db.startTransaction().execute() + + const admission = await trx + .insertInto('admission') + .values({ ...admissionProps, createdById: request.session.userId }) + .returningAll() + .executeTakeFirstOrThrow() + + await trx + .insertInto('admissions_roles') + .values(roles.map((roleId) => ({ admissionId: admission.id, roleId }))) + .execute() + + await trx.commit().execute() + + return { + ...admission, + roles: await db + .selectFrom('role as r') + .innerJoin('admissions_roles as ar', 'ar.roleId', 'r.id') + .select(['r.id', 'r.name']) + .execute(), } - - return queries.create(body).then((row) => { - return reply.header('Location', `${request.url}/${row.id}`).status(201).send(row) - }) }, }) @@ -70,16 +89,18 @@ const admissionsPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) url: '/:id', method: 'DELETE', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), response: { 204: {}, 404: {}, }, }, - handler(request, reply) { - return queries.removeById(request.params.id).then((count) => reply.status(count > 0 ? 204 : 404).send()) + handler() { + return {} + + // return queries.removeById(request.params.id).then((count: number) => reply.status(count > 0 ? 204 : 404).send()) }, }) @@ -87,25 +108,50 @@ const admissionsPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) url: '/:id', method: 'PATCH', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.coerce.number(), + }), + body: AdmissionSchema.pick({ regex: true }).extend({ + roles: z.array(z.coerce.number()), }), - body: AdmissionVariableSchema, response: { 204: {}, }, }, async handler(request) { - let body = request.body + const { roles, ...admissionProps } = request.body + const trx = await db.startTransaction().execute() + const admissionId = request.params.id - if (request.session.userId) { - body = { - ...request.body, - modifiedById: request.session.userId, - } + const [updatedAdmission] = await Promise.all([ + trx + .updateTable('admission') + .set({ ...admissionProps, modifiedById: request.session.userId }) + .where('id', '=', request.params.id) + .returningAll() + .execute(), + trx + .deleteFrom('admissions_roles') + .where('admissionId', '=', request.params.id) + .where('roleId', 'not in', roles) + .execute(), + sql`INSERT INTO admissions_roles("admissionId", "roleId") + SELECT ${admissionId}, "roleIds" FROM unnest(${roles}::int[]) AS "roleIds" WHERE NOT EXISTS + (SELECT 1 FROM admissions_roles WHERE "admissionId" = ${request.params.id} AND "roleId" = "roleIds")`.execute( + trx, + ), + ]) + + await trx.commit().execute() + + return { + ...updatedAdmission, + roles: await db + .selectFrom('role as r') + .innerJoin('admissions_roles as ar', 'ar.roleId', 'r.id') + .select(['r.id', 'r.name']) + .execute(), } - - return queries.update(request.params.id, body) }, }) diff --git a/server/routes/api/balances.ts b/server/routes/api/balances.ts index 1ed5497..eda0c7b 100644 --- a/server/routes/api/balances.ts +++ b/server/routes/api/balances.ts @@ -1,8 +1,8 @@ import _ from 'lodash' -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const balanceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const balanceRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', diff --git a/server/routes/api/entries.ts b/server/routes/api/entries.ts index 875e6f5..75a3624 100644 --- a/server/routes/api/entries.ts +++ b/server/routes/api/entries.ts @@ -1,15 +1,16 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const entryRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const entryRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', schema: { - querystring: Type.Object({ - journal: Type.String(), - year: Type.Number(), + querystring: z.object({ + journal: z.string(), + year: z.coerce.number(), }), }, async handler(req) { @@ -35,8 +36,8 @@ const entryRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:id', method: 'GET', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), }, async handler(req) { diff --git a/server/routes/api/financial_years.ts b/server/routes/api/financial_years.ts index ba8496a..abec382 100644 --- a/server/routes/api/financial_years.ts +++ b/server/routes/api/financial_years.ts @@ -1,8 +1,8 @@ import _ from 'lodash' -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const financialYearRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const financialYearRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', diff --git a/server/routes/api/invites.ts b/server/routes/api/invites.ts index 2464097..ac63717 100644 --- a/server/routes/api/invites.ts +++ b/server/routes/api/invites.ts @@ -1,42 +1,44 @@ -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' +import { jsonArrayFrom } from 'kysely/helpers/postgres' import config from '../../config.ts' -import knex from '../../lib/knex.ts' -import emitter from '../../lib/emitter.ts' import sendMail from '../../lib/send_mail.ts' -import Queries from '../../services/invites/queries.ts' +import { generateToken } from '../../plugins/auth/helpers.ts' import inviteEmailTemplate from '../../templates/emails/invite.ts' -import { RoleSchema } from './roles.ts' +import { InviteSchema, RoleSchema } from '../../schemas/db.ts' -export const InviteSchema = Type.Object({ - id: Type.Number(), - email: Type.String({ format: 'email' }), - token: Type.String(), - roles: Type.Array(RoleSchema), - createdAt: Type.String(), - createdById: Type.Number(), - createdBy: Type.Object({}), - consumedAt: [Type.String(), Type.Null()], - consumedById: [Type.Number(), Type.Null()], - consumedBy: [Type.Object({}), Type.Null()], -}) +const invitesPlugin: FastifyPluginCallbackZod = (fastify, _option, done) => { + const { db } = fastify -const invitesPlugin: FastifyPluginCallbackTypebox = (fastify, _option, done) => { - const queries = Queries({ emitter, knex }) - - fastify.addHook('onRequest', fastify.auth) + // fastify.addHook('onRequest', fastify.auth) fastify.route({ url: '/', method: 'GET', schema: { - querystring: InviteSchema, response: { - 200: InviteSchema, + 200: z.array( + InviteSchema.extend({ + roles: z.array(RoleSchema.pick({ id: true, name: true })), + }), + ), }, }, - handler(request) { - return queries.find(request.query) + handler() { + return db + .selectFrom('invite as i') + .selectAll() + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('role as r') + .innerJoin('invites_roles as ir', 'ir.roleId', 'r.id') + .select(['r.id', 'r.name']) + .whereRef('r.id', '=', 'ir.roleId'), + ).as('roles'), + ]) + .execute() }, }) @@ -44,31 +46,44 @@ const invitesPlugin: FastifyPluginCallbackTypebox = (fastify, _option, done) => url: '/', method: 'POST', schema: { - body: { - type: Type.Object({ - email: Type.String({ format: 'email' }), - roles: Type.Array(Type.Number()), + body: z.object({ + email: z.email(), + roles: z.array(z.coerce.number()), + }), + response: { + 201: InviteSchema.extend({ + roles: z.array(RoleSchema.pick({ id: true, name: true })), }), - properties: { - email: { type: 'string' }, - roles: { type: 'array', items: { type: 'integer' } }, - }, - required: ['email', 'roles'], }, - response: { 201: InviteSchema }, }, async handler(request, reply) { - const trx = await knex.transaction() + const trx = await db.startTransaction().execute() - const invite = await queries.create( - { - ...request.body, - createdById: request.session.userId, - }, - trx, - ) + const token = generateToken() + + const invite = await trx + .insertInto('invite') + .values({ email: request.body.email, token, createdById: request.session.userId }) + .returningAll() + .executeTakeFirstOrThrow() + + await trx + .insertInto('invites_roles') + .values( + request.body.roles.map((roleId) => ({ + inviteId: invite.id, + roleId, + })), + ) + .execute() try { + const roles = await trx + .selectFrom('role as r') + .select(['id', 'name']) + .innerJoin('invites_roles as ir', 'ir.roleId', 'r.id') + .where('ir.inviteId', '=', invite.id) + .execute() const link = `${new URL(config.auth.paths.register, config.site.url)}?email=${invite.email}&token=${invite.token}` await sendMail({ @@ -77,11 +92,17 @@ const invitesPlugin: FastifyPluginCallbackTypebox = (fastify, _option, done) => html: await inviteEmailTemplate({ link }).text(), }) - await trx.commit() + await trx.commit().execute() - return reply.header('Location', `${request.url}/${invite.id}`).status(201).send(invite) + return reply + .header('Location', `${request.url}/${invite.id}`) + .status(201) + .send({ + ...invite, + roles, + }) } catch (err) { - await trx.rollback() + await trx.rollback().execute() throw err } @@ -92,16 +113,18 @@ const invitesPlugin: FastifyPluginCallbackTypebox = (fastify, _option, done) => url: '/:id', method: 'DELETE', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), response: { 204: {}, 404: {}, }, }, - handler(request, reply) { - return queries.removeById(request.params.id).then((count) => reply.status(count > 0 ? 204 : 404).send()) + async handler(request, reply) { + const result = await db.deleteFrom('invite').where('id', '=', request.params.id).executeTakeFirstOrThrow() + + return reply.status(result.numDeletedRows > 0 ? 204 : 404).send() }, }) diff --git a/server/routes/api/invoices.ts b/server/routes/api/invoices.ts index 6406e52..1421508 100644 --- a/server/routes/api/invoices.ts +++ b/server/routes/api/invoices.ts @@ -1,16 +1,17 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' import StatusError from '../../lib/status_error.ts' -const invoiceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const invoiceRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', schema: { - querystring: Type.Object({ - year: Type.Optional(Type.Number()), - supplier: Type.Optional(Type.Number()), + querystring: z.object({ + year: z.number().optional(), + supplier: z.number().optional(), }), }, async handler(req) { @@ -53,9 +54,9 @@ const invoiceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/total-amount', method: 'GET', schema: { - querystring: Type.Object({ - year: Type.Optional(Type.Number()), - supplier: Type.Optional(Type.Number()), + querystring: z.object({ + year: z.number().optional(), + supplier: z.number().optional(), }), }, async handler(req) { @@ -80,8 +81,8 @@ const invoiceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:id', method: 'GET', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), }, handler(req) { @@ -110,8 +111,8 @@ const invoiceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/by-supplier/:supplier', method: 'GET', schema: { - params: Type.Object({ - supplier: Type.Number(), + params: z.object({ + supplier: z.number(), }), }, handler(req) { @@ -136,8 +137,8 @@ const invoiceRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/by-year/:year', method: 'GET', schema: { - params: Type.Object({ - year: Type.Number(), + params: z.object({ + year: z.number(), }), }, async handler(req) { diff --git a/server/routes/api/journals.ts b/server/routes/api/journals.ts index bf333dc..32bf47c 100644 --- a/server/routes/api/journals.ts +++ b/server/routes/api/journals.ts @@ -1,8 +1,8 @@ import _ from 'lodash' -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const journalRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const journalRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', diff --git a/server/routes/api/objects.ts b/server/routes/api/objects.ts index 7f69eb2..187cc67 100644 --- a/server/routes/api/objects.ts +++ b/server/routes/api/objects.ts @@ -1,8 +1,9 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const objectRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const objectRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', @@ -19,8 +20,8 @@ const objectRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:id', method: 'GET', schema: { - params: Type.Object({ - id: Type.Number(), + params: z.object({ + id: z.number(), }), }, async handler(req) { diff --git a/server/routes/api/process.ts b/server/routes/api/process.ts index a7c9d00..be2fe13 100644 --- a/server/routes/api/process.ts +++ b/server/routes/api/process.ts @@ -1,55 +1,56 @@ import process from 'node:process' import os from 'node:os' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import env from '../../env.ts' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' -const ProcessSchema = Type.Object({ - env: Type.String(), - version: Type.String(), - uptime: Type.Number(), - memoryUsage: Type.Object({ - rss: Type.Number(), - heapTotal: Type.Number(), - heapUsed: Type.Number(), - external: Type.Number(), - arrayBuffers: Type.Number(), +const ProcessSchema = z.object({ + env: z.string(), + version: z.string(), + uptime: z.number(), + memoryUsage: z.object({ + rss: z.number(), + heapTotal: z.number(), + heapUsed: z.number(), + external: z.number(), + arrayBuffers: z.number(), }), - resourceUsage: Type.Object({ - userCPUTime: Type.Number(), - systemCPUTime: Type.Number(), - maxRSS: Type.Number(), - sharedMemorySize: Type.Number(), - unsharedDataSize: Type.Number(), - unsharedStackSize: Type.Number(), - minorPageFault: Type.Number(), - majorPageFault: Type.Number(), - swappedOut: Type.Number(), - fsRead: Type.Number(), - fsWrite: Type.Number(), - ipcSent: Type.Number(), - ipcReceived: Type.Number(), - signalsCount: Type.Number(), - voluntaryContextSwitches: Type.Number(), - involuntaryContextSwitches: Type.Number(), + resourceUsage: z.object({ + userCPUTime: z.number(), + systemCPUTime: z.number(), + maxRSS: z.number(), + sharedMemorySize: z.number(), + unsharedDataSize: z.number(), + unsharedStackSize: z.number(), + minorPageFault: z.number(), + majorPageFault: z.number(), + swappedOut: z.number(), + fsRead: z.number(), + fsWrite: z.number(), + ipcSent: z.number(), + ipcReceived: z.number(), + signalsCount: z.number(), + voluntaryContextSwitches: z.number(), + involuntaryContextSwitches: z.number(), }), }) -const OSSchema = Type.Object({ - freemem: Type.Number(), - totalmem: Type.Number(), - arch: Type.String(), - homedir: Type.String(), - hostname: Type.String(), - loadavg: Type.Array(Type.Number()), - machine: Type.String(), - platform: Type.String(), - release: Type.String(), - type: Type.String(), - uptime: Type.Number(), - version: Type.String(), +const OSSchema = z.object({ + freemem: z.number(), + totalmem: z.number(), + arch: z.string(), + homedir: z.string(), + hostname: z.string(), + loadavg: z.array(z.number()), + machine: z.string(), + platform: z.string(), + release: z.string(), + type: z.string(), + uptime: z.number(), + version: z.string(), }) -const processPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) => { +const processPlugin: FastifyPluginCallbackZod = (fastify, _options, done) => { fastify.addHook('onRequest', fastify.auth) fastify.route({ @@ -57,7 +58,7 @@ const processPlugin: FastifyPluginCallbackTypebox = (fastify, _options, done) => url: '/', schema: { response: { - 200: Type.Object({ + 200: z.object({ process: ProcessSchema, os: OSSchema, }), diff --git a/server/routes/api/results.ts b/server/routes/api/results.ts index 99edb68..1b8bac8 100644 --- a/server/routes/api/results.ts +++ b/server/routes/api/results.ts @@ -1,8 +1,9 @@ import _ from 'lodash' -import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import * as z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' -const resultRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { +const resultRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { fastify.route({ url: '/', method: 'GET', @@ -69,8 +70,8 @@ const resultRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { url: '/:year', method: 'GET', schema: { - params: Type.Object({ - year: Type.Number(), + params: z.object({ + year: z.number(), }), }, async handler(req) { diff --git a/server/routes/api/roles.test.ts b/server/routes/api/roles.test.ts deleted file mode 100644 index 8331f75..0000000 --- a/server/routes/api/roles.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -// import { test, type TestContext } from 'node:test' -// import Server from '../../server.ts' -// import rolesPlugin from './roles.ts' -// import type { FastifyInstance } from 'fastify' - -// let server: FastifyInstance -// test('roles', (t: TestContext) => { -// t.beforeEach(() => { -// server = Server() -// }) -// }) diff --git a/server/routes/api/roles.ts b/server/routes/api/roles.ts index 0fa45b6..a340c83 100644 --- a/server/routes/api/roles.ts +++ b/server/routes/api/roles.ts @@ -1,151 +1,114 @@ 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' import { RoleSchema } from '../../schemas/db.ts' - -// export const RoleFullSchema = Type.Object({ -// id: Type.Number(), -// name: Type.String(), -// createdAt: Type.String(), -// createdById: Type.Number(), -// modifiedAt: Type.String(), -// modifiedById: Type.Number(), -// }) - -// console.log(RoleFullSchema) - -// export const RoleSchema = Type.Pick(RoleFullSchema, ['id', 'name']) - -// export const RoleVariableSchema = Type.Pick(RoleFullSchema, ['name', 'createdById']) +import { jsonObjectFrom } from 'kysely/helpers/postgres' const rolesPlugin: FastifyPluginCallbackZod = (fastify, _options, done) => { - // const queries = Queries({ knex }) const { db } = fastify - // fastify.addHook('onRequest', fastify.auth) + fastify.addHook('onRequest', fastify.auth) fastify.route({ url: '/', method: 'GET', schema: { - querystring: RoleSchema.partial().extend({ - limit: z.number().optional(), - sort: z.keyof(RoleSchema).optional(), - offset: z.number().optional(), - }), response: { - 200: z.array(RoleSchema), + 200: z.array( + RoleSchema.extend({ + createdBy: z.object({ + id: z.number(), + email: z.string(), + }), + modifiedBy: z + .object({ + id: z.number(), + email: z.string(), + }) + .nullable(), + }), + ), }, }, - handler(request) { - // 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() + handler() { + return db + .selectFrom('role as r') + .selectAll() + .select((eb) => [ + jsonObjectFrom(eb.selectFrom('user as u').select(['u.id', 'u.email']).whereRef('u.id', '=', 'r.createdById')) + .$notNull() + .as('createdBy'), + jsonObjectFrom( + eb.selectFrom('user as u').select(['u.id', 'u.email']).whereRef('u.id', '=', 'r.modifiedById'), + ).as('modifiedBy'), + ]) + .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: RoleSchema.pick({ name: true }), + response: { + 201: RoleSchema, + }, + }, + handler(request) { + return db + .insertInto('role') + .values({ + ...request.body, + createdById: request.session.userId, + }) + .returningAll() + .executeTakeFirstOrThrow() + }, + }) - // 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: z.object({ + id: z.number(), + }), + response: { + 204: {}, + 404: {}, + }, + }, + handler(request) { + return db.deleteFrom('role').where('id', '=', request.params.id).execute() + }, + }) - // 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 - - // return queries.update(request.params.id, patch) - // }, - // }) + fastify.route({ + url: '/:id', + method: 'PATCH', + schema: { + params: z.object({ + id: z.number(), + }), + body: RoleSchema.pick({ name: true }), + response: { + 204: {}, + }, + }, + async handler(request) { + return db + .updateTable('role') + .set({ + ...request.body, + modifiedById: request.session.userId, + }) + .where('id', '=', request.params.id) + .returningAll() + .execute() + }, + }) done() } diff --git a/server/routes/api/suppliers.ts b/server/routes/api/suppliers.ts index 5d2a57b..02bf08b 100644 --- a/server/routes/api/suppliers.ts +++ b/server/routes/api/suppliers.ts @@ -1,6 +1,6 @@ import _ from 'lodash' -import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import z from 'zod' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import { SupplierSchema } from '../../schemas/db.ts' const supplierRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { diff --git a/server/routes/api/transactions.ts b/server/routes/api/transactions.ts index 4cee604..9b4b4d9 100644 --- a/server/routes/api/transactions.ts +++ b/server/routes/api/transactions.ts @@ -1,7 +1,7 @@ import _ from 'lodash' import * as z from 'zod' import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' -import StatusError from '../../lib/status_error.ts' +import { paginate } from '../../lib/kysely_helpers.ts' const transactionRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { const { db } = fastify @@ -13,31 +13,21 @@ const transactionRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { querystring: z.object({ year: z.optional(z.coerce.number()), accountNumber: z.optional(z.coerce.number()), + limit: z.number().default(2), + sort: z.string().default('t.id'), + offset: z.number().optional(), }), }, - async handler(req) { - const query: { financialYearId?: number; accountNumber?: number } = {} + async handler(request) { + const { offset, limit, sort: _sort, ...where } = request.query - if (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 - } - - if (req.query.accountNumber) { - query.accountNumber = req.query.accountNumber - } - - return db + const baseQuery = db .selectFrom('transaction as t') .innerJoin('entry as e', 't.entryId', 'e.id') - .select([ + .innerJoin('financialYear as fy', 'e.financialYearId', 'fy.id') + + const result = await paginate( + baseQuery.select([ 't.accountNumber', 'e.transactionDate', 't.entryId', @@ -45,9 +35,17 @@ const transactionRoutes: FastifyPluginCallbackZod = (fastify, _, done) => { 't.description', 't.invoiceId', 'e.description as entryDescription', - ]) - .where((eb) => eb.and(query)) - .execute() + ]), + baseQuery, + { + // @ts-ignore + where, + limit, + offset, + }, + ) + + return result }, }) diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 452d6c7..fde93d1 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -1,9 +1,9 @@ -import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import type { FastifyPluginCallbackZod } from 'fastify-type-provider-zod' import knex from '../../lib/knex.ts' import emitter from '../../lib/emitter.ts' import Queries from '../../services/users/queries.ts' -const usersPlugin: FastifyPluginCallbackTypebox<{ addParentSchema: (schema: ANY) => void }> = ( +const usersPlugin: FastifyPluginCallbackZod<{ addParentSchema: (schema: ANY) => void }> = ( fastify, { addParentSchema }, done, diff --git a/server/schemas/db.ts b/server/schemas/db.ts index 69cc675..9efbe5c 100644 --- a/server/schemas/db.ts +++ b/server/schemas/db.ts @@ -1,11 +1,62 @@ import { z } from 'zod' +export const AccountSchema = z.object({ + id: z.number().int().optional(), + number: z.int(), + financialYearId: z.number().int(), + description: z.string(), + sru: z.int().nullable().optional(), +}) + +export const AdmissionSchema = z.object({ + id: z.number().int().optional(), + regex: z.string(), + createdAt: z.date().nullable().optional(), + createdById: z.number().int(), + modifiedAt: z.date().nullable().optional(), + modifiedById: z.number().int().nullable().optional(), +}) + +export const AccountBalanceSchema = z.object({ + accountNumber: z.number().int(), + financialYearId: z.number().int(), + in: z.number().optional(), + out: z.number().optional(), + inQuantity: z.number().int().nullable().optional(), + outQuantity: z.number().int().nullable().optional(), +}) + +export const InviteSchema = z.object({ + id: z.number().int().optional(), + email: z.string(), + token: z.string(), + createdAt: z.date().optional(), + createdById: z.number().int().nullable().optional(), + modifiedAt: z.date().nullable().optional(), + modifiedById: z.number().int().nullable().optional(), + consumedAt: z.date().nullable().optional(), + consumedById: z.number().int().nullable().optional(), +}) + +export const InvoiceSchema = z.object({ + id: z.number().int().optional(), + financialYearId: z.number().int().nullable().optional(), + supplierId: z.number().int(), + fiskenNumber: z.number().int().nullable().optional(), + phmNumber: z.number().int().nullable().optional(), + invoiceNumber: z.string().nullable().optional(), + invoiceDate: z.string().nullable().optional(), + dueDate: z.string().nullable().optional(), + ocr: z.string().nullable().optional(), + amount: z.number().nullable().optional(), +}) + export const RoleSchema = z.object({ id: z.number().int().optional(), name: z.string(), - createdAt: z.string().optional(), + createdAt: z.date().optional(), createdById: z.number().int().nullable().optional(), - modifiedAt: z.string().nullable().optional(), + modifiedAt: z.date().nullable().optional(), modifiedById: z.number().int().nullable().optional(), }) diff --git a/server/tests/admissions.test.ts b/server/tests/admissions.test.ts new file mode 100644 index 0000000..8729dd5 --- /dev/null +++ b/server/tests/admissions.test.ts @@ -0,0 +1,27 @@ +import { test, type TestContext } from 'node:test' +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod' + +import admissionPlugin from '../routes/api/admissions.ts' +import fastify from 'fastify' + +test('/api/admissions', async (t: TestContext) => { + const server = fastify() + + server.setValidatorCompiler(validatorCompiler) + server.setSerializerCompiler(serializerCompiler) + + server.decorate('auth', (_request, _reply, done) => done()) + + server.register(admissionPlugin, { prefix: '/api/admissions' }) + + const res = await server.inject({ + method: 'GET', + url: '/api/admissions', + }) + + t.assert.equal(res.statusCode, 200) + + await server.close() + + // TODO verify that roles are inserted and deleted on PATCH +}) diff --git a/shared/global.d.ts b/shared/global.d.ts index a325a95..e1a6389 100644 --- a/shared/global.d.ts +++ b/shared/global.d.ts @@ -23,7 +23,7 @@ declare module 'fastify' { } interface FastifyRequest { - logout: () => void + logout: () => Promise login: (user: ANY) => Promise user: Promise getUser: () => Promise diff --git a/shared/types.db.ts b/shared/types.db.ts index c799be3..05caf51 100644 --- a/shared/types.db.ts +++ b/shared/types.db.ts @@ -133,7 +133,7 @@ export interface Invite { } export interface InvitesRoles { - invitedId: number + inviteId: number roleId: number }