diff --git a/apps/backend/prisma/migrations/20260805120000_add_price_direction/migration.sql b/apps/backend/prisma/migrations/20260805120000_add_price_direction/migration.sql new file mode 100644 index 0000000..468d4c5 --- /dev/null +++ b/apps/backend/prisma/migrations/20260805120000_add_price_direction/migration.sql @@ -0,0 +1,26 @@ +-- CreateEnum +CREATE TYPE "PriceDirection" AS ENUM ('MAX', 'MIN'); + +-- CreateTable +CREATE TABLE "telegram_daily_price_notifications" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "quoteType" "QuoteType" NOT NULL, + "direction" "PriceDirection" NOT NULL, + "value" MONEY NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_daily_price_notifications_pkey" PRIMARY KEY ("id") +); + +-- Migrate existing max records (if any) +INSERT INTO "telegram_daily_price_notifications" ("id", "date", "quoteType", "direction", "value", "createdAt", "updatedAt") +SELECT "id", "date", "quoteType", 'MAX'::"PriceDirection", "maxValue", "createdAt", "updatedAt" +FROM "telegram_daily_max_notifications"; + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_daily_price_notifications_date_quoteType_direction_key" ON "telegram_daily_price_notifications"("date", "quoteType", "direction"); + +-- DropTable +DROP TABLE "telegram_daily_max_notifications"; \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 659ab5c..f99ac8e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -79,16 +79,22 @@ model TelegramChat { @@map("telegram_chats") } -model TelegramDailyMaxNotification { - id String @id @default(cuid()) +enum PriceDirection { + MAX + MIN +} + +model TelegramDailyPriceNotification { + id String @id @default(cuid()) date DateTime quoteType QuoteType - maxValue Decimal @db.Money - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + direction PriceDirection + value Decimal @db.Money + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - @@unique([date, quoteType]) - @@map("telegram_daily_max_notifications") + @@unique([date, quoteType, direction]) + @@map("telegram_daily_price_notifications") } enum QuoteType { diff --git a/apps/backend/src/modules/quotes/quote.job.ts b/apps/backend/src/modules/quotes/quote.job.ts index 2863871..7c88f14 100644 --- a/apps/backend/src/modules/quotes/quote.job.ts +++ b/apps/backend/src/modules/quotes/quote.job.ts @@ -1,7 +1,7 @@ import cron from "node-cron"; import { eventBus } from "../../lib/event-bus"; import { logger } from "../../lib/logger"; -import { checkAndNotifyBeloMax } from "../telegram/telegram.service"; +import { checkAndNotifyBelo } from "../telegram/telegram.service"; import { processBeloQuote } from "./belo.service"; import { processBlueQuote } from "./blue.service"; import { processBnaQuote } from "./bna.service"; @@ -14,7 +14,7 @@ async function safeProcess( await fn(); logger.info(`${name} quote processed successfully`); if (name === "BELO") { - await checkAndNotifyBeloMax(); + await checkAndNotifyBelo(); } } catch (error) { logger.error(error, `Error processing ${name} quote`); diff --git a/apps/backend/src/modules/telegram/telegram.service.ts b/apps/backend/src/modules/telegram/telegram.service.ts index f7179ec..9e5a235 100644 --- a/apps/backend/src/modules/telegram/telegram.service.ts +++ b/apps/backend/src/modules/telegram/telegram.service.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { prisma } from "../../lib/prisma"; import { logger } from "../../lib/logger"; +import { prisma } from "../../lib/prisma"; import { getBot, getBotUsername } from "../../lib/telegram"; function generateRandomCode(): string { @@ -72,7 +72,93 @@ export async function getTelegramStatus(): Promise<{ }; } -export async function checkAndNotifyBeloMax(): Promise { +function formatTime(date: Date): string { + const artOffset = -3; + const artMs = date.getTime() + artOffset * 3600000; + const artDate = new Date(artMs); + return artDate.toLocaleTimeString("es-AR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }); +} + +async function checkAndNotifyBound({ + chatId, + direction, + currentValue, + quoteTime, + todayStart, +}: { + chatId: string | bigint; + direction: "MAX" | "MIN"; + currentValue: number; + quoteTime: Date; + todayStart: Date; +}): Promise { + const bot = getBot(); + if (!bot) return; + + const isMax = direction === "MAX"; + + const lastNotified = await prisma.telegramDailyPriceNotification.findUnique({ + where: { + date_quoteType_direction: { + date: todayStart, + quoteType: "BELO", + direction, + }, + }, + }); + + const lastNotifiedValue = lastNotified + ? Number(lastNotified.value) + : isMax + ? 0 + : Number.POSITIVE_INFINITY; + + const shouldNotify = isMax + ? currentValue > lastNotifiedValue + : currentValue < lastNotifiedValue; + + if (!shouldNotify) return; + + const formattedPrice = formatPrice(currentValue); + const time = formatTime(quoteTime); + const icon = isMax ? "🟢📈" : "🔴📉"; + const word = isMax ? "máximo" : "mínimo"; + const message = `${icon} BELO alcanzó el ${word} del día a las ${time}, cotización actual es de $${formattedPrice}`; + + try { + await bot.api.sendMessage(Number(chatId), message); + + await prisma.telegramDailyPriceNotification.upsert({ + where: { + date_quoteType_direction: { + date: todayStart, + quoteType: "BELO", + direction, + }, + }, + create: { + date: todayStart, + quoteType: "BELO", + direction, + value: currentValue, + }, + update: { + value: currentValue, + }, + }); + + logger.info(`Telegram BELO ${word} notification sent: $${formattedPrice}`); + } catch (error) { + logger.error(error, `Failed to send Telegram BELO ${word} notification`); + } +} + +export async function checkAndNotifyBelo(): Promise { const chat = await prisma.telegramChat.findFirst({ where: { validatedAt: { not: null } }, orderBy: { validatedAt: "desc" }, @@ -84,7 +170,7 @@ export async function checkAndNotifyBeloMax(): Promise { if (!bot) return; const now = new Date(); - const artHour = ((now.getUTCHours() - 3) % 24 + 24) % 24; + const artHour = (((now.getUTCHours() - 3) % 24) + 24) % 24; if (artHour < 8 || artHour >= 20) return; const currentQuote = await prisma.quote.findFirst({ @@ -106,46 +192,19 @@ export async function checkAndNotifyBeloMax(): Promise { ), ); - const lastNotified = await prisma.telegramDailyMaxNotification.findUnique({ - where: { - date_quoteType: { - date: todayStart, - quoteType: "BELO", - }, - }, + await checkAndNotifyBound({ + chatId: chat.chatId, + direction: "MAX", + currentValue: currentBuy, + quoteTime: currentQuote.timeStamp, + todayStart, }); - const lastNotifiedValue = lastNotified ? Number(lastNotified.maxValue) : 0; - - if (currentBuy <= lastNotifiedValue) return; - - const formattedPrice = formatPrice(currentBuy); - const message = `BELO alcanzó el máximo del día, la cotización actual es de $${formattedPrice}`; - - try { - await bot.api.sendMessage(Number(chat.chatId), message); - - await prisma.telegramDailyMaxNotification.upsert({ - where: { - date_quoteType: { - date: todayStart, - quoteType: "BELO", - }, - }, - create: { - date: todayStart, - quoteType: "BELO", - maxValue: currentBuy, - }, - update: { - maxValue: currentBuy, - }, - }); - - logger.info( - `Telegram BELO max notification sent: $${formattedPrice}`, - ); - } catch (error) { - logger.error(error, "Failed to send Telegram BELO max notification"); - } + await checkAndNotifyBound({ + chatId: chat.chatId, + direction: "MIN", + currentValue: currentBuy, + quoteTime: currentQuote.timeStamp, + todayStart, + }); }