Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
|
|
import {
|
|
TbankAccessService,
|
|
TbankClient,
|
|
tbankSourceTtlMs,
|
|
} from '../../../../integrations/tbank/tbank.api';
|
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
|
import {
|
|
CatalogDiscoveryRepository,
|
|
catalogQuery,
|
|
type CatalogQuery,
|
|
catalogScope,
|
|
} from '../infrastructure/catalog-discovery.repository';
|
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
|
import {
|
|
catalogSearchLimit,
|
|
selectCatalogInstruments,
|
|
} from './catalog-selection';
|
|
import { publicInstrument } from './public-instrument';
|
|
|
|
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
|
const NO_MATCHES = 0;
|
|
const FIRST_RESULT_OFFSET = 0;
|
|
|
|
const tickerAmbiguityLimit = 2;
|
|
|
|
@Injectable()
|
|
export class CatalogDiscoveryService {
|
|
constructor(
|
|
private readonly repository: CatalogDiscoveryRepository,
|
|
private readonly access: TbankAccessService,
|
|
private readonly tbank: TbankClient,
|
|
private readonly enrichment: CatalogEnrichmentService,
|
|
) {}
|
|
|
|
async read(userId: string, input: CatalogQuery) {
|
|
input = catalogQuery(input);
|
|
const connection = await this.access.local(userId);
|
|
const saved = await this.repository.read(input);
|
|
return {
|
|
data: saved.data,
|
|
meta: investmentResourceMeta({
|
|
resourceKey: catalogScope(input),
|
|
dataRevision: saved.dataRevision,
|
|
availability: saved.data === null ? 'missing' : 'complete',
|
|
fetchedAt: saved.fetchedAt,
|
|
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
work: await this.repository.work(userId, connection.revision, input),
|
|
}),
|
|
};
|
|
}
|
|
|
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
|
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
|
const discovery = await this.read(userId, input);
|
|
const sources = discovery.data
|
|
? await this.enrichment.readMany(
|
|
userId,
|
|
input.instrumentType,
|
|
discovery.data.map((item) => item.uid),
|
|
observe,
|
|
)
|
|
: [];
|
|
const dependencies = [
|
|
discovery.meta,
|
|
...sources.map((source) => source.meta),
|
|
];
|
|
return {
|
|
data:
|
|
discovery.data?.map((short) => ({
|
|
...short,
|
|
detail:
|
|
sources.find(
|
|
(source) =>
|
|
source.input.uid === short.uid &&
|
|
source.input.kind === 'detail',
|
|
)?.instrument ?? null,
|
|
price:
|
|
sources.find(
|
|
(source) =>
|
|
source.input.uid === short.uid &&
|
|
source.input.kind === 'last-price',
|
|
)?.price ?? null,
|
|
})) ?? null,
|
|
meta: investmentResourceMeta({
|
|
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
|
|
dataRevision: dependencies
|
|
.map((dependency) => dependency.dataRevision)
|
|
.join('/'),
|
|
availability:
|
|
discovery.data === null
|
|
? 'missing'
|
|
: sources.every((source) => source.meta.completeness === 'complete')
|
|
? 'complete'
|
|
: 'partial',
|
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
|
? new Date(discovery.meta.lastSuccessfulAt)
|
|
: null,
|
|
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
work: discovery.meta.sync.id
|
|
? {
|
|
id: discovery.meta.sync.id,
|
|
state: discovery.meta.sync.state,
|
|
errorCode: discovery.meta.sync.errorCode,
|
|
}
|
|
: null,
|
|
dependencies,
|
|
}),
|
|
};
|
|
}
|
|
|
|
async detail(
|
|
userId: string,
|
|
instrumentType: 'bond' | 'share',
|
|
uid: string,
|
|
observe = false,
|
|
) {
|
|
if (!observe) {
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
const sources = await this.enrichment.readMany(
|
|
userId,
|
|
instrumentType,
|
|
[uid],
|
|
observe,
|
|
['detail'],
|
|
);
|
|
const source = sources[0];
|
|
if (source.meta.completeness === 'complete' && !source.instrument) {
|
|
throw new NotFoundException();
|
|
}
|
|
const instrument = source.instrument;
|
|
let slug = uid;
|
|
const dependencies = [source.meta];
|
|
if (
|
|
instrument?.ticker &&
|
|
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
|
) {
|
|
const alias = await this.read(userId, {
|
|
instrumentType,
|
|
query: instrument.ticker,
|
|
exactTicker: true,
|
|
});
|
|
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
|
slug = instrument.ticker.toUpperCase();
|
|
}
|
|
dependencies.push(alias.meta);
|
|
}
|
|
return {
|
|
data: instrument ? { ...instrument, uid, slug } : null,
|
|
meta: investmentResourceMeta({
|
|
resourceKey: `${instrumentType}/detail/${uid}`,
|
|
dataRevision: dependencies
|
|
.map((dependency) => dependency.dataRevision)
|
|
.join('/'),
|
|
availability: source.meta.availability,
|
|
fetchedAt: source.meta.lastSuccessfulAt
|
|
? new Date(source.meta.lastSuccessfulAt)
|
|
: null,
|
|
freshForMs: tbankSourceTtlMs.portfolio,
|
|
work: source.meta.sync.id
|
|
? {
|
|
id: source.meta.sync.id,
|
|
state: source.meta.sync.state,
|
|
errorCode: source.meta.sync.errorCode,
|
|
}
|
|
: null,
|
|
dependencies,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
|
async byTicker(
|
|
userId: string,
|
|
instrumentType: 'bond' | 'share',
|
|
ticker: string,
|
|
observe = false,
|
|
) {
|
|
if (!observe) {
|
|
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 };
|
|
}
|
|
const lookup = await this.read(userId, {
|
|
instrumentType,
|
|
query: ticker,
|
|
exactTicker: true,
|
|
});
|
|
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
|
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
|
throw new ConflictException({
|
|
code:
|
|
instrumentType === 'share'
|
|
? InstrumentErrorCode.StockTickerAmbiguous
|
|
: InstrumentErrorCode.BondTickerAmbiguous,
|
|
});
|
|
}
|
|
const result = lookup.data
|
|
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
|
: null;
|
|
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
|
return {
|
|
data: result?.data
|
|
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
|
: null,
|
|
meta: investmentResourceMeta({
|
|
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
|
dataRevision: dependencies
|
|
.map((dependency) => dependency.dataRevision)
|
|
.join('/'),
|
|
availability: result?.meta.availability ?? 'missing',
|
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
|
? new Date(lookup.meta.lastSuccessfulAt)
|
|
: null,
|
|
freshForMs: tbankSourceTtlMs.portfolio,
|
|
work: lookup.meta.sync.id
|
|
? {
|
|
id: lookup.meta.sync.id,
|
|
state: lookup.meta.sync.state,
|
|
errorCode: lookup.meta.sync.errorCode,
|
|
}
|
|
: null,
|
|
dependencies,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
}
|