import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { TbankAccessService, TbankClient, tbankSourceTtlMs, } from '#src/integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { catalogQuery, type CatalogQuery, catalogScope, } from '../infrastructure/catalog-discovery.repository'; import { catalogSearchLimit, selectCatalogInstruments, } from './catalog-selection'; import { publicInstrument } from './public-instrument'; const UNAMBIGUOUS_MATCH_COUNT = 1; const FIRST_RESULT_OFFSET = 0; const tickerAmbiguityLimit = 2; @Injectable() export class CatalogDiscoveryService { constructor( private readonly access: TbankAccessService, private readonly tbank: TbankClient, ) {} async search(userId: string, input: CatalogQuery) { return this.onlineSearch(userId, catalogQuery(input)); } async detail( userId: string, instrumentType: 'bond' | 'share', uid: string, ) { const instrument = await this.access.online(userId, ({ token }) => instrumentType === 'share' ? this.tbank.getShare(token, uid) : this.tbank.getBond(token, uid), ); if (!instrument) throw new NotFoundException(); return { data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/detail/${uid}`, dataRevision: `online:${Date.now()}`, availability: 'complete', fetchedAt: new Date(), freshForMs: tbankSourceTtlMs.portfolio, work: null, }), }; } async byTicker( userId: string, instrumentType: 'bond' | 'share', ticker: string, ) { const query = ticker.trim().toUpperCase(); const found = await this.access.online(userId, ({ token }) => instrumentType === 'share' ? this.tbank.findShares(token, query) : this.tbank.findBonds(token, query), ); const matches = selectCatalogInstruments( found.filter((item) => item.ticker?.trim().toUpperCase() === query), query, instrumentType, ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit); if (!matches.length) throw new NotFoundException(); if (matches.length > UNAMBIGUOUS_MATCH_COUNT) { throw new ConflictException({ code: instrumentType === 'share' ? InstrumentErrorCode.StockTickerAmbiguous : InstrumentErrorCode.BondTickerAmbiguous, }); } const result = await this.detail(userId, instrumentType, matches[0].uid); return { ...result, data: result.data ? { ...result.data, slug: query } : null }; } private async onlineSearch(userId: string, input: CatalogQuery) { const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => { const found = input.instrumentType === 'share' ? await this.tbank.findShares(token, input.query) : await this.tbank.findBonds(token, input.query); const instruments = selectCatalogInstruments( input.exactTicker ? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query) : found, input.query, input.instrumentType, ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); const [details, prices] = await Promise.all([ Promise.all( instruments.map((item) => input.instrumentType === 'share' ? this.tbank.getShare(token, item.uid) : this.tbank.getBond(token, item.uid), ), ), instruments.length ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) : [], ]); return { details, instruments, prices }; }); const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); return [{ ...item, detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null, price: prices.find((price) => price.instrumentUid === item.uid) ?? null, }]; }); return { data, meta: investmentResourceMeta({ resourceKey: `search:${catalogScope(input)}`, dataRevision: `online:${fetchedAt.toISOString()}`, availability: 'complete', fetchedAt, freshForMs: tbankSourceTtlMs.portfolio, work: null, }), }; } }