Files
antigol-service/backend/src/modules/signals/signals.service.ts
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

421 lines
11 KiB
TypeScript

import { Prisma, SignalStatus, SourceType, SubscriptionStatus } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { createSignalDedupeKey } from "../../lib/dedupe.js";
import { HttpError } from "../../lib/errors.js";
import { settleSignal } from "../../lib/settlement.js";
type UpsertSignalInput = {
providerId?: string | null;
eventId: string;
sportType: string;
leagueName: string;
homeTeam: string;
awayTeam: string;
eventStartTime: string;
marketType: string;
selection: string;
forecast?: string | null;
lineValue?: number | null;
odds: number;
signalTime: string;
sourceType: "manual" | "provider";
comment?: string | null;
rawPayload?: Record<string, unknown> | null;
published?: boolean;
};
function normalizeJson(value: Record<string, unknown> | null | undefined): Prisma.InputJsonValue | typeof Prisma.JsonNull | undefined {
if (value === undefined) return undefined;
if (value === null) return Prisma.JsonNull;
return value as Prisma.InputJsonObject;
}
function asWhereArray(value: Prisma.SignalWhereInput["AND"]): Prisma.SignalWhereInput[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
const PARSER_INACTIVE_COMMENT = "Signal marked inactive by parser";
function withCreatedAtAsSignalTime<T extends { signalTime: Date; createdAt: Date }>(signal: T) {
return {
...signal,
signalTime: signal.createdAt
};
}
function buildActiveSubscriptionWhere(userId: string): Prisma.UserBotAccessWhereInput {
const now = new Date();
return {
userId,
status: SubscriptionStatus.active,
startsAt: {
lte: now
},
OR: [
{
expiresAt: null
},
{
expiresAt: {
gte: now
}
}
]
};
}
function buildInactiveSignalWhere(): Prisma.SignalWhereInput {
return {
comment: PARSER_INACTIVE_COMMENT
};
}
function buildActiveSignalWhere(): Prisma.SignalWhereInput {
return {
OR: [
{
comment: null
},
{
comment: {
not: PARSER_INACTIVE_COMMENT
}
}
]
};
}
export async function createSignal(input: UpsertSignalInput) {
const dedupeKey = createSignalDedupeKey(input);
try {
return await prisma.signal.create({
data: {
providerId: input.providerId ?? null,
eventId: input.eventId,
sportType: input.sportType,
leagueName: input.leagueName,
homeTeam: input.homeTeam,
awayTeam: input.awayTeam,
eventStartTime: new Date(input.eventStartTime),
marketType: input.marketType,
selection: input.selection,
forecast: input.forecast ?? null,
lineValue: input.lineValue ?? null,
odds: input.odds,
signalTime: new Date(input.signalTime),
status: SignalStatus.pending,
sourceType: input.sourceType as SourceType,
comment: input.comment ?? null,
published: input.published ?? true,
dedupeKey,
rawPayload: normalizeJson(input.rawPayload)
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new HttpError(409, "Такой сигнал уже существует");
}
throw error;
}
}
export async function updateSignal(id: string, input: Partial<UpsertSignalInput> & { status?: SignalStatus }) {
const current = await prisma.signal.findUnique({ where: { id } });
if (!current) throw new HttpError(404, "Сигнал не найден");
const dedupeKey = createSignalDedupeKey({
providerId: input.providerId ?? current.providerId,
eventId: input.eventId ?? current.eventId,
marketType: input.marketType ?? current.marketType,
selection: input.selection ?? current.selection,
lineValue: input.lineValue ?? current.lineValue
});
return prisma.signal.update({
where: { id },
data: {
providerId: input.providerId ?? undefined,
eventId: input.eventId,
sportType: input.sportType,
leagueName: input.leagueName,
homeTeam: input.homeTeam,
awayTeam: input.awayTeam,
eventStartTime: input.eventStartTime ? new Date(input.eventStartTime) : undefined,
marketType: input.marketType,
selection: input.selection,
forecast: input.forecast === null ? null : input.forecast,
lineValue: input.lineValue === null ? null : input.lineValue,
odds: input.odds,
signalTime: input.signalTime ? new Date(input.signalTime) : undefined,
sourceType: input.sourceType ? (input.sourceType as SourceType) : undefined,
comment: input.comment === null ? null : input.comment,
published: input.published,
status: input.status,
dedupeKey,
rawPayload: normalizeJson(input.rawPayload)
}
});
}
export async function listSignals(filters: {
status?: string;
sportType?: string;
sourceType?: string;
q?: string;
published?: string;
botKey?: string;
activeTab?: number;
page: number;
perPage: number;
}, options: { userId?: string; role?: "admin" | "user" } = {}) {
const publishedFilter = filters.published === undefined ? true : filters.published === "true";
const page = filters.page;
const perPage = filters.perPage;
const where: Prisma.SignalWhereInput = {
status: filters.status as SignalStatus | undefined,
sportType: filters.sportType,
sourceType: filters.sourceType as "manual" | "provider" | undefined,
published: publishedFilter,
OR: filters.q
? [
{ homeTeam: { contains: filters.q, mode: "insensitive" } },
{ awayTeam: { contains: filters.q, mode: "insensitive" } },
{ leagueName: { contains: filters.q, mode: "insensitive" } }
]
: undefined
};
const andConditions: Prisma.SignalWhereInput[] = [];
let activeTabCondition: Prisma.SignalWhereInput | null = null;
if (filters.activeTab !== undefined) {
activeTabCondition = filters.activeTab === 2
? buildInactiveSignalWhere()
: buildActiveSignalWhere();
}
if (filters.botKey) {
andConditions.push({
OR: [
{
rawPayload: {
path: ["botKey"],
equals: filters.botKey
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(filters.botKey)
}
}
]
});
}
if (options.role === "user" && options.userId) {
const accesses = await prisma.userBotAccess.findMany({
where: buildActiveSubscriptionWhere(options.userId),
select: {
bot: {
select: {
key: true
}
}
}
});
const allowedBotKeys = accesses.map((entry) => entry.bot.key);
andConditions.push({
OR: [
{
sourceType: SourceType.manual
},
...allowedBotKeys.flatMap((key) => [
{
rawPayload: {
path: ["botKey"],
equals: key
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(key)
}
}
])
]
});
}
if (activeTabCondition) {
andConditions.push(activeTabCondition);
}
if (andConditions.length > 0) {
where.AND = andConditions;
}
const tabCountAndConditions = activeTabCondition
? andConditions.filter((condition) => condition !== activeTabCondition)
: andConditions;
const tabCountsWhere: Prisma.SignalWhereInput | null = filters.botKey || options.role === "user"
? {
status: undefined,
sportType: filters.sportType,
sourceType: filters.sourceType as "manual" | "provider" | undefined,
published: publishedFilter,
OR: undefined
}
: null;
if (tabCountsWhere && tabCountAndConditions.length > 0) {
tabCountsWhere.AND = tabCountAndConditions;
}
const activeTab1Where: Prisma.SignalWhereInput | null = tabCountsWhere
? {
...tabCountsWhere,
AND: [
...asWhereArray(tabCountsWhere.AND),
buildActiveSignalWhere()
]
}
: null;
const activeTab2Where: Prisma.SignalWhereInput | null = tabCountsWhere
? {
...tabCountsWhere,
AND: [
...asWhereArray(tabCountsWhere.AND),
buildInactiveSignalWhere()
]
}
: null;
const [items, total, allTabTotal, activeTabTotal, inactiveTabTotal] = await Promise.all([
prisma.signal.findMany({
where,
orderBy: [{ eventStartTime: "desc" }, { signalTime: "desc" }],
skip: (page - 1) * perPage,
take: perPage
}),
prisma.signal.count({ where }),
tabCountsWhere ? prisma.signal.count({ where: tabCountsWhere }) : Promise.resolve(0),
activeTab1Where ? prisma.signal.count({ where: activeTab1Where }) : Promise.resolve(0),
activeTab2Where ? prisma.signal.count({ where: activeTab2Where }) : Promise.resolve(0)
]);
return {
items: items.map(withCreatedAtAsSignalTime),
pagination: {
page,
perPage,
total,
totalPages: Math.max(1, Math.ceil(total / perPage))
},
tabCounts: tabCountsWhere
? {
all: allTabTotal,
"1": activeTabTotal,
"2": inactiveTabTotal
}
: undefined
};
}
export async function listActiveSignalCountsByBot(options: { userId?: string; role?: "admin" | "user" } = {}) {
const botWhere: Prisma.BotWhereInput = {
active: true
};
if (options.role === "user" && options.userId) {
botWhere.userAccesses = {
some: buildActiveSubscriptionWhere(options.userId)
};
}
const bots = await prisma.bot.findMany({
where: botWhere,
select: {
id: true,
key: true,
name: true
},
orderBy: {
name: "asc"
}
});
const items = await Promise.all(
bots.map(async (bot) => {
const activeSignals = await prisma.signal.count({
where: {
published: true,
OR: [
{
rawPayload: {
path: ["botKey"],
equals: bot.key
}
},
{
rawPayload: {
path: ["botKey"],
equals: String(bot.key)
}
}
],
AND: [
buildActiveSignalWhere()
]
}
});
return {
botId: bot.id,
botKey: bot.key,
botName: bot.name,
activeSignals
};
})
);
return {
items
};
}
export async function settlePendingSignals() {
const pendingSignals = await prisma.signal.findMany({
where: { status: SignalStatus.pending, published: true }
});
for (const signal of pendingSignals) {
const eventResult = await prisma.eventResult.findUnique({ where: { eventId: signal.eventId } });
if (!eventResult || eventResult.homeScore === null || eventResult.awayScore === null) continue;
const result = settleSignal(signal, {
homeScore: eventResult.homeScore,
awayScore: eventResult.awayScore
});
await prisma.signal.update({ where: { id: signal.id }, data: { status: result } });
await prisma.settlement.upsert({
where: { signalId: signal.id },
update: { result, explanation: `Автоматический settlement по счету ${eventResult.homeScore}:${eventResult.awayScore}` },
create: {
signalId: signal.id,
result,
explanation: `Автоматический settlement по счету ${eventResult.homeScore}:${eventResult.awayScore}`
}
});
}
}