initial commit

This commit is contained in:
Jose Selesan
2026-05-28 14:33:16 -03:00
commit 7bc3d9f898
211 changed files with 161253 additions and 0 deletions

18
apps/backend/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
import { logger } from "./lib/logger";
const app = new Hono();
app.route("/api/quotes", quotesRouter);
app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" }));
startQuoteJob();
logger.info("Backend started");
export default app;

View File

@@ -0,0 +1,19 @@
type Listener = (...args: unknown[]) => void;
class EventBus {
private listeners = new Map<string, Set<Listener>>();
on(event: string, listener: Listener): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return () => this.listeners.get(event)?.delete(listener);
}
emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((listener) => listener(...args));
}
}
export const eventBus = new EventBus();

View File

@@ -0,0 +1,17 @@
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL ?? (isDev ? "debug" : "info"),
...(isDev && {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
},
}),
});

View File

@@ -0,0 +1,6 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({ adapter });

View File

@@ -0,0 +1,10 @@
export function roundTo(v: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round(v * factor) / factor;
}
export function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}

View File

@@ -0,0 +1,84 @@
import { logger } from "../../lib/logger";
import { prisma } from "../../lib/prisma";
import { roundTo, startOfToday } from "../../lib/utils";
interface BeloApiResponse {
ask: number;
bid: number;
totalAsk: number;
totalBid: number;
time: number;
}
export async function fetchBeloQuote(): Promise<{ buy: number; sell: number }> {
const response = await fetch("https://criptoya.com/api/belo/USDC/ARS/1");
const data: BeloApiResponse = await response.json();
return { buy: data.bid, sell: data.ask };
}
export async function processBeloQuote(): Promise<void> {
const { buy, sell } = await fetchBeloQuote();
const existingQuote = await prisma.quote.findFirst({
where: { type: "BELO" },
});
if (!existingQuote) {
await prisma.quote.create({
data: {
buy,
sell,
type: "BELO",
difference: 0,
percentage: 0,
previousDayDifference: 0,
previousDayPercentage: 0,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BELO" },
});
return;
}
const existingSell = Number(existingQuote.sell);
const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BELO`);
const todayStart = startOfToday();
const prevDayHistory = await prisma.quoteHistory.findFirst({
where: {
type: "BELO",
timeStamp: { lt: todayStart },
},
orderBy: { timeStamp: "desc" },
});
let prevDayDiff = 0;
let prevDayPct = 0;
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
}
await prisma.quote.update({
where: { id: existingQuote.id },
data: {
buy,
sell,
timeStamp: new Date(),
difference: diff,
percentage: pct,
previousDayDifference: prevDayDiff,
previousDayPercentage: prevDayPct,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BELO" },
});
}

View File

@@ -0,0 +1,98 @@
import { logger } from "../../lib/logger";
import { prisma } from "../../lib/prisma";
import { roundTo, startOfToday } from "../../lib/utils";
interface DolaritoApiResponse {
informal: {
buy: number;
sell: number;
};
}
export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
credentials: "omit",
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
Accept: "application/json, text/plain, */*",
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
},
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
});
const data: DolaritoApiResponse = await response.json();
return { buy: data.informal.buy, sell: data.informal.sell };
}
export async function processBlueQuote(): Promise<void> {
const { buy, sell } = await fetchBlueQuote();
const existingQuote = await prisma.quote.findFirst({
where: { type: "BLUE" },
});
if (!existingQuote) {
await prisma.quote.create({
data: {
buy,
sell,
type: "BLUE",
difference: 0,
percentage: 0,
previousDayDifference: 0,
previousDayPercentage: 0,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BLUE" },
});
return;
}
const existingSell = Number(existingQuote.sell);
const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`);
const todayStart = startOfToday();
const prevDayHistory = await prisma.quoteHistory.findFirst({
where: {
type: "BLUE",
timeStamp: { lt: todayStart },
},
orderBy: { timeStamp: "desc" },
});
let prevDayDiff = 0;
let prevDayPct = 0;
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
}
await prisma.quote.update({
where: { id: existingQuote.id },
data: {
buy,
sell,
timeStamp: new Date(),
difference: diff,
percentage: pct,
previousDayDifference: prevDayDiff,
previousDayPercentage: prevDayPct,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BLUE" },
});
}

