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 | null; published?: boolean; }; function normalizeJson(value: Record | 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(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 & { 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}` } }); } }