421 lines
11 KiB
TypeScript
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}`
|
|
}
|
|
});
|
|
}
|
|
}
|