init
This commit is contained in:
420
backend/src/modules/signals/signals.service.ts
Normal file
420
backend/src/modules/signals/signals.service.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user