WIP knex > kysely

This commit is contained in:
Linus Miller 2025-12-18 10:20:02 +01:00
parent 3ff6aa7310
commit 8f6591b679
20 changed files with 349 additions and 192 deletions

View File

@ -1,7 +1,7 @@
meta { meta {
name: /api/journals name: /api/journals
type: http type: http
seq: 20 seq: 21
} }
get { get {
@ -12,4 +12,5 @@ get {
settings { settings {
encodeUrl: true encodeUrl: true
timeout: 0
} }

View File

@ -0,0 +1,20 @@
meta {
name: /api/suppliers/:id
type: http
seq: 17
}
get {
url: {{base_url}}/api/suppliers/:id
body: none
auth: inherit
}
params:path {
id: 105
}
settings {
encodeUrl: true
timeout: 0
}

View File

@ -1,7 +1,7 @@
meta { meta {
name: /api/suppliers/merge name: /api/suppliers/merge
type: http type: http
seq: 17 seq: 18
} }
post { post {

View File

@ -1,7 +1,7 @@
meta { meta {
name: /api/transactions name: /api/transactions
type: http type: http
seq: 18 seq: 19
} }
get { get {

View File

@ -1,7 +1,7 @@
meta { meta {
name: /api/users name: /api/users
type: http type: http
seq: 19 seq: 20
} }
get { get {

View File

@ -1,7 +1,7 @@
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import knex from '../server/lib/knex.ts' import db from '../server/lib/kysely.ts'
const dirs = process.argv.slice(2) const dirs = process.argv.slice(2)
@ -12,7 +12,7 @@ for await (const dir of dirs) {
await readdir(dir) await readdir(dir)
} }
knex.destroy() db.destroy()
async function readdir(dir: string) { async function readdir(dir: string) {
const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => { const files = (await fs.readdir(dir)).toSorted((a: string, b: string) => {
@ -26,7 +26,7 @@ async function readdir(dir: string) {
} }
}) })
const trx = await knex.transaction() const trx = await db.startTransaction().execute()
for await (const originalFilename of files) { for await (const originalFilename of files) {
const result = originalFilename.match(rFileName) const result = originalFilename.match(rFileName)
@ -37,15 +37,21 @@ async function readdir(dir: string) {
const [, fiskenNumber, invoiceDate, supplierName] = result const [, fiskenNumber, invoiceDate, supplierName] = result
let supplier = await trx('supplier').first('*').where('name', supplierName) let supplier = await trx.selectFrom('supplier').selectAll().where('name', '=', supplierName).executeTakeFirst()
if (!supplier) { if (!supplier) {
supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0] supplier = await trx
.insertInto('supplier')
.values({ name: supplierName, supplierTypeId: 1 })
.returningAll()
.executeTakeFirstOrThrow()
} }
const invoice = ( const invoice = await trx
await trx('invoice').insert({ fiskenNumber, invoiceDate, supplierId: supplier.id }).returning('*') .insertInto('invoice')
)[0] .values({ fiskenNumber: parseInt(fiskenNumber), invoiceDate, supplierId: supplier.id })
.returningAll()
.executeTakeFirstOrThrow()
const ext = path.extname(originalFilename) const ext = path.extname(originalFilename)
const filename = `${invoiceDate}_fisken_${fiskenNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}` const filename = `${invoiceDate}_fisken_${fiskenNumber}_${supplierName.split(/[\s/]/).join('_').split(/[']/).join('')}${ext}`
@ -58,9 +64,9 @@ async function readdir(dir: string) {
console.info('ALREADY EXISTS: ' + filename) console.info('ALREADY EXISTS: ' + filename)
} }
const file = (await trx('file').insert({ filename }).returning('id'))[0] const file = await trx.insertInto('file').values({ filename }).returning('id').executeTakeFirstOrThrow()
await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) await trx.insertInto('filesToInvoice').values({ fileId: file.id, invoiceId: invoice.id }).execute()
} }
await trx.commit() await trx.commit().execute()
} }

View File

@ -1,52 +1,56 @@
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import knex from '../server/lib/knex.ts' import db from '../server/lib/kysely.ts'
import { csvParseRows } from 'd3-dsv' import { csvParseRows } from 'd3-dsv'
const csvFilename = process.argv[2] const csvFilename = process.argv[2]
const csvString = await fs.readFile(csvFilename, { encoding: 'utf8' }) const csvString = await fs.readFile(csvFilename, { encoding: 'utf8' })
const rows = csvParseRows(csvString) const rows = csvParseRows(csvString)
const trx = await knex.transaction() const trx = await db.startTransaction().execute()
for (const row of rows.toReversed()) { for (const row of rows.toReversed()) {
const [ const [
phmNumber, phmNumber,
// type, _type,
// supplierId, _supplierId,
supplierName, supplierName,
invoiceDate, invoiceDate,
dueDate, dueDate,
invoiceNumber, invoiceNumber,
ocr, ocr,
amount, amount,
// vat, _vat,
// balance, _balance,
// currency, _currency,
// status, _status,
filesString, filesString,
] = row ] = row
let supplier = await trx('supplier').first('*').where('name', supplierName) let supplier = await trx.selectFrom('supplier').selectAll().where('name', '=', supplierName).executeTakeFirst()
if (!supplier) { if (!supplier) {
supplier = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('*'))[0] supplier = await trx
.insertInto('supplier')
.values({ name: supplierName, supplierTypeId: 1 })
.returningAll()
.executeTakeFirstOrThrow()
} }
const invoice = ( const invoice = await trx
await trx('invoice') .insertInto('invoice')
.insert({ .values({
invoiceDate, invoiceDate,
supplierId: supplier.id, supplierId: supplier.id,
dueDate, dueDate,
ocr, ocr,
invoiceNumber, invoiceNumber,
phmNumber, phmNumber: parseInt(phmNumber),
amount, amount,
}) })
.returning('id') .returning('id')
)[0] .executeTakeFirstOrThrow()
const filenames = filesString.split(',').map((filename) => filename.trim()) const filenames = filesString.split(',').map((filename) => filename.trim())
@ -63,11 +67,11 @@ for (const row of rows.toReversed()) {
console.info('ALREADY EXISTS: ' + filename) console.info('ALREADY EXISTS: ' + filename)
} }
const file = (await trx('file').insert({ filename }).returning('id'))[0] const file = await trx.insertInto('file').values({ filename }).returning('id').executeTakeFirstOrThrow()
await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) await trx.insertInto('filesToInvoice').values({ fileId: file.id, invoiceId: invoice.id }).execute()
} }
} }
trx.commit() await trx.commit().execute()
knex.destroy() db.destroy()

