import type express from 'express'; import { asyncErr, KError } from '../helpers'; import { findIgnoredDuplicates, findRedundantPairs, getDuplicatePairScore, } from '../lib/duplicates-manager'; import { Account, DuplicatesIgnored, Setting, Transaction, User } from '../models'; import { DUPLICATE_IGNORE_DIFFERENT_CUSTOM_FIELDS, DUPLICATE_THRESHOLD } from '../shared/settings'; import type { Duplicates } from '../shared/types'; import type { IdentifiedRequest } from './routes'; export async function getDuplicates(req: IdentifiedRequest, res: express.Response) { try { const { id: userId } = req.user; const user = await User.find(userId); if (!user) { throw new KError('Missing user id', 403); } const threshold = await Setting.findOrCreateDefault(userId, DUPLICATE_THRESHOLD); const thresholdValue = parseInt(threshold.value, 10); const ignoreDuplicatesWithDifferentCustomFields = await Setting.findOrCreateDefaultBooleanValue( userId, DUPLICATE_IGNORE_DIFFERENT_CUSTOM_FIELDS ); const ignored = await findIgnoredDuplicates(userId); // The two transactions of a pair always belong to the same account, so the pairs of the // other accounts can't match anything here: no need to filter them out per account. const pairsToIgnore = ignored.flatMap(item => item.duplicates); const allDuplicates: Duplicates = { new: [], ignored, }; const accounts = await Account.all(userId); for (const account of accounts) { const transactions = await Transaction.byAccount(userId, account.id); const duplicates = findRedundantPairs( transactions, thresholdValue, ignoreDuplicatesWithDifferentCustomFields, pairsToIgnore ); if (duplicates.length > 0) { allDuplicates.new.push({ accountId: account.id, duplicates, }); } } res.status(200).json(allDuplicates); } catch (err) { asyncErr(res, err, `when retrieving duplicates`); } } export async function getIgnoredDuplicates(req: IdentifiedRequest, res: express.Response) { try { const { id: userId } = req.user; const user = await User.find(userId); if (!user) { throw new KError('Missing user id', 403); } const ignored = await findIgnoredDuplicates(userId); res.status(200).json(ignored); } catch (err) { asyncErr(res, err, 'when retrieving ignored duplicates'); } } // Reads and validates the pair of transactions referenced in the request's body. async function extractPairFromRequest( userId: number, body: any ): Promise<[transaction: Transaction, otherTransaction: Transaction]> { const { transactionId, otherTransactionId } = body || {}; if (typeof transactionId !== 'number' || typeof otherTransactionId !== 'number') { throw new KError('missing or invalid transaction ids', 400); } if (transactionId === otherTransactionId) { throw new KError('a transaction cannot be a duplicate of itself', 400); } // Make sure both transactions exist and belong to the user. const pair = []; for (const id of [transactionId, otherTransactionId]) { const transaction = await Transaction.find(userId, id); if (transaction === null) { throw new KError(`transaction ${id} not found`, 404); } pair.push(transaction); } if (pair[0].accountId !== pair[1].accountId) { throw new KError( `transaction ${pair[0].id} and ${pair[1].id} don't belong to the same account`, 400 ); } return [pair[0], pair[1]]; } export async function ignoreDuplicate(req: IdentifiedRequest, res: express.Response) { try { const { id: userId } = req.user; const [transaction, otherTransaction] = await extractPairFromRequest(userId, req.body); await DuplicatesIgnored.create(userId, transaction.id, otherTransaction.id); res.status(201).end(); } catch (err) { asyncErr(res, err, 'when ignoring a pair of duplicates'); } } export async function unignoreDuplicate(req: IdentifiedRequest, res: express.Response) { try { const { id: userId } = req.user; const [transaction, otherTransaction] = await extractPairFromRequest(userId, req.body); const deleted = await DuplicatesIgnored.destroy( userId, transaction.id, otherTransaction.id ); let isDuplicate = false; if (deleted) { // Let the client know whether the pair is detected as a duplicate again, so that it can // add it back to the list of duplicates without refetching the whole list. const threshold = await Setting.findOrCreateDefault(userId, DUPLICATE_THRESHOLD); const ignoreDuplicatesWithDifferentCustomFields = await Setting.findOrCreateDefaultBooleanValue( userId, DUPLICATE_IGNORE_DIFFERENT_CUSTOM_FIELDS ); // The threshold setting is in hours, transform it to days. const thresholdInDays = Math.round(parseInt(threshold.value, 10) / 24); isDuplicate = getDuplicatePairScore( transaction, otherTransaction, thresholdInDays, ignoreDuplicatesWithDifferentCustomFields ) > 0; } res.status(200).json({ isDuplicate }); } catch (err) { asyncErr(res, err, 'when unignoring a pair of duplicates'); } }