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

7
apps/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma
/web

View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
echo "Running database migrations..."
max_retries=30
i=0
until [ $i -ge $max_retries ]; do
if bun run --cwd apps/backend prisma:migrate:deploy 2>&1; then
echo "Migrations complete"
break
fi
i=$((i + 1))
echo "Migration attempt $i failed, retrying in 2s..."
sleep 2
done
if [ $i -ge $max_retries ]; then
echo "Failed to run migrations after $max_retries attempts"
exit 1
fi
exec "$@"

33
apps/backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@personal-admin/backend",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:migrate:deploy": "prisma migrate deploy"
},
"dependencies": {
"@personal-admin/common": "workspace:*",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"hono": "^4.7.0",
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
"prisma": {
"seed": "bun run prisma/seed.ts"
},
"devDependencies": {
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.20.0",
"dotenv": "^16.4.0",
"prisma": "^7.8.0",
"typescript": "^5.8.0"
}
}

View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "QuoteType" AS ENUM ('BLUE', 'BNA', 'BELO');
-- CreateTable
CREATE TABLE "quotes" (
"id" SERIAL NOT NULL,
"timeStamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"buy" MONEY NOT NULL,
"sell" MONEY NOT NULL,
"type" "QuoteType" NOT NULL,
"difference" DECIMAL(9,2) NOT NULL DEFAULT 0,
"percentage" DECIMAL(9,2) NOT NULL DEFAULT 0,
"previousDayDifference" DECIMAL(9,2) NOT NULL DEFAULT 0,
"previousDayPercentage" DECIMAL(9,2) NOT NULL DEFAULT 0,
CONSTRAINT "quotes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quotes_history" (
"id" SERIAL NOT NULL,
"timeStamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"buy" MONEY NOT NULL,
"sell" MONEY NOT NULL,
"type" "QuoteType" NOT NULL,
CONSTRAINT "quotes_history_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,44 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum QuoteType {
BLUE
BNA
BELO
}
model Quote {
id Int @id @default(autoincrement())
timeStamp DateTime @default(now())
buy Decimal @db.Money
sell Decimal @db.Money
type QuoteType
difference Decimal @default(0) @db.Decimal(9, 2)
percentage Decimal @default(0) @db.Decimal(9, 2)
previousDayDifference Decimal @default(0) @db.Decimal(9, 2)
previousDayPercentage Decimal @default(0) @db.Decimal(9, 2)
@@map("quotes")
}
model QuoteHistory {
id Int @id @default(autoincrement())
timeStamp DateTime @default(now())
buy Decimal @db.Money
sell Decimal @db.Money
type QuoteType
@@map("quotes_history")
}

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;

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}