View File

@ -1,6 +1,6 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import parseStream from '../server/lib/parse_stream.ts' import parseStream from '../server/lib/parse_stream.ts'
import knex from '../server/lib/knex.ts' import db from '../server/lib/kysely.ts'
for await (const file of process.argv.slice(2)) { for await (const file of process.argv.slice(2)) {
const fh = await fs.open(file) const fh = await fs.open(file)
@ -12,4 +12,4 @@ for await (const file of process.argv.slice(2)) {
await fh.close() await fh.close()
} }
knex.destroy() db.destroy()

View File

@ -13,12 +13,14 @@ const format = Format.bind(null, null, 'YYYY.MM.DD')
const InvoicesPage: FunctionComponent = () => { const InvoicesPage: FunctionComponent = () => {
const route = useRoute() const route = useRoute()
const [supplier, invoices, totalAmount] = usePromise<[Supplier, (Invoice & { files?: File[] })[], number]>(() => const [supplier, { data: invoices }, totalAmount] = usePromise<
[Supplier, { data: (Invoice & { files?: File[] })[] }, number]
>(() =>
Promise.all([ Promise.all([
rek(`/api/suppliers/${route.params.supplier}`), rek(`/api/suppliers/${route.params.supplier}`),
rek(`/api/invoices?supplier=${route.params.supplier}`), rek(`/api/invoices?supplierId=${route.params.supplier}`),
rek(`/api/invoices/total-amount?supplier=${route.params.supplier}`).then( rek(`/api/invoices/total-amount?supplierId=${route.params.supplier}`).then(
(totalAmount: { amount: number }) => totalAmount.amount, (totalAmount: { totalAmount: number }) => totalAmount.totalAmount,
), ),
]), ]),
) )

View File

@ -12,7 +12,9 @@ import type { Transaction, FinancialYear } from '../../../shared/types.db.ts'
const TransactionsPage: FunctionComponent = () => { const TransactionsPage: FunctionComponent = () => {
const [financialYears, setFinancialYears] = useState<FinancialYear[]>([]) const [financialYears, setFinancialYears] = useState<FinancialYear[]>([])
const [transactions, setTransactions] = useState<(Transaction & { entryDescription: string })[]>([]) const [{ data: transactions }, setTransactions] = useState<{ data: (Transaction & { entryDescription: string })[] }>({
data: [],
})
const location = useLocation() const location = useLocation()

View File

@ -53,6 +53,7 @@ export default read(
'SESSION_SECRET', 'SESSION_SECRET',
] as const, ] as const,
{ {
MAILGUN_API_KEY: 'invalid key',
PGPASSWORD: null, PGPASSWORD: null,
PGPORT: null, PGPORT: null,
PGUSER: null, PGUSER: null,

View File

@ -1,5 +1,6 @@
import type { ReadableStream } from 'stream/web' import type { ReadableStream } from 'stream/web'
import knex from './knex.ts' import type { Selectable } from 'kysely'
import db from './kysely.ts'
import split, { type Decoder } from './split.ts' import split, { type Decoder } from './split.ts'
import { import {
@ -37,25 +38,27 @@ const defaultDecoder = {
}, },
} }
import type { Entry, FinancialYear } from '../../shared/types.db.ts'
export default async function parseStream(stream: ReadableStream, decoder: Decoder = defaultDecoder) { export default async function parseStream(stream: ReadableStream, decoder: Decoder = defaultDecoder) {
const journals = new Map() const journals = new Map()
let currentEntry: { id: number; description: string } let currentEntry: Pick<Selectable<Entry>, 'id' | 'description'>
let currentInvoiceId: number | null let currentInvoiceId: number | null | undefined
let currentYear = null let currentYear: Selectable<FinancialYear>
const details: Record<string, string> = {} const details: Record<string, string> = {}
const trx = await knex.transaction() const trx = await db.startTransaction().execute()
async function getJournalId(identifier: string) { async function getJournalId(identifier: string) {
if (journals.has(identifier)) { if (journals.has(identifier)) {
return journals.get(identifier) return journals.get(identifier)
} }
let journal = await trx('journal').first('*').where('identifier', identifier) let journal = await trx.selectFrom('journal').selectAll().where('identifier', '=', identifier).executeTakeFirst()
if (!journal) { if (!journal) {
journal = (await trx('journal').insert({ identifier }).returning('*'))[0] journal = await trx.insertInto('journal').values({ identifier }).returningAll().executeTakeFirstOrThrow()
} }
journals.set(identifier, journal.id) journals.set(identifier, journal.id)
@ -77,12 +80,16 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
case '#DIM': { case '#DIM': {
const { number, name } = parseDim(line) const { number, name } = parseDim(line)
const existingDimension = await trx('dimension').first('*').where('number', number) const existingDimension = await db
.selectFrom('dimension')
.selectAll()
.where('number', '=', number)
.executeTakeFirst()
if (!existingDimension) { if (!existingDimension) {
await trx('dimension').insert({ number, name }) await trx.insertInto('dimension').values({ number, name }).execute()
} else if (existingDimension.name !== name) { } else if (existingDimension.name !== name) {
await trx('dimension').update({ name }).where('number', number) await trx.updateTable('dimension').set({ name }).where('number', '=', number).execute()
} }
break break
@ -92,27 +99,36 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
if (yearNumber !== 0) continue if (yearNumber !== 0) continue
const existingAccountBalance = await trx('accountBalance') const existingAccountBalance = await trx
.first('*') .selectFrom('accountBalance')
.where({ financialYearId: currentYear.id, accountNumber }) .selectAll()
.where((eb) => eb.and({ financialYearId: currentYear!.id, accountNumber }))
.executeTakeFirst()
if (!existingAccountBalance) { if (!existingAccountBalance) {
await trx('accountBalance').insert({ await trx
financialYearId: currentYear.id, .insertInto('accountBalance')
.values({
financialYearId: currentYear!.id,
accountNumber, accountNumber,
in: balance, in: balance,
inQuantity: quantity, inQuantity: quantity,
}) })
.execute()
} else { } else {
await trx('accountBalance') await trx
.update({ .updateTable('accountBalance')
.set({
in: balance, in: balance,
inQuantity: quantity, inQuantity: quantity,
}) })
.where({ .where((eb) =>
financialYearId: currentYear.id, eb.and({
financialYearId: currentYear!.id,
accountNumber, accountNumber,
}) }),
)
.execute()
} }
break break
@ -120,19 +136,24 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
case '#KONTO': { case '#KONTO': {
const { number, description } = parseKonto(line) const { number, description } = parseKonto(line)
const existingAccount = await trx('account') const existingAccount = await trx
.first('*') .selectFrom('account')
.where('number', number) .selectAll()
.where('number', '=', number)
.orderBy('financialYearId', 'desc') .orderBy('financialYearId', 'desc')
.executeTakeFirst()
if (!existingAccount) { if (!existingAccount) {
await trx('account').insert({ await trx
.insertInto('account')
.values({
financialYearId: currentYear!.id, financialYearId: currentYear!.id,
number, number,
description, description,
}) })
.execute()
} else if (existingAccount.description !== description) { } else if (existingAccount.description !== description) {
await trx('account').update({ description }).where('id', existingAccount.id) await trx.updateTable('account').set({ description }).where('id', '=', existingAccount.id).execute()
} }
break break
@ -140,16 +161,28 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
case '#OBJEKT': { case '#OBJEKT': {
const { dimensionNumber, number, name } = parseObjekt(line) const { dimensionNumber, number, name } = parseObjekt(line)
const dimension = await trx('dimension').first('*').where('number', dimensionNumber) const dimension = await trx
.selectFrom('dimension')
.selectAll()
.where('number', '=', dimensionNumber)
.executeTakeFirst()
if (!dimension) throw new Error(`Dimension "${dimensionNumber}" does not exist`) if (!dimension) throw new Error(`Dimension "${dimensionNumber}" does not exist`)
const existingObject = await trx('object').first('*').where({ dimensionId: dimension.id, number }) const existingObject = await trx
.selectFrom('object')
.selectAll()
.where((eb) => eb.and({ dimensionId: dimension.id, number }))
.executeTakeFirst()
if (!existingObject) { if (!existingObject) {
await trx('object').insert({ dimensionId: dimension.id, number, name }) await trx.insertInto('object').values({ dimensionId: dimension.id, number, name }).execute()
} else if (existingObject.name !== name) { } else if (existingObject.name !== name) {
await trx('object').update({ name }).where({ dimensionId: dimension.id, number }) await trx
.updateTable('object')
.set({ name })
.where((eb) => eb.and({ dimensionId: dimension.id, number }))
.execute()
} }
break break
@ -160,9 +193,11 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
if (yearNumber !== 0) continue if (yearNumber !== 0) continue
currentYear = ( currentYear = (
await trx('financialYear') await trx
.insert({ year: startDate.slice(0, 4), startDate, endDate }) .insertInto('financialYear')
.returning('*') .values({ year: parseInt(startDate.slice(0, 4)), startDate, endDate })
.returningAll()
.execute()
)[0] )[0]
break break
@ -170,22 +205,27 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
case '#SRU': { case '#SRU': {
const { number, sru } = parseSRU(line) const { number, sru } = parseSRU(line)
const existingAccount = await trx('account') const existingAccount = await trx
.first('*') .selectFrom('account')
.where('number', number) .selectAll()
.where('number', '=', number)
.orderBy('financialYearId', 'desc') .orderBy('financialYearId', 'desc')
.executeTakeFirst()
if (existingAccount) { if (existingAccount) {
if (existingAccount.sru !== sru) { if (existingAccount.sru !== sru) {
await trx('account').update({ sru: sru }).where('id', existingAccount.id) await trx.updateTable('account').set({ sru: sru }).where('id', '=', existingAccount.id).execute()
} }
} else { } else {
await trx('account').insert({ await trx
.insertInto('account')
.values({
financialYearId: currentYear!.id, financialYearId: currentYear!.id,
number, number,
description: existingAccount.description, description: existingAccount!.description,
sru, sru,
}) })
.execute()
} }
break break
@ -198,67 +238,95 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const result = transaction.description?.match(rFisken) const result = transaction.description?.match(rFisken)
let invoiceId: number let invoiceId: number | null | undefined
if (result) { if (result) {
const [, fiskenNumber, supplierName] = result const [, fiskenNumber, supplierName] = result
// invoiceId = (await trx('invoice').first('id').where({ fiskenNumber})).id // invoiceId = (await trx('invoice').first('id').where({ fiskenNumber})).id
invoiceId = (await trx('invoice').first('id').where({ fiskenNumber }))?.id invoiceId = (
await trx
.selectFrom('invoice')
.select('id')
.where('fiskenNumber', '=', parseInt(fiskenNumber))
.executeTakeFirst()
)?.id
if (!invoiceId) { if (!invoiceId) {
let supplierId = (await trx('supplier').first('id').where('name', supplierName))?.id let supplierId = (
await trx.selectFrom('supplier').select('id').where('name', '=', supplierName).executeTakeFirst()
)?.id
if (!supplierId) { if (!supplierId) {
supplierId = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('id'))[0] supplierId = (
?.id await trx
.insertInto('supplier')
.values({ name: supplierName, supplierTypeId: 1 })
.returning('id')
.executeTakeFirstOrThrow()
).id
} }
invoiceId = ( invoiceId = (
await trx('invoice') await trx
.insert({ financialYearId: currentYear!.id, fiskenNumber, supplierId }) .insertInto('invoice')
.values({ financialYearId: currentYear!.id, fiskenNumber: parseInt(fiskenNumber), supplierId })
.returning('id') .returning('id')
)[0]?.id .executeTakeFirstOrThrow()
).id
} }
if (transaction.accountNumber === 2441 && currentEntry!.description?.includes('Fakturajournal')) { if (transaction.accountNumber === 2441 && currentEntry!.description?.includes('Fakturajournal')) {
await trx('invoice').update('amount', Math.abs(transaction.amount)).where('id', invoiceId) await trx
.updateTable('invoice')
.set('amount', Math.abs(transaction.amount))
.where('id', '=', invoiceId!)
.execute()
} }
} }
if (invoiceId! && currentInvoiceId!) { if (invoiceId && currentInvoiceId) {
throw new Error('invoiceId and currentInvoiceId') throw new Error('invoiceId and currentInvoiceId')
} }
const transactionId = ( const transactionId = (
await trx('transaction') await trx
.insert({ .insertInto('transaction')
.values({
entryId: currentEntry!.id, entryId: currentEntry!.id,
...transaction, ...transaction,
invoiceId: invoiceId! || currentInvoiceId!, invoiceId: invoiceId! || currentInvoiceId!,
}) })
.returning('id') .returning('id')
)[0].id .executeTakeFirstOrThrow()
).id
if (objectList) { if (objectList) {
for (const [dimensionNumber, objectNumber] of objectList) { for (const [dimensionNumber, objectNumber] of objectList) {
const objectId = ( const objectId = (
await trx('object') await trx
.first('object.id') .selectFrom('object')
.innerJoin('dimension', 'object.dimensionId', 'dimension.id') .innerJoin('dimension', 'object.dimensionId', 'dimension.id')
.where({ .select('object.id')
.where((eb) =>
eb.and({
'object.number': objectNumber, 'object.number': objectNumber,
'dimension.number': dimensionNumber, 'dimension.number': dimensionNumber,
}) }),
)
.executeTakeFirst()
)?.id )?.id
if (!objectId) { if (!objectId) {
throw new Error(`Object {${dimensionNumber} ${objectNumber}} does not exist!`) throw new Error(`Object {${dimensionNumber} ${objectNumber}} does not exist!`)
} }
await trx('transactionsToObjects').insert({ await trx
.insertInto('transactionsToObjects')
.values({
transactionId, transactionId,
objectId, objectId,
}) })
.execute()
} }
} }
@ -269,27 +337,36 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
if (yearNumber !== 0) continue if (yearNumber !== 0) continue
const existingAccountBalance = await trx('accountBalance') const existingAccountBalance = await trx
.first('*') .selectFrom('accountBalance')
.where({ financialYearId: currentYear!.id, accountNumber }) .selectAll()
.where((eb) => eb.and({ financialYearId: currentYear!.id, accountNumber }))
.executeTakeFirst()
if (!existingAccountBalance) { if (!existingAccountBalance) {
await trx('accountBalance').insert({ await trx
.insertInto('accountBalance')
.values({
financialYearId: currentYear!.id, financialYearId: currentYear!.id,
accountNumber, accountNumber,
out: balance, out: balance,
outQuantity: quantity, outQuantity: quantity,
}) })
.execute()
} else { } else {
await trx('accountBalance') await trx
.update({ .updateTable('accountBalance')
.set({
out: balance, out: balance,
outQuantity: quantity, outQuantity: quantity,
}) })
.where({ .where((eb) =>
eb.and({
financialYearId: currentYear!.id, financialYearId: currentYear!.id,
accountNumber, accountNumber,
}) }),
)
.execute()
} }
break break
@ -310,24 +387,27 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
if (result) { if (result) {
currentInvoiceId = ( currentInvoiceId = (
await trx('invoice') await trx
.select('invoice.*', 'supplier.name') .selectFrom('invoice')
.innerJoin('supplier', 'invoice.supplierId', 'supplier.id') .innerJoin('supplier', 'invoice.supplierId', 'supplier.id')
.where('phmNumber', result[1]) .selectAll('invoice')
)[0]?.id .select('supplier.name')
.where('phmNumber', '=', parseInt(result[1]))
.executeTakeFirst()
)?.id
} else { } else {
currentInvoiceId = null currentInvoiceId = null
} }
currentEntry = ( currentEntry = await trx
await trx('entry') .insertInto('entry')
.insert({ .values({
journalId, journalId,
financialYearId: currentYear!.id, financialYearId: currentYear!.id,
...rest, ...rest,
}) })
.returning(['id', 'description']) .returning(['id', 'description'])
)[0] .executeTakeFirstOrThrow()
break break
} }
@ -336,10 +416,10 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
} }
} }
await trx.commit() await trx.commit().execute()
// oxlint-disable-next-line no-console // oxlint-disable-next-line no-console
console.dir(details) console.dir(details)
console.info(`DONE!: ${currentYear.startDate} - ${currentYear.endDate}`) console.info(`DONE!: ${currentYear!.startDate} - ${currentYear!.endDate}`)
} }

View File

@ -1,7 +1,7 @@
import type { RouteHandler } from 'fastify' import type { RouteHandler } from 'fastify'
import * as z from 'zod' import * as z from 'zod'
import { sql } from 'kysely'
import config from '../../../config.ts' import config from '../../../config.ts'
import knex from '../../../lib/knex.ts'
import StatusError from '../../../lib/status_error.ts' import StatusError from '../../../lib/status_error.ts'
import { hashPassword } from '../helpers.ts' import { hashPassword } from '../helpers.ts'
@ -23,32 +23,38 @@ const changePassword: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async
request, request,
reply, reply,
) { ) {
const { db } = request.server
try { try {
if (!request.body.email || !request.body.password || !request.body.token) { if (!request.body.email || !request.body.password || !request.body.token) {
throw new StatusError(...errors.missingParameters) throw new StatusError(...errors.missingParameters)
} }
const [token, latestToken] = await Promise.all([ const [token, latestToken] = await Promise.all([
knex('passwordToken as pt') db
.first('pt.id', 'u.id as userId', 'u.email', 'pt.token', 'pt.createdAt', 'pt.cancelledAt', 'pt.consumedAt') .selectFrom('passwordToken as pt')
.innerJoin('user as u', 'pt.userId', 'u.id') .innerJoin('user as u', 'pt.userId', 'u.id')
.where({ .select(['pt.id', 'u.id as userId', 'u.email', 'pt.token', 'pt.createdAt', 'pt.cancelledAt', 'pt.consumedAt'])
.where((eb) =>
eb.and({
'u.email': request.body.email, 'u.email': request.body.email,
'pt.token': request.body.token, 'pt.token': request.body.token,
}), }),
knex('passwordToken as pt') )
.first('pt.id') .executeTakeFirst(),
db
.selectFrom('passwordToken as pt')
.innerJoin('user as u', 'pt.userId', 'u.id') .innerJoin('user as u', 'pt.userId', 'u.id')
.where({ .select('pt.id')
'u.email': request.body.email, .where('u.email', '=', request.body.email)
})
.orderBy('pt.createdAt', 'desc') .orderBy('pt.createdAt', 'desc')
.limit(1), .limit(1)
.executeTakeFirst(),
]) ])
if (!token) { if (!token) {
throw new StatusError(...errors.tokenNotFound) throw new StatusError(...errors.tokenNotFound)
} else if (token.id !== latestToken.id) { } else if (token.id !== latestToken!.id) {
throw new StatusError(...errors.tokenNotLatest) throw new StatusError(...errors.tokenNotLatest)
} else if (token.consumedAt) { } else if (token.consumedAt) {
throw new StatusError(...errors.tokenConsumed) throw new StatusError(...errors.tokenConsumed)
@ -58,10 +64,14 @@ const changePassword: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async
const hash = await hashPassword(request.body.password) const hash = await hashPassword(request.body.password)
await knex.transaction((trx) => await db.transaction().execute((trx) =>
Promise.all([ Promise.all([
trx('user').update({ password: hash }).where('id', token.userId), trx.updateTable('user').set({ password: hash }).where('id', '=', token.userId).execute(),
trx('password_token').update({ consumedAt: knex.fn.now() }).where('id', token.id), trx
.updateTable('passwordToken')
.set({ consumedAt: sql`now()` })
.where('id', '=', token.id)
.execute(),
]), ]),
) )

View File

@ -1,15 +1,12 @@
import _ from 'lodash' import _ from 'lodash'
import * as z from 'zod'
import type { RouteHandler } from 'fastify' import type { RouteHandler } from 'fastify'
import * as z from 'zod'
import { sql } from 'kysely'
import config from '../../../config.ts' import config from '../../../config.ts'
import UserQueries from '../../../services/users/queries.ts'
import emitter from '../../../lib/emitter.ts'
import knex from '../../../lib/knex.ts'
import StatusError from '../../../lib/status_error.ts' import StatusError from '../../../lib/status_error.ts'
import { StatusErrorSchema } from '../../../schemas/status_error.ts' import { StatusErrorSchema } from '../../../schemas/status_error.ts'
import { verifyPassword } from '../helpers.ts' import { verifyPassword } from '../helpers.ts'
const userQueries = UserQueries({ knex, emitter })
const { errors, maxLoginAttempts, requireVerification } = config.auth const { errors, maxLoginAttempts, requireVerification } = config.auth
const BodySchema = z.object({ const BodySchema = z.object({
@ -32,21 +29,27 @@ const ResponseSchema = {
} }
const login: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) { const login: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) {
const { db } = request.server
try { try {
const user = await userQueries.findOne({ email: request.body.email }) const user = await db.selectFrom('user').selectAll().where('email', '=', request.body.email).executeTakeFirst()
if (!user) { if (!user) {
throw new StatusError(...errors.noUserFound) throw new StatusError(...errors.noUserFound)
} else if (!user.password) { } else if (!user.password) {
throw new StatusError(...errors.notLocal) throw new StatusError(...errors.notLocal)
} else if (user.loginAttempts >= maxLoginAttempts) { } else if (user.loginAttempts && user.loginAttempts >= maxLoginAttempts) {
throw new StatusError(...errors.tooManyLoginAttempts) throw new StatusError(...errors.tooManyLoginAttempts)
} }
const result = await verifyPassword(request.body.password, user.password) const result = await verifyPassword(request.body.password, user.password)
if (!result) { if (!result) {
await userQueries.onLoginAttempt(user.id) await db
.updateTable('user')
.set({ lastLoginAttemptAt: sql`now()`, loginAttempts: sql`"loginAttempts" + 1` })
.where('id', '=', user.id)
.execute()
throw new StatusError(...errors.wrongPassword) throw new StatusError(...errors.wrongPassword)
} }
@ -65,7 +68,11 @@ const login: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function
await request.login(user) await request.login(user)
await userQueries.onLogin(user.id) await db
.updateTable('user')
.set({ lastLoginAt: sql`now()`, loginAttempts: 0, lastLoginAttemptAt: null })
.where('id', '=', user.id)
.execute()
return reply.status(200).send(_.omit(user, 'password')) return reply.status(200).send(_.omit(user, 'password'))
} catch (err) { } catch (err) {

View File

@ -1,7 +1,6 @@
import type { RouteHandler } from 'fastify' import type { RouteHandler } from 'fastify'
import * as z from 'zod' import * as z from 'zod'
import config from '../../../config.ts' import config from '../../../config.ts'
import knex from '../../../lib/knex.ts'
import sendMail from '../../../lib/send_mail.ts' import sendMail from '../../../lib/send_mail.ts'
import StatusError from '../../../lib/status_error.ts' import StatusError from '../../../lib/status_error.ts'
import { StatusErrorSchema } from '../../../schemas/status_error.ts' import { StatusErrorSchema } from '../../../schemas/status_error.ts'
@ -21,16 +20,18 @@ const ResponseSchema = {
} }
const forgotPassword: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) { const forgotPassword: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) {
const { db } = request.server
const { email } = request.body const { email } = request.body
try { try {
const user = await knex('user').first('id').where({ email }) const user = await db.selectFrom('user').select('id').where('email', '=', email).executeTakeFirst()
if (!user) throw new StatusError(...errors.noUserFound) if (!user) throw new StatusError(...errors.noUserFound)
const token = generateToken() const token = generateToken()
await knex('password_token').insert({ userId: user.id, token }) await db.insertInto('passwordToken').values({ userId: user.id, token }).execute()
const link = `${new URL('/admin/change-password', config.site.url)}?email=${encodeURI(email)}&token=${token}` const link = `${new URL('/admin/change-password', config.site.url)}?email=${encodeURI(email)}&token=${token}`

View File

@ -1,9 +1,9 @@
import type { RouteHandler } from 'fastify' import type { RouteHandler } from 'fastify'
import * as z from 'zod' import * as z from 'zod'
import config from '../../../config.ts' import config from '../../../config.ts'
import knex from '../../../lib/knex.ts'
import StatusError from '../../../lib/status_error.ts' import StatusError from '../../../lib/status_error.ts'
import { StatusErrorSchema } from '../../../schemas/status_error.ts' import { StatusErrorSchema } from '../../../schemas/status_error.ts'
import { sql } from 'kysely'
const { errors, timeouts } = config.auth const { errors, timeouts } = config.auth
@ -18,23 +18,37 @@ const ResponseSchema = {
} }
const verifyEmail: RouteHandler<{ Querystring: z.infer<typeof QuerystringSchema> }> = async function (request, reply) { const verifyEmail: RouteHandler<{ Querystring: z.infer<typeof QuerystringSchema> }> = async function (request, reply) {
const { db } = request.server
try { try {
const { token, email } = request.query const { token, email } = request.query
const emailToken = await knex('email_token').first('*').where({ email, token }) const emailToken = await db
.selectFrom('emailToken')
.selectAll()
.where((eb) => eb.and({ email, token }))
.executeTakeFirst()
if (!emailToken) { if (!emailToken) {
throw new StatusError(...errors.tokenNotFound) throw new StatusError(...errors.tokenNotFound)
} else if (emailToken.consumed_at) { } else if (emailToken.consumedAt) {
throw new StatusError(...errors.tokenConsumed) throw new StatusError(...errors.tokenConsumed)
} else if (Date.now() > emailToken.created_at.getTime() + timeouts.verifyEmail) { } else if (Date.now() > emailToken.createdAt.getTime() + timeouts.verifyEmail) {
throw new StatusError(...errors.tokenExpired) throw new StatusError(...errors.tokenExpired)
} }
await knex.transaction((trx) => await db.transaction().execute((trx) =>
Promise.all([ Promise.all([
trx('user').update({ email_verified_at: knex.fn.now() }).where('id', emailToken.user_id), trx
trx('email_token').update({ consumed_at: knex.fn.now() }).where('id', emailToken.id), .updateTable('user')
.set({ emailVerifiedAt: sql`now()` })
.where('id', '=', emailToken.userId)
.execute(),
trx
.updateTable('emailToken')
.set({ consumedAt: sql`now()` })
.where('id', '=', emailToken.id)
.execute(),
]), ]),
) )

View File

@ -69,7 +69,7 @@ const entryRoutes: FastifyPluginCallbackZod = (fastify, _, done) => {
.groupBy(['e.id', 'j.identifier']) .groupBy(['e.id', 'j.identifier'])
.where('e.id', '=', req.params.id) .where('e.id', '=', req.params.id)
.where('t.amount', '>', 0 as ANY) .where('t.amount', '>', 0 as ANY)
.execute() .executeTakeFirst()
}, },
}) })

View File

@ -55,7 +55,7 @@ const invoiceRoutes: FastifyPluginCallbackZod = (fastify, _, done) => {
method: 'GET', method: 'GET',
schema: { schema: {
querystring: z.object({ querystring: z.object({
supplierId: z.number().optional(), supplierId: z.coerce.number().optional(),
}), }),
response: { response: {
200: z.object({ 200: z.object({

View File

@ -23,7 +23,7 @@ const supplierRoutes: FastifyPluginCallbackZod = (fastify, _, done) => {
url: '/:id', url: '/:id',
method: 'GET', method: 'GET',
schema: { schema: {
params: z.object({ id: z.number() }), params: z.object({ id: z.coerce.number() }),
response: { response: {
200: SupplierSchema, 200: SupplierSchema,
}, },

View File

@ -0,0 +1,9 @@
export type Balance = {
accountNumber: string
description: string
} & Record<number, number>
export type Result = {
accountNumber: number
description?: string
} & Record<number, number>