initial commit
This commit is contained in:
7
apps/backend/.gitignore
vendored
Normal file
7
apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
/web
|
||||
22
apps/backend/docker-entrypoint.sh
Normal file
22
apps/backend/docker-entrypoint.sh
Normal 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
33
apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
apps/backend/prisma.config.ts
Normal file
14
apps/backend/prisma.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
@@ -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")
|
||||
);
|
||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
44
apps/backend/prisma/schema.prisma
Normal file
44
apps/backend/prisma/schema.prisma
Normal 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
18
apps/backend/src/index.ts
Normal 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;
|
||||
19
apps/backend/src/lib/event-bus.ts
Normal file
19
apps/backend/src/lib/event-bus.ts
Normal 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();
|
||||
17
apps/backend/src/lib/logger.ts
Normal file
17
apps/backend/src/lib/logger.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
6
apps/backend/src/lib/prisma.ts
Normal file
6
apps/backend/src/lib/prisma.ts
Normal 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 });
|
||||
10
apps/backend/src/lib/utils.ts
Normal file
10
apps/backend/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
84
apps/backend/src/modules/quotes/belo.service.ts
Normal file
84
apps/backend/src/modules/quotes/belo.service.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
98
apps/backend/src/modules/quotes/blue.service.ts
Normal file
98
apps/backend/src/modules/quotes/blue.service.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
98
apps/backend/src/modules/quotes/bna.service.ts
Normal file
98
apps/backend/src/modules/quotes/bna.service.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
25
apps/backend/src/modules/quotes/handlers/fetchQuotes.ts
Normal file
25
apps/backend/src/modules/quotes/handlers/fetchQuotes.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
71
apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts
Normal file
71
apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts
Normal 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);
|
||||
}
|
||||
45
apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts
Normal file
45
apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts
Normal 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);
|
||||
}
|
||||
39
apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts
Normal file
39
apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts
Normal 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);
|
||||
}
|
||||
49
apps/backend/src/modules/quotes/handlers/quoteEvents.ts
Normal file
49
apps/backend/src/modules/quotes/handlers/quoteEvents.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
34
apps/backend/src/modules/quotes/quote.job.ts
Normal file
34
apps/backend/src/modules/quotes/quote.job.ts
Normal 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");
|
||||
}
|
||||
18
apps/backend/src/modules/quotes/quotes.routes.ts
Normal file
18
apps/backend/src/modules/quotes/quotes.routes.ts
Normal 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;
|
||||
11
apps/backend/tsconfig.json
Normal file
11
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user