View File

@@ -0,0 +1,98 @@
import { logger } from "../../lib/logger";
import { prisma } from "../../lib/prisma";
import { roundTo, startOfToday } from "../../lib/utils";
interface DolaritoApiResponse {
oficial: {
buy: number;
sell: number;
};
}
export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
credentials: "omit",
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
Accept: "application/json, text/plain, */*",
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
},
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
});
const data: DolaritoApiResponse = await response.json();
return { buy: data.oficial.buy, sell: data.oficial.sell };
}
export async function processBnaQuote(): Promise<void> {
const { buy, sell } = await fetchBnaQuote();
const existingQuote = await prisma.quote.findFirst({
where: { type: "BNA" },
});
if (!existingQuote) {
await prisma.quote.create({
data: {
buy,
sell,
type: "BNA",
difference: 0,
percentage: 0,
previousDayDifference: 0,
previousDayPercentage: 0,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BNA" },
});
return;
}
const existingSell = Number(existingQuote.sell);
const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`);
const todayStart = startOfToday();
const prevDayHistory = await prisma.quoteHistory.findFirst({
where: {
type: "BNA",
timeStamp: { lt: todayStart },
},
orderBy: { timeStamp: "desc" },
});
let prevDayDiff = 0;
let prevDayPct = 0;
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
}
await prisma.quote.update({
where: { id: existingQuote.id },
data: {
buy,
sell,
timeStamp: new Date(),
difference: diff,
percentage: pct,
previousDayDifference: prevDayDiff,
previousDayPercentage: prevDayPct,
},
});
await prisma.quoteHistory.create({
data: { buy, sell, type: "BNA" },
});
}

View File

@@ -0,0 +1,25 @@
import type { Context } from "hono";
import { processBeloQuote } from "../belo.service";
import { processBlueQuote } from "../blue.service";
import { processBnaQuote } from "../bna.service";
import { logger } from "../../../lib/logger";
import { eventBus } from "../../../lib/event-bus";
export async function fetchQuotes(c: Context) {
const results: { type: string; status: string; error?: string }[] = [];
for (const [name, fn] of [["BELO", processBeloQuote], ["BLUE", processBlueQuote], ["BNA", processBnaQuote]] as const) {
try {
await fn();
results.push({ type: name, status: "ok" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(error, `Error fetching ${name} quote via endpoint`);
results.push({ type: name, status: "error", error: message });
}
}
eventBus.emit("quotes-updated");
return c.json(results);
}

View File

@@ -0,0 +1,7 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
export async function getCurrentQuotes(c: Context) {
const quotes = await prisma.quote.findMany();
return c.json(quotes);
}

View File

@@ -0,0 +1,71 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import type { QuoteType } from "../../../generated/prisma/client";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getDailyMinMax(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
}
const startDateParam = c.req.query("startDate");
const endDateParam = c.req.query("endDate");
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
const rows = await prisma.$queryRaw<Array<{
date: string;
minBuy: number;
maxBuy: number;
minSell: number;
maxSell: number;
}>>`
SELECT
DATE("timeStamp")::text AS date,
MIN(buy::numeric)::float8 AS "minBuy",
MAX(buy::numeric)::float8 AS "maxBuy",
MIN(sell::numeric)::float8 AS "minSell",
MAX(sell::numeric)::float8 AS "maxSell"
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
GROUP BY DATE("timeStamp")
ORDER BY DATE("timeStamp") ASC
`;
if (rows.length === 0) {
const current = await prisma.quote.findFirst({
where: { type: rawType as QuoteType },
orderBy: { timeStamp: "desc" },
});
if (current) {
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
return c.json([
{
date: today,
minBuy: Number(current.buy),
maxBuy: Number(current.buy),
minSell: Number(current.sell),
maxSell: Number(current.sell),
},
]);
}
}
return c.json(rows);
}

View File

@@ -0,0 +1,45 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getDailyQuotes(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
}
const startDateParam = c.req.query("startDate");
const endDateParam = c.req.query("endDate");
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
const rows = await prisma.$queryRaw<Array<{
date: string;
buy: number;
sell: number;
}>>`
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
ORDER BY DATE("timeStamp"), "timeStamp" DESC
) sub
ORDER BY DATE("timeStamp") ASC
`;
return c.json(rows);
}

View File

@@ -0,0 +1,39 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import type { QuoteType } from "../../../generated/prisma/client";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getQuoteHistory(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
}
const type = rawType as QuoteType;
const startDateParam = c.req.query("startDate");
const endDateParam = c.req.query("endDate");
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
const history = await prisma.quoteHistory.findMany({
where: {
type,
timeStamp: { gte: startDate, lt: endDate },
},
orderBy: { timeStamp: "asc" },
});
return c.json(history);
}

View File

@@ -0,0 +1,49 @@
import type { Context } from "hono";
import { eventBus } from "../../../lib/event-bus";
export function quoteEvents(c: Context) {
const cleanups: (() => void)[] = [];
let isCancelled = false;
function cleanup() {
if (isCancelled) return;
isCancelled = true;
cleanups.forEach((fn) => fn());
}
const stream = new ReadableStream({
start(controller) {
const unsubscribe = eventBus.on("quotes-updated", () => {
const data = "event: quotes-updated\ndata: updated\n\n";
try {
controller.enqueue(new TextEncoder().encode(data));
} catch {
/* stream closed */
}
});
cleanups.push(unsubscribe);
const keepAlive = setInterval(() => {
try {
controller.enqueue(new TextEncoder().encode(": keepalive\n\n"));
} catch {
clearInterval(keepAlive);
}
}, 5_000);
cleanups.push(() => clearInterval(keepAlive));
c.req.raw.signal.addEventListener("abort", cleanup);
},
cancel() {
cleanup();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}

View File

@@ -0,0 +1,34 @@
import cron from "node-cron";
import { processBeloQuote } from "./belo.service";
import { processBlueQuote } from "./blue.service";
import { processBnaQuote } from "./bna.service";
import { logger } from "../../lib/logger";
import { eventBus } from "../../lib/event-bus";
async function safeProcess(
name: string,
fn: () => Promise<void>,
): Promise<void> {
try {
await fn();
logger.info(`${name} quote processed successfully`);
} catch (error) {
logger.error(error, `Error processing ${name} quote`);
}
}
const QUOTES = [
{ name: "BELO", fn: processBeloQuote },
{ name: "BLUE", fn: processBlueQuote },
{ name: "BNA", fn: processBnaQuote },
] as const;
export function startQuoteJob(): void {
cron.schedule("*/10 * * * *", async () => {
logger.info("Fetching quotes...");
await Promise.all(QUOTES.map((q) => safeProcess(q.name, q.fn)));
eventBus.emit("quotes-updated");
});
logger.info("Cron scheduled: every 10 minutes");
}

View File

@@ -0,0 +1,18 @@
import { Hono } from "hono";
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
import { fetchQuotes } from "./handlers/fetchQuotes";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getDailyMinMax } from "./handlers/getDailyMinMax";
import { quoteEvents } from "./handlers/quoteEvents";
const app = new Hono();
app.get("/", getCurrentQuotes);
app.get("/:type/history", getQuoteHistory);
app.get("/:type/daily", getDailyQuotes);
app.get("/:type/min-max", getDailyMinMax);
app.get("/fetch", fetchQuotes);
app.get("/events", quoteEvents);
export default app;