UNPKG

@accounter/server

Version:
507 lines (464 loc) • 15.1 kB
import DataLoader from 'dataloader'; import { Injectable, Scope } from 'graphql-modules'; import { sql, type IDatabaseConnection } from '@pgtyped/runtime'; import { reassureOwnerIdExists } from '../../../shared/helpers/index.js'; import type { Optional, TimelessDateString } from '../../../shared/types/index.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import type { IDeleteDocumentParams, IDeleteDocumentQuery, IGetAllDocumentsParams, IGetAllDocumentsQuery, IGetAllDocumentsResult, IGetDocumentsByBusinessIdsQuery, IGetDocumentsByChargeIdQuery, IGetDocumentsByExtendedFiltersParams, IGetDocumentsByExtendedFiltersQuery, IGetDocumentsByFiltersParams, IGetDocumentsByFiltersQuery, IGetDocumentsByHashesQuery, IGetDocumentsByIdsQuery, IGetDocumentsByMissingRequiredInfoQuery, IInsertDocumentsParams, IInsertDocumentsQuery, IReplaceDocumentsChargeIdParams, IReplaceDocumentsChargeIdQuery, IUpdateDocumentParams, IUpdateDocumentQuery, } from '../types.js'; const getAllDocuments = sql<IGetAllDocumentsQuery>` SELECT * FROM accounter_schema.documents ORDER BY created_at DESC; `; const getDocumentsByChargeId = sql<IGetDocumentsByChargeIdQuery>` SELECT * FROM accounter_schema.documents WHERE charge_id in $$chargeIds ORDER BY created_at DESC; `; const getDocumentsByIds = sql<IGetDocumentsByIdsQuery>` SELECT * FROM accounter_schema.documents WHERE id IN $$Ids; `; const getDocumentsByBusinessIds = sql<IGetDocumentsByBusinessIdsQuery>` SELECT * FROM accounter_schema.documents WHERE debtor_id IN $$Ids OR creditor_id IN $$Ids; `; const getDocumentsByHashes = sql<IGetDocumentsByHashesQuery>` SELECT * FROM accounter_schema.documents WHERE file_hash IN $$hashes; `; const getDocumentsByMissingRequiredInfo = sql<IGetDocumentsByMissingRequiredInfoQuery>` SELECT * FROM accounter_schema.documents WHERE charge_id IS NOT NULL AND (image_url IS NULL AND file_url IS NULL) -- missing link OR (type <> 'OTHER' AND (serial_number IS NULL OR date IS NULL OR total_amount IS NULL OR currency_code IS NULL OR vat_amount IS NULL OR debtor_id IS NULL OR creditor_id IS NULL)); `; const updateDocument = sql<IUpdateDocumentQuery>` UPDATE accounter_schema.documents SET charge_id = CASE WHEN $chargeId='00000000-0000-0000-0000-000000000000' THEN NULL ELSE COALESCE( $chargeId::UUID, charge_id, NULL ) END, currency_code = COALESCE( $currencyCode, currency_code, NULL ), date = COALESCE( $date, date, NULL ), file_url = COALESCE( $fileUrl, file_url, NULL ), id = COALESCE( id, id, NULL ), image_url = COALESCE( $imageUrl, image_url, NULL ), updated_at = NOW(), serial_number = COALESCE( $serialNumber, serial_number, NULL ), total_amount = COALESCE( $totalAmount, total_amount, NULL ), type = COALESCE( $type, type, NULL ), vat_amount = COALESCE( $vatAmount, vat_amount, NULL ), is_reviewed = COALESCE( $isReviewed, is_reviewed ), creditor_id = COALESCE( $creditorId, creditor_id ), debtor_id = COALESCE( $debtorId, debtor_id ), no_vat_amount = COALESCE( $noVatAmount, no_vat_amount ), vat_report_date_override = COALESCE( $vatReportDateOverride, vat_report_date_override ), allocation_number = COALESCE( $allocationNumber, allocation_number ), exchange_rate_override = COALESCE( $exchangeRateOverride, exchange_rate_override ), description = COALESCE( $description, description ), remarks = COALESCE( $remarks, remarks ) WHERE id = $documentId RETURNING *; `; const deleteDocument = sql<IDeleteDocumentQuery>` DELETE FROM accounter_schema.documents WHERE id = $documentId RETURNING id; `; const insertDocuments = sql<IInsertDocumentsQuery>` INSERT INTO accounter_schema.documents ( image_url, file_url, type, serial_number, date, total_amount, currency_code, vat_amount, charge_id, vat_report_date_override, no_vat_amount, creditor_id, debtor_id, allocation_number, exchange_rate_override, file_hash, description, remarks, owner_id ) VALUES $$documents( image, file, documentType, serialNumber, date, amount, currencyCode, vat, chargeId, vatReportDateOverride, noVatAmount, creditorId, debtorId, allocationNumber, exchangeRateOverride, fileHash, description, remarks, ownerId ) RETURNING *;`; const getDocumentsByFilters = sql<IGetDocumentsByFiltersQuery>` SELECT * FROM accounter_schema.documents WHERE ($isIDs = 0 OR id IN $$IDs) AND ($fromVatDate ::TEXT IS NULL OR COALESCE(vat_report_date_override ,date)::TEXT::DATE >= date_trunc('day', $fromVatDate ::DATE)) AND ($toVatDate ::TEXT IS NULL OR COALESCE(vat_report_date_override ,date)::TEXT::DATE <= date_trunc('day', $toVatDate ::DATE)) AND ($isBusinessIDs = 0 OR debtor_id IN $$businessIDs OR creditor_id IN $$businessIDs) ORDER BY created_at DESC; `; const getDocumentsByExtendedFilters = sql<IGetDocumentsByExtendedFiltersQuery>` SELECT * FROM accounter_schema.documents WHERE ($isIDs = 0 OR id IN $$IDs) AND ($fromVatDate ::TEXT IS NULL OR COALESCE(vat_report_date_override ,date)::TEXT::DATE >= date_trunc('day', $fromVatDate ::DATE)) AND ($toVatDate ::TEXT IS NULL OR COALESCE(vat_report_date_override ,date)::TEXT::DATE <= date_trunc('day', $toVatDate ::DATE)) AND ($fromDate ::TEXT IS NULL OR date::TEXT::DATE >= date_trunc('day', $fromDate ::DATE)) AND ($toDate ::TEXT IS NULL OR date::TEXT::DATE <= date_trunc('day', $toDate ::DATE)) AND ($isBusinessIDs = 0 OR debtor_id IN $$businessIDs OR creditor_id IN $$businessIDs) AND ($isOwnerIDs = 0 OR owner_id IN $$ownerIDs) AND ($isUnmatched = 0 OR NOT EXISTS ( SELECT 1 FROM accounter_schema.transactions t WHERE t.charge_id = charge_id )) ORDER BY created_at DESC; `; type IGetAdjustedDocumentsByFiltersParams = Optional< Omit<IGetDocumentsByFiltersParams, 'isIDs'>, 'IDs' | 'businessIDs' >; type IGetAdjustedDocumentsByExtendedFiltersParams = Optional< Omit<IGetDocumentsByExtendedFiltersParams, 'isIDs' | 'fromDate' | 'toDate' | 'isUnmatched'>, 'IDs' | 'businessIDs' | 'ownerIDs' > & { fromDate?: TimelessDateString | null; toDate?: TimelessDateString | null; unmatched?: boolean | null; }; const replaceDocumentsChargeId = sql<IReplaceDocumentsChargeIdQuery>` UPDATE accounter_schema.documents SET charge_id = $assertChargeID WHERE charge_id = $replaceChargeID RETURNING id; `; @Injectable({ scope: Scope.Operation, global: true, }) export class DocumentsProvider { constructor( private db: TenantAwareDBClient, private adminContextProvider: AdminContextProvider, ) {} private allDocumentsCache: Promise<IGetAllDocumentsResult[]> | null = null; public async getAllDocuments(params: IGetAllDocumentsParams) { if (this.allDocumentsCache) { return this.allDocumentsCache; } this.allDocumentsCache = getAllDocuments.run(params, this.db).then(docs => { docs.map(doc => { this.getDocumentsByIdLoader.prime(doc.id, doc); }); return docs; }); return this.allDocumentsCache; } private async batchDocumentsByChargeIds(chargeIds: readonly string[]) { const uniqueIDs = [...new Set(chargeIds)]; try { const docs = await getDocumentsByChargeId.run({ chargeIds: uniqueIDs }, this.db); return chargeIds.map(id => docs.filter(doc => { this.getDocumentsByIdLoader.prime(doc.id, doc); return doc.charge_id === id; }), ); } catch (e) { console.error(e); return chargeIds.map(() => []); } } public getDocumentsByChargeIdLoader = new DataLoader((keys: readonly string[]) => this.batchDocumentsByChargeIds(keys), ); public getDocumentsByFilters(params: IGetAdjustedDocumentsByFiltersParams) { const isIDs = !!params?.IDs?.filter(Boolean).length; const isBusinessIDs = !!params?.businessIDs?.filter(Boolean).length; const fullParams: IGetDocumentsByFiltersParams = { isIDs: isIDs ? 1 : 0, isBusinessIDs: isBusinessIDs ? 1 : 0, fromVatDate: null, toVatDate: null, ...params, IDs: isIDs ? params.IDs! : [null], businessIDs: isBusinessIDs ? params.businessIDs! : [null], }; return getDocumentsByFilters.run(fullParams, this.db).then(docs => { docs.map(doc => { this.getDocumentsByIdLoader.prime(doc.id, doc); }); return docs; }); } public getDocumentsByExtendedFilters(params: IGetAdjustedDocumentsByExtendedFiltersParams) { const isIDs = !!params?.IDs?.filter(Boolean).length; const isBusinessIDs = !!params?.businessIDs?.filter(Boolean).length; const isOwnerIDs = !!params?.ownerIDs?.filter(Boolean).length; const fullParams: IGetDocumentsByExtendedFiltersParams = { isIDs: isIDs ? 1 : 0, isBusinessIDs: isBusinessIDs ? 1 : 0, isOwnerIDs: isOwnerIDs ? 1 : 0, fromVatDate: null, toVatDate: null, ...params, isUnmatched: params.unmatched ? 1 : 0, IDs: isIDs ? params.IDs! : [null], businessIDs: isBusinessIDs ? params.businessIDs! : [null], ownerIDs: isOwnerIDs ? params.ownerIDs! : [null], }; return getDocumentsByExtendedFilters.run(fullParams, this.db); } private async batchDocumentsByIds(ids: readonly string[]) { const uniqueIDs = [...new Set(ids)]; try { const docs = await getDocumentsByIds.run({ Ids: uniqueIDs }, this.db); return ids.map(id => docs.find(doc => doc.id === id)); } catch (e) { console.error(e); return ids.map(() => null); } } public getDocumentsByIdLoader = new DataLoader((keys: readonly string[]) => this.batchDocumentsByIds(keys), ); private async batchDocumentsByBusinessIds(businessIds: readonly string[]) { const uniqueIDs = [...new Set(businessIds)]; try { const docs = await getDocumentsByBusinessIds.run({ Ids: uniqueIDs }, this.db); docs.map(doc => { this.getDocumentsByIdLoader.prime(doc.id, doc); }); return businessIds.map(id => docs.filter(doc => doc.creditor_id === id || doc.debtor_id === id), ); } catch (e) { console.error(e); return businessIds.map(() => null); } } public getDocumentsByBusinessIdLoader = new DataLoader((businessIds: readonly string[]) => this.batchDocumentsByBusinessIds(businessIds), ); private async batchDocumentsByHash(hashes: readonly number[]) { const uniqueHashes = [...new Set(hashes)]; try { const docs = await getDocumentsByHashes.run( { hashes: uniqueHashes.map(hash => hash.toString()) }, this.db, ); docs.map(doc => { this.getDocumentsByIdLoader.prime(doc.id, doc); }); return hashes.map(hash => docs.find(doc => doc.file_hash === hash.toString())); } catch (e) { console.error(e); return hashes.map(() => null); } } public getDocumentByHash = new DataLoader((hashes: readonly number[]) => this.batchDocumentsByHash(hashes), ); public async getDocumentsByMissingRequiredInfo() { return getDocumentsByMissingRequiredInfo.run(undefined, this.db); } public async updateDocument(params: IUpdateDocumentParams) { if (params.documentId) { const document = await this.getDocumentsByIdLoader.load(params.documentId); if (document?.charge_id) { this.getDocumentsByChargeIdLoader.clear(document.charge_id); } this.invalidateById(params.documentId); } const totalAmount = params.totalAmount == null ? undefined : Math.abs(params.totalAmount); // ensure amount is positive, as the sign is determined by debtor/creditor return updateDocument.run({ ...params, totalAmount }, this.db); } public async deleteDocument(params: IDeleteDocumentParams) { if (params.documentId) { const document = await this.getDocumentsByIdLoader.load(params.documentId); if (document?.charge_id) { this.getDocumentsByChargeIdLoader.clear(document.charge_id); } this.invalidateById(params.documentId); } return deleteDocument.run(params, this.db); } public async insertDocuments(params: IInsertDocumentsParams, dbConnection?: IDatabaseConnection) { if (params.documents.length) { params.documents.map(doc => { if (doc.chargeId) this.invalidateByChargeId(doc.chargeId); }); } const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext(); const documentsWithOwnerId = params.documents.map(doc => reassureOwnerIdExists( { ...doc, amount: doc.amount == null ? undefined : Math.abs(doc.amount) }, // ensure amount is positive, as the sign is determined by debtor/creditor ownerId, ), ); return insertDocuments.run({ documents: documentsWithOwnerId }, dbConnection ?? this.db); } public async replaceDocumentsChargeId(params: IReplaceDocumentsChargeIdParams) { if (params.assertChargeID) { this.invalidateByChargeId(params.assertChargeID); } if (params.replaceChargeID) { this.invalidateByChargeId(params.replaceChargeID); } return replaceDocumentsChargeId.run(params, this.db); } public async invalidateById(id: string) { const document = await this.getDocumentsByIdLoader.load(id); if (document) { if (document.charge_id) this.getDocumentsByChargeIdLoader.clear(document.charge_id); if (document.file_hash) this.getDocumentByHash.clear(Number(document.file_hash)); if (document.debtor_id) this.getDocumentsByBusinessIdLoader.clear(document.debtor_id); if (document.creditor_id) this.getDocumentsByBusinessIdLoader.clear(document.creditor_id); } this.getDocumentsByIdLoader.clear(id); this.allDocumentsCache = null; } public async invalidateByChargeId(chargeId: string) { const documents = await this.getDocumentsByChargeIdLoader.load(chargeId); documents.map(doc => { this.getDocumentsByIdLoader.clear(doc.id); if (doc.debtor_id) this.getDocumentsByBusinessIdLoader.clear(doc.debtor_id); if (doc.creditor_id) this.getDocumentsByBusinessIdLoader.clear(doc.creditor_id); }); this.getDocumentsByChargeIdLoader.clear(chargeId); this.allDocumentsCache = null; } public clearCache() { this.getDocumentsByIdLoader.clearAll(); this.getDocumentsByChargeIdLoader.clearAll(); this.getDocumentsByBusinessIdLoader.clearAll(); this.getDocumentByHash.clearAll(); this.allDocumentsCache = null; } }