INITIAL COMMIT

This commit is contained in:
maximo tejeda 2024-07-21 11:30:05 -04:00
commit 198a67fbff
69 changed files with 2103 additions and 0 deletions

0
.dockerignore Normal file
View File

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
k8s/deployment.yml
bin/
dolardb/

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM debian:unstable-slim
ARG BINAME=us-dop-api-linux-arm64-0.0.0_1
RUN mkdir /app
COPY ./assets/ /app/assets/
COPY ./bin/${BINAME} /usr/bin/us-dop-api
WORKDIR /app
CMD ["us-dop-api"]

61
Makefile Normal file
View File

@ -0,0 +1,61 @@
# must create a .env file with info
# must have compose installed
include .env
export
OS:=${shell go env GOOS}
ARCH=$(shell go env GOARCH)
OOSS="linux"
ARRCHS="arm 386"
DEBUG=1
SERVICE=us-dop-api
VERSION=0.0.0_1
BINAME=$(SERVICE)-$(OS)-$(ARCH)-$(VERSION)
BINAMEARM=$(SERVICE)-$(OS)-arm64-$(VERSION)
# can be docker or podman or whatever
CONTAINERS=docker
COMPOSE=$(CONTAINERS)-compose
# Configure local registry
REGADDR=192.168.0.151:32000
K8SRSNAME=$(shell kubectl get rs --no-headers -o custom-columns=":metadata.name" | grep us-dop-api)
.phony: all clean build test clean-image build-image build-image-debug run-image run-image-debug run-local
build-image:
# here we made the images and push to registry with buildx
@$(CONTAINERS) buildx build --platform linux/arm64 --push -t $(REGADDR)/$(SERVICE):latest .
# Here we upload it to local
run-image: build-image
@$(CONTAINERS) compose -f docker-compose.yaml up
build-image-debug: clean
@$(CONTAINERS) compose -f docker-compose-debug.yaml build
run-image-debug: build-image-debug
@$(CONTAINERS) compose -f docker-compose-debug.yaml up
run-local:clean build
@bin/$(BINAME)
build:
@mkdir dolardb || true
@env GOOS=$(OS) GOARCH=$(arch) go build -o ./bin/$(BINAME) ./cmd/api/.
@env GOOS=$(OS) GOARCH=arm64 go build -o ./bin/$(BINAMEARM) ./cmd/api/.
create-descriptors:
@envsubst < k8s/deployment.yml.template > k8s/deployment.yml
deploy: build-image create-descriptors
@kubectl apply -f k8s/deployment.yml
@kubectl scale rs $(K8SRSNAME) --replicas=0
@kubectl scale rs $(K8SRSNAME) --replicas=1
test:
@go -count=1 test ./...
clean:
@rm -rf ./bin
clean-image:
@$(CONTAINERS) system prune -f

124
adapters/dolar/dolar.go Normal file
View File

@ -0,0 +1,124 @@
package dolar
import (
"context"
"time"
"github.com/maximotejeda/msvc-proto/golang/dolar"
"github.com/maximotejeda/us_dop_api/domain"
"google.golang.org/grpc"
)
type Adapter struct {
dolar dolar.DollarClient
conn *grpc.ClientConn
}
func NewAdapter(conn *grpc.ClientConn) (*Adapter, error) {
client := dolar.NewDollarClient(conn)
return &Adapter{dolar: client, conn: conn}, nil
}
func (a *Adapter) GetLatest(name string) (*domain.History, error) {
hr, err := a.dolar.GetLatest(context.Background(), &dolar.GetLatestRequest{Name: name})
if err != nil {
return nil, err
}
date := time.Unix(hr.Actual.Parsed, 0)
loc, _ := time.LoadLocation("America/Santo_Domingo")
history := &domain.History{
ID: hr.Actual.Id,
Institution: domain.Institution{
Name: hr.Actual.Name,
},
Compra: float64(hr.Actual.Compra),
Venta: float64(hr.Actual.Venta),
Parser: hr.Actual.Parser,
Parsed: date.In(loc).Format(time.DateTime),
}
return history, nil
}
func (a Adapter) GetSince(name string, duration int64) (list []*domain.History, err error) {
hrl, err := a.dolar.GetSince(context.Background(), &dolar.GetSinceRequest{
Name: name,
Duration: duration,
})
if err != nil {
return nil, err
}
list = []*domain.History{}
for _, hr := range hrl.Histories {
date := time.Unix(hr.Parsed, 0)
loc, _ := time.LoadLocation("America/Santo_Domingo")
hist := &domain.History{
ID: hr.Id,
Institution: domain.Institution{
Name: hr.Name,
},
Compra: float64(hr.Compra),
Venta: float64(hr.Venta),
Parser: hr.Parser,
Parsed: date.In(loc).Format(time.DateTime),
}
list = append(list, hist)
}
return list, nil
}
func (a Adapter) GetInstByType(name string) (list []string, err error) {
hrl, err := a.dolar.GetInstByType(context.Background(), &dolar.GetInstByTypeRequest{
Name: name,
})
if err != nil {
return nil, err
}
list = []string{}
list = append(list, hrl.InstList...)
return list, nil
}
func (a Adapter) Subscribe(tgbid int64, instName string) (bool, error) {
ctx := context.Background()
_, err := a.dolar.TGBSubscribe(ctx, &dolar.TGBSubscribeRequest{TgbId: tgbid, InstName: instName})
if err != nil {
return false, err
}
return true, nil
}
func (a Adapter) Unsubscribe(tgbid int64, instName string) (bool, error) {
ctx := context.Background()
_, err := a.dolar.TGBUnsubscribe(ctx, &dolar.TGBUnsubscribeRequest{TgbId: tgbid, InstName: instName})
if err != nil {
return false, err
}
return true, nil
}
func (a Adapter) GetSubscribedUsers(instName string) ([]int64, error) {
ctx := context.Background()
users, err := a.dolar.TGBGetSubscribedUsers(ctx, &dolar.TGBGetSubscribedUsersRequest{InstName: instName})
if err != nil {
return nil, err
}
list := []int64{}
list = append(list, users.TgbIds...)
return list, nil
}
func (a Adapter) GetSubscribedInsts(tgbid int64) ([]string, error) {
ctx := context.Background()
insts, err := a.dolar.TGBGetSubscribedInsts(ctx, &dolar.TGBGetSubscribedInstRequest{TgbId: tgbid})
if err != nil {
return nil, err
}
list := []string{}
list = append(list, insts.InstName...)
return list, nil
}

124
assets/css/comparer.css Normal file
View File

@ -0,0 +1,124 @@
.hidden{
display: none;
}
#compare-page{
#toogleinst{
display: flex;
justify-content: right;
align-content: center;
gap: 20px;
color: var(--root-color, white);
margin-bottom: 10px;
border-bottom: 1px solid var(--root-border-color, white);
background-color: var(--root-bg-color-secondary, hsla(250, 100%, 3%, 1));
button {
width: 25px;
height: 25px;
margin-right: 10px;
border: 1px solid var(--root-border-color, white);
background-color: var(--root-bg-color-secondary, black);
align-self: center;
color: var(--root-color, white);
border-radius: 4px;
}
}
#response-institution {
#inst-result-list-container {
#inst-result-list{
display: flex;
list-style: none;
gap: 5px;
flex-wrap: wrap;
.card{
.inst-link{
width: 86px;
height: 33px;
img{
width: 78px;
height: 28px;
}
}
}
} /* end inst-result-list */
} /* end inst-result-list-container */
div{
display: flex;
align-items: center;
flex-flow: row wrap;
gap: 5px;
div#control-compra-venta {
display: flex;
flex: 0 0 100%;
justify-content: center;
}
div#control-cantidad{
display: flex;
flex: 0 0 100%;
justify-content: center;
input{
border: none;
border-bottom: 1px solid var(--root-border-color, white);
background-color: var(--root-bg-color-secondary, hsla(250, 50%, 20%, 0.3));
color: var(--root-color, white);
font-size: large;
width: 10ch;
}
}
div#operation-description{
display: flex;
flex: 0 0 100%;
justify-content: center;
}
}
#show-price-container{
justify-content: center;
div#container-comparer-table{
justify-content: center;
img{
width: 78px;
height: 28px;
background-color: var( --root-bg-color-img, white);
}
table{
thead th:nth-child(1) {
width: 30%;
}
thead th:nth-child(2) {
width: 15%;
text-align: center;
}
thead th:nth-child(3) {
width: 15%;
text-align: center;
}
thead th:nth-child(4) {
width: 10%;
text-align: right;
}
thead th:nth-child(5) {
width: 30%;
text-align: left;
}
/* body content */
tbody tr td:nth-child(1),tbody tr td:nth-child(2), tbody tr td:nth-child(3) {
text-align: center;
}
tbody tr td:nth-child(4){
text-align: right;
}
tbody tr td:nth-child(5){
text-align: left;
}
}
}
}
} /* end response-institution */
} /* compare-page end */

291
assets/css/main.css Normal file
View File

@ -0,0 +1,291 @@
/* hello */
:root{
--root-bg-color: black;
--root-bg-color-secondary: hsla(250, 50%, 20%, 0.2);
--root-bg-color-secondary-tewwo: hsla(250, 50%, 95%, 0.2);
--root-color: white;
--root-border-color: white;
--root-bg-color-btn: rgba(50, 50, 50, 0.5);
--root-bg-color-btn-hoover: rgb(50 50 50);
--root-bg-color-btn-focus: rgb(50 50 50);
--root-bg-color-img: white;
--root-shadow-color-btn-opened: rgba(0, 250, 0, 0.5);
--root-shadow-color-btn-cloded: rgba(250, 0, 0, 0.5);
--root-shadow-color-img: rgb(50 100 50);
}
.toggle {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
background-color: hsla(100, 50%, 0%, 0.1);
border-radius: 17px;
border: 1px solid gray;
font-size: x-small;
}
.toggle:after{
content: '';
text-align: center;
text-justify: center;
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: gray;
top: 0px;
left: 0px;
transition: all 0.5s;
}
.checkbox:checked + .toggle::after {
left : 20px;
}
.checkbox:checked + .toggle {
background-color: white;
}
.checkbox {
display : none;
}
body {
margin: 0px;
padding: 0px;
background-color: var(--root-bg-color, black);
color: var(--root-color, white);
#app {
#root{
p {
margin-left: 10px;
}
#root-controls{
#toogle-root-controls{
display: flex;
justify-content: right;
align-content: center;
gap: 20px;
color: var(--root-color, white);
margin-bottom: 10px;
border-bottom: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-secondary, black);
#toogle-root-contols-btn{
width: 25px;
height: 25px;
margin-right: 10px;
align-self: center;
border: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-btn, black);
color: var(--root-color, white);
border-radius: 4px;
}
#theme-container{
margin-right: auto;
margin-left: 10px;
align-self: center;
}
}
#root-select-control{
display: flex;
gap: 15px;
justify-content: center;
button{
align-self: center;
border: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-btn, black);
color: var(--root-color, white);;
border-radius: 4px;
font-size: larger;
}
}
}
#inst-selector {
#toogle-hist-control {
display: flex;
justify-content: right;
align-content: center;
gap: 20px;
color: var(--root-color, white);;
margin-bottom: 10px;
border-bottom: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-secondary, black);
button{
width: 25px;
height: 25px;
margin-right: 10px;
border: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-btn, black);
align-self: center;
color: var(--root-color, white);;
border-radius: 4px;
}
}
#inst-type-selector{
display: flex;
gap: 6px;
align-content: center;
justify-content: center;
height: 35px;
button{
background-color: var(--root-bg-color-btn, black);
/* color: #fff; */
/* border: none; */
/* border-radius: 10px; */
/* box-shadow: 0px 1px 3px 2px rgb(90 90 90); */
margin-top: 10px;
margin-right: 6px;
width: 70px;
border: 1px solid var(--root-border-color, white);;
background-color: var(--root-bg-color-btn, black);
align-self: center;
color: var(--root-color, white);;
border-radius: 4px;
}
} /* <- end #inst-type-selector */
#response-div{
height: 70;
#inst-result-list-container{
#inst-result-list {
display: flex;
flex-direction: row;
list-style-type: none;
flex-wrap: wrap;
gap: 10px;
.card {
.inst-link{
width: 86px;
height: 33px;
img{
width: 78px;
height: 28px;
}
}
}
}
}
} /* end #response-div*/
#response-inst-card{
display: flex;
width: 370px;
flex-flow: column;
align-items: center;
#inst-selected{
}
form{
width: 360px;
border-radius: 6px;
#inst-selected{
padding: 5px;
display: flex;
justify-content: center;
img {
/* originally the dimensions where width: 155px, height: 55px */
background-color: var( --root-bg-color-img, white);;
padding: 2px;
width: 93px;
height: 33px;
border-radius: 10px;
box-shadow: 0px 0px 3px 4px var(--root-shadow-color-img, rgb(50 100 50));
}
} /* inst-selected */
fieldset{
border-radius: 8px;
margin-top: 5px;
border-width: 0.5px;
box-shadow: 0px 2px 3px 2px rgb(150 100 100);
border-width: 0px;
#tf-container{
margin: 10px 0px;
background: linear-gradient(to bottom, rgb( 70 70 70 / 60%), rgb(150 150 150 / 30%));
padding: 3px;
border-radius: 10px;
}
} /* end fieldset */
label#time-amount-selector{
width: 93px;
display: inline-block;
}
input#button-submit{
margin-top: 0px;
margin-left: 20px;
border-radius: 10px;
}
}
}
}
#response-inst-table-container{
display: flex;
table{
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border: 2px solid var(--root-border-color, white);;
thead th:nth-child(1) {
width: 19%;
}
thead th:nth-child(2) {
width: 15%;
}
thead th:nth-child(3) {
width: 19%;
}
thead th:nth-child(4) {
width: 15%;
}
thead th:nth-child(5) {
width: 32%;
}
td, th{
text-align: center;
padding: 4px;
span.green{
color: green;
}
span.red{
color: red;
}
}
tfoot th {
text-align: left;
}
thead,
tfoot {
/* background: url(leopardskin.jpg); */
color: var(--root-color, white);;
text-shadow: 1px 1px 1px black;
}
thead th, tfoot th, tfoot td {
background: linear-gradient(to bottom, rgb( 0 0 70 / 10%), rgb(100 100 100 / 60%));
border: 3px solid hsla(270, 100%, 20%, 0.1);
}
}
}
}
}
}
.opened{
box-shadow: 0px 2px 2px 2px var(--root-shadow-color-btn-opened, green);
}
.closed{
box-shadow: 0px 1px 1px 1px var(--root-shadow-color-btn-closed, red);
}

0
assets/html/index.html Normal file
View File

12
assets/html/other.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>app</title>
</head>
<body>
<div id="app"><p>this is the app</p></div>
</body>
</html>

BIN
assets/img/a-cibao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/img/a-peravia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/img/a-popular.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
assets/img/acn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
assets/img/banco-ademi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/img/banco-bdi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
assets/img/banco-caribe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/img/banco-lafise.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/img/banesco-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

BIN
assets/img/banesco.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
assets/img/banreservas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/img/capla.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/img/gamelin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/img/girosol.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/img/moneycorps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/img/rm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/img/scotiabank.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/img/sct.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
assets/img/taveras.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

5
assets/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

65
assets/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

163
assets/js/main.js Normal file
View File

@ -0,0 +1,163 @@
function enableSelector(e){
const value = document.querySelector("#ta-value");
const input = document.querySelector("#ta-amount");
const instSelectors = document.querySelector("#response-inst-card");
const instNameSelector = document.querySelector("#inst-name-selector");
value.textContent = input.value;
input.addEventListener("input", (event) =>{
value.textContent = event.target.value;
});
const name = document.querySelector("#inst-name-selector");
console.log(e.target.name);
name.value = e.target.name;
instSelectors.hidden=false;
}
document.addEventListener('alpine:init', ()=>{
Alpine.data('app', ()=>({
open: false,
instSelected: '',
instSelectedImage: '',
tf: '',
ta: '',
compvalue: 1,
compoperation: 'compra',
buttoninst: {},
theme: 'dark',
get urler() { return '/api/instituciones/history';},
// print representation of search history on form
get queryRepr() {
if (this.instSelected === ""|| this.tf === ""){
console.log(this.instSelected, this.tf);
return "Seleciona institucion y periodo de tiempo";
}
const tc = document.querySelector("#response-inst-table-container");
let tfstr = '';
switch (this.tf){
case 'week':
tfstr = 'semanas';
break;
case 'day':
tfstr = 'dias';
break;
case 'hour':
tfstr = 'horas';
break;
case 'month':
tfstr = 'meses';
break;
}
return `Busqueda: desde hace ${this.ta} ${tfstr} en ${this.instSelected}`;
},
// return table to empty content con history
resetTableC(){
const tc = document.querySelector("#response-inst-table-container");
tc.innerHTML = "";
},
// change div visibility on comparer
toglediv(e){
// lets change button content
let clase = "id-" + e.target.name;
const container = document.querySelector(`#${clase}`);
console.log(clase);
if (container.classList.contains("hidden")) {
container.classList.remove('hidden');
}else {
container.classList.add('hidden');
}
},
// calculate value on a table depending on options provided
calcvalue(e){
const quantity = e.target.value;
const operation = document.getElementById("comparar-operacion");
const containers = document.getElementsByClassName("comparer-row");
if ( containers.length === 0){
return;
}
for (let cont of containers){
const compra = document.getElementById(cont.id+"-compra");
const venta = document.getElementById(cont.id+"-venta");
const result = document.getElementById(cont.id+"-res");
const currency = document.getElementById(cont.id+"-cur");
if (isNaN(this.compvalue)|| this.compvalue == 0){
console.log("is not a number", this.compvalue);
return;
}
switch (this.compoperation){
case "compra":
result.innerHTML = Number((this.compvalue / venta.innerHTML).toFixed(2)).toLocaleString();
currency.innerHTML = "$ US";
break;
case "venta":
result.innerHTML = Number((compra.innerHTML * this.compvalue).toFixed(2)).toLocaleString();
currency.innerHTML = "$ RD";
break;
}
// comparemos si compra y venta son numeros
}
},
get reprCompOperation(){
switch (this.compoperation){
case "compra":
return `Comprando dolares con ${Number(this.compvalue).toFixed(2).toLocaleString()} $RD pesos.`;
break;
case "venta":
return `Vendiendo ${Number(this.compvalue).toFixed(2).toLocaleString()} dolares con cambios a $RD pesos.`;
break;
};
},
themeChange(e){
let r = document.documentElement.style;
switch (e.target.checked){
case false:
r.setProperty('--root-bg-color', 'black');
r.setProperty("--root-bg-color-secondary", "hsla(250, 50%, 20%, 0.2);");
r.setProperty("--root-border-color", "white");
r.setProperty("--root-color", "white");
r.setProperty("--root-bg-color-btn", "rgba(50, 50, 50, 0.5)");
r.setProperty("--root-bg-color-btn-hoover", "rgb(50 50 50)");
r.setProperty("--root-bg-color-btn-focus", "rgb(50 50 50)");
r.setProperty("--root-bg-color-img", "white");
r.setProperty("--root-shadow-color-btn-opened", "rgba(0, 250, 0, 0.5)");
r.setProperty("--root-shadow-color-btn-cloded", "rgba(250, 0, 0, 0.5)");
r.setProperty("--root-shadow-color-img", "rgb(50 100 50)");
console.log(this.theme);
break;
case true:
r.setProperty('--root-bg-color', 'white');
r.setProperty("--root-bg-color-secondary", "hsla(250, 50%, 85%, 0.2)");
r.setProperty("--root-color", "black");
r.setProperty("--root-border-color", "black");
r.setProperty("--root-bg-color-btn", "rgba(80, 80, 80, 0.2)");
r.setProperty("--root-bg-color-btn-hoover", "rgb(50 50 50)");
r.setProperty("--root-bg-color-btn-focus", "rgb(50 50 50)");
r.setProperty("--root-bg-color-img", "white");
r.setProperty("--root-shadow-color-btn-opened", "rgba(0, 250, 0, 0.5)");
r.setProperty("--root-shadow-color-btn-cloded", "rgba(250, 0, 0, 0.5)");
r.setProperty("--root-shadow-color-img", "rgb(50 100 50)");
console.log(this.theme);
break;
}
},
}));
});
document.body.addEventListener("htmx:afterSwap",(e)=>{
const page = document.querySelector("#page");
htmx.process(e.target);
});

View File

@ -0,0 +1,76 @@
{{ define "card" }}
{{ if ne .InstRequested "" }}
{{ $inst := .InstRequested }}
{{ range .Results }}
<div class="card">
<p class="inst-name">
{{ .Name }}
<span class="institucion">{{ $inst }}</span>
</p>
<a class="inst-link" href="/api/instituciones/{{ .Institution.Name }}/history?ta=4&tf=week&format=html">{{ .Institution.Name }}</a>
</div>
{{ end }}
{{ end }}
{{ end }}
<!-- definicion de lista -->
{{ define "list" }}
{{ if ne .InstRequested "" }}
{{ $inst := .InstRequested }}
<div id="inst-result-list-container" {{if eq $inst "all"}} x-show="comparerpage" {{ end }}>
<ul id="inst-result-list">
{{ range $idx, $obj := .Results }}
{{$idname := .Institution.Name | replace " " "-" }}
<li class="card">
{{ $name := .Institution.Name | replace " dominicano" "" | replace "asociacion " "a "| replace " de ahorros y prestamos" ""| replace " cambio online" "" | replace " " "-" }}
{{ $idname := .Institution.Name| replace " " "-" }}
<button
class="inst-link btn-{{ $idx }}"
name="{{ $idname }}"
style='{{ if eq $idname "banco-vimenca" }} background-color:#1789E1 {{ end }}'
{{ if ne $inst "all" }}
href="/api/instituciones/{{ $obj.Institution.Name }}/history?ta=4&tf=week&format=html"
x-bind:class='instSelected === "{{ $obj.Institution.Name }}" ? "opened" : ""'
@click='
open = true;
instSelected = "{{ $obj.Institution.Name }}";
instSelectedImage = "/static/img/{{ $name }}.png";
resetTableC();
'
{{ else }}
x-init="buttoninst['{{ $idname }}'] = false;"
@click='toglediv; buttoninst["{{ $idname }}"] = ! buttoninst["{{ $idname }}"];'
x-bind:class="buttoninst['{{ $idname }}'] ? 'opened' : '' "
href="/"
hx-target="id-{{ .Institution.Name }}"
{{ end }}
>
<img class="img-{{ $idx }}" name="{{ $idname }}" src='/static/img/{{ $name }}.png' alt="bank logo" >
</button>
</li>
{{ end }}
</ul>
</div>
{{ if eq $inst "all" }}
<div>
<div id="control-compra-venta">
<label>Compra</label>
<input type="radio" value="compra" @change="calcvalue" x-on:input="compoperation = 'compra'" checked name="operation">
<label>Venta</label>
<input type="radio" value="venta" @change="calcvalue" x-on:input="compoperation = 'venta'" name="operation">
</div>
<div id="control-cantidad">
<label>Cantidad</label>
<input @change="calcvalue" x-model="compvalue" x-on:input="calcvalue" inputmode="numeric" maxlength="7" pattern="[0-9.]{1,7}">
</div>
<div id="operation-description">
<p x-text="reprCompOperation"></p>
</div>
</div>
<div id="show-price-container" hx-get="/api/latest/all" hx-trigger="load delay:1sec">
</div>
{{ end }}
{{ end }}
{{ end }}

View File

@ -0,0 +1,124 @@
{{ define "card-history" }}
{{ if ne .InstRequested "" }}
{{ $inst := .InstRequested }}
{{ range .Results }}
<div class="card">
<p class="inst-name">
{{ .Institution.Name }}
<span class="institucion">{{ $inst }}</span>
</p>
<a class="inst-link" href="/api/instituciones/{{ .Institution.Name }}/history?ta=4&tf=week&format=html">{{ .Institution.Name }}</a>
</div>
{{ end }}
{{ end }}
{{ end }}
<!-- definicion de lista -->
{{ define "list-history" }}
{{ if ne .InstRequested "" }}
{{ $inst := .InstRequested }}
<div>
<p>{{ $inst }}</p>
<ul>
{{ range $idx, $obj := .Results }}
<li class="lista item-{{ $idx }}">
<p class="inst-name">
<span class="compra inst list">Compra: RD. {{ $obj.Compra }} </span>
<span class="venta inst list">Venta: RD. {{ $obj.Venta }} </span>
<span class="time inst list">Fecha: {{ $obj.Parsed }} </span>
<span class="parser inst list">Parser: {{ $obj.Parser }} </span>
</p>
</li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}
<!-- crate table -->
{{ define "table-history" }}
{{ if ne .InstRequested "" }}
<table class="inst {{ .InstRequested }}" name="{{ .InstRequested }}">
<caption>Historico de precios para {{ .InstRequested }} los precios se encuentran en la moneda local de Republica Dominicana RD.</caption>
<thead>
<tr>
<!-- <th>Nombre</th> -->
<th>Compra</th>
<th>+/-</th>
<th>Venta</th>
<th>+/-</th>
<th>Fecha</th>
<th class="table-parser" hidden="true">Parser</th>
</tr>
</thead>
<tbody>
{{ $acumComp := 0.0 }}
{{ $acumVent := 0.0 }}
{{ $actualComp := 0.0 }}
{{ $actualVent := 0.0 }}
{{ $antComp := 0.0 }}
{{ $antVent := 0.0 }}
{{ range $idx, $obj := .Results }}
<tr class="row-{{ $idx }}">
<td>
{{ if eq $idx 0}}
{{ $actualComp = $obj.Compra }}
{{ $actualVent = $obj.Venta }}
{{ end }}
<span id="row-{{ $idx }}-compra" class="compra amount">{{ $obj.Compra | printf "%0.2f"}}</span>
</td>
<td >
{{ if ne $antComp 0.0 }}
{{ $tmpVarCompra := mult $obj.Compra -1.0 | add $antComp}}
<span class="compra diff {{if gt $tmpVarCompra 0.0 }} green {{ else if lt $tmpVarCompra 0.0 }} red {{end}}">{{ $tmpVarCompra | printf "%10.2f" }} </span>
{{ end }}
</td>
<td>
<span id="row-{{ $idx }}-venta" class="venta amount">{{ $obj.Venta | printf "%0.2f" }}</span></td>
<td >
{{ if ne $antVent 0.0 }}
{{ $tmpVarVenta := mult $obj.Venta -1.0 | add $antVent }}
<span class="venta diff {{if gt $tmpVarVenta 0.0 }} green {{ else if lt $tmpVarVenta 0.0 }} red {{end}}">{{ $tmpVarVenta | printf "%10.2f" }}</span>
{{ end }}
</td>
<td> <span class="fecha">{{ $obj.Parsed }}</span></td>
<td class="table-parser" hidden="true">{{ $obj.Parser }}</td>
</tr>
{{ $acumComp = add $acumComp $obj.Compra }}
{{ $acumVent = add $acumVent $obj.Venta }}
{{ $antComp = $obj.Compra }}
{{ $antVent = $obj.Venta }}
{{ end }}
{{ if le (len .Results) 0 }}
<tr>
<th>Error</th><td>Sin historico en este periodo</td>
</tr>
{{end}}
</tbody>
<tfoot>
<!-- Calculo de la acumulacion de los distintos valores media y diferencia -->
<!-- solo muestra footer si el nunero de celdas es mayor a 1 -->
{{if gt (len .Results) 1 }}
<tr>
<th>C Media</th>
{{ $mediaComp := len .Results | div $acumComp}}
{{ $diffMediaCompra:= mult $actualComp -1.0 | add $mediaComp | mult -1.0 }}
<td><span class="compra media">{{ $mediaComp | printf "%.2f" }}</span></td>
<th>V Media</th>
{{ $mediaVent := len .Results | div $acumVent }}
{{ $diffMediaVent := mult $actualVent -1.0 | add $mediaVent | mult -1.0}}
<td><span class="venta media">{{ $mediaVent | printf "%.2f" }}</span></td>
</tr>
<tr>
<th id="inst-table-dcompra-header">C Diff </th>
<td id="inst-table-dcompra-data"><span class="compra media diff {{if gt $diffMediaCompra 0.0 }} green {{ else }}red{{end}}"> {{ $diffMediaCompra | printf "%0.3f" }}</span></td>
<th id="inst-table-dventa-header">V Diff</th>
<td id="inst-table-dventa-data"> <span class="venta media diff {{if gt $diffMediaVent 0.0 }} green {{ else }}red{{end}}">{{ $diffMediaVent | printf "%0.3f" }}</span></td>
</tr>
{{ end }}
</tfoot>
</table>
{{ end}}
{{ end }}

View File

@ -0,0 +1,37 @@
{{ define "latest-price" }}
<div id="container-comparer-table" class="comparer-container">
<table>
<thead>
<th> Institucion</th>
<th>Compra</th>
<th>Venta</th>
<th>Moneda</th>
<th> Aprox </th>
</thead>
<tbody>
{{ range . }}
{{ $idname := .Institution.Name | replace " " "-"}}
{{ $name := .Institution.Name | replace " dominicano" "" | replace "asociacion " "a "| replace " de ahorros y prestamos" ""| replace " cambio online" "" | replace " " "-" }}
<tr id="id-{{ $idname }}" class="comparer-row hidden">
<td><img class="compare-img img-{{ $idname }}" name="{{ $idname }}" src='/static/img/{{ $name }}.png' alt="bank logo" > </td>
<!-- <label>{{ .Institution.Name }}</label> -->
<td id="id-{{ $idname }}-compra" class="comparer compra">{{ .Compra | printf "%.2f" }}</td>
<td id="id-{{ $idname }}-venta" class="comparer venta">{{ .Venta | printf "%.2f" }}</td>
<td id="id-{{ $idname }}-cur" class="comparer currency"></td>
<td id="id-{{ $idname }}-res">0.00</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
{{ define "all-latest-price" }}
<!-- {{ range $idx, $obj := . }} -->
{{ template "latest-price" . }}
<!-- {{ end }} -->
{{ end }}

View File

@ -0,0 +1,81 @@
{{ define "index" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/comparer.css">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
<!-- <script src="/static/js/htmx.min.js"></script> -->
<!-- <script defer src="/static/js/alpine.min.js" ></script> -->
<script src="//unpkg.com/alpinejs" defer></script>
<title>app</title>
</head>
<body>
<div id="app"
x-data="app">
<div id="root">
<div id="root-controls" x-data="{rootcontrols: true}">
<div id="toogle-root-controls">
<div id="theme-container">
<input
type="checkbox"
id="theme-switch"
class="checkbox"
@change="themeChange"
>
<label for="theme-switch" class="toggle" id="theme-toggle-label">
</div>
</label>
<p x-text="rootcontrols ? 'Ocultar Operaciones' : 'Mostrar Operaciones'"></p>
<button
id="toogle-root-contols-btn"
@click="rootcontrols = !rootcontrols"
x-html="rootcontrols ? '-' : '+'"
x-bind:class="rootcontrols ? 'opened' : 'closed'"
>+</button>
</div>
<div id="root-select-control" x-show="rootcontrols"
x-data="{
select: false,
compare: false,
}">
<button class=""
x-ref="historybtn"
hx-get="/api/control/select"
hx-target="#page"
@click="
select = true;
compare = false;
$refs.historybtn.disabled = true;
$refs.comparebtn.disabled = false;
"
x-bind:class="select ? 'opened' : ''"
>Historico</button>
<button class=""
x-ref="comparebtn"
hx-get="/api/control/compare"
hx-target="#page"
@click="
compare = true;
select = false;
$refs.historybtn.disabled = false;
$refs.comparebtn.disabled = true;
"
x-bind:class="compare ? 'opened' : ''"
>Comparar</button>
</div>
</div>
<div id="page">
</div>
</div>
</div>
</body>
<script src="/static/js/main.js"></script>
</html>
{{ end }}

View File

@ -0,0 +1,147 @@
{{ define "hist-page" }}
<div id="hist-page"
x-data="{histcontrol: true, bancosselector: false, cajasselector: false, agentesselector: false }"
>
<div id="inst-selector">
<div id="toogle-hist-control">
<p x-text="histcontrol ? 'Ocultar Control e Instituciones' : 'Mostrar Control e Instituciones'"></p>
<button
id="toogle-hist-control-btn"
@click="histcontrol = ! histcontrol"
x-html="histcontrol ? '-' : '+'"
x-bind:class="histcontrol ? 'opened' : 'closed'"
>+</button>
</div>
<div id="inst-type-selector" x-show="histcontrol" >
<button
x-ref="bancosselectbtn"
class="select bancos"
hx-get="/api/bancos/?format=html&res=list"
hx-target="#response-div"
hx-trigger="click delay:500ms"
@click="
bancosselector = true;
cajasselector = false;
agentesselector = false;
$refs.bancosselectbtn.disabled = true;
$refs.cajasselectbtn.disabled = false;
$refs.agentesselectbtn.disabled = false;
"
x-bind:class="bancosselector ? 'opened': ''"
>Bancos</button>
<button
x-ref="cajasselectbtn"
class="select cajas"
hx-get="/api/cajas/?format=html&res=list"
hx-target="#response-div"
hx-trigger="click delay:500ms"
@click="
bancosselector = false;
cajasselector = true;
agentesselector = false;
$refs.bancosselectbtn.disabled = false;
$refs.cajasselectbtn.disabled = true;
$refs.agentesselectbtn.disabled = false;
"
x-bind:class="cajasselector ? 'opened': ''"
>Cajas</button>
<button
x-ref="agentesselectbtn"
class="select agentes"
hx-get="/api/agentes/?format=html&res=list"
hx-target="#response-div"
hx-trigger="click delay:500ms"
@click="
bancosselector = false;
cajasselector = false;
agentesselector = true;
$refs.bancosselectbtn.disabled = false;
$refs.cajasselectbtn.disabled = false;
$refs.agentesselectbtn.disabled = true;
"
x-bind:class="agentesselector ? 'opened': ''"
>Agentes</button>
</div>
<div id="response-div" x-show="histcontrol">
</div>
<div id="response-inst-card" x-show="open">
<form x-ref="form"
x-bind:hx-post="urler"
hx-target="#response-inst-table-container"
>
<div
id="inst-selected">
<img
x-bind:style='instSelected === "banco vimenca" ? "background-color: #1789E1": ""'
x-bind:name="instSelected"
x-bind:src='instSelectedImage'
x-bind:hx-post="urler"
hx-trigger="load delay:500ms"
alt="bank logo" >
</div>
<fieldset>
<div>
<input id="inst-name-selector" hidden="true" name="name" x-bind:value="instSelected" required>
<input id="inst-res-selector" hidden="true" name="res" value="table">
<input id="inst-format-selector" hidden="true" name="format" value="html">
</div>
<div id="tf-container">
<input id="hours" type="radio" name="tf" value="hour" @change="e=>tf='hour'" >
<label for="hours">Horas</label>
<input id="days" type="radio" name="tf" value="day" @change="e=>tf='day'" required checked>
<label for="days">Dias</label>
<input id="weeks" type="radio" name="tf" value="week" @change="e=>tf='week'" >
<label for="weeks">Semanas</label>
<input id="months" type="radio" name="tf" value="month" @change="e=>tf='month'" >
<label for="months">Meses</label>
</div>
<div>
<label id="time-amount-selector" for="amount">Cantidad <span id="ta-value" x-model="ta" required></span></label>
<input x-bind:hx-post="urler" hx-trigger="change delay:1.5s" @change="e => ta = e.target.value" x-ref="ta" type="range" id="ta-amount" name="ta" min="1" max="12" list="markers" x-bind:value="ta">
<datalist id="markers">
<option value="2"></option>
<option value="4"></option>
<option value="6"></option>
<option value="8"></option>
<option value="10"></option>
<option value="12"></option>
</datalist>
<input id="button-submit" type="submit" value="GO">
</div>
</fieldset>
</form>
<div>
</div>
</div>
</div>
<p x-text="queryRepr">Template</p>
<div
id="response-inst-table-container">
</div>
<div id="response-table-graph">
</div>
</div>
{{ end }}
{{ define "compare-page" }}
<div id="compare-page" x-data="{comparerpage: true}">
<div id="toogleinst">
<p x-text="comparerpage ? 'Ocultar Instituciones' : 'Mostrar Instituciones'">Mostrar Instituciones</p>
<button
id="toogle-inst-btn"
@click="comparerpage = !comparerpage"
x-bind:class="comparerpage ? 'opened' : 'closed'"
x-html="comparerpage ? '-' : '+'"
>+</button>
</div>
<div id="response-institution" hx-get="/api/all/?format=html&res=list" hx-trigger="load delay:1s" >
</div>
</div>
{{ end }}

83
cmd/api/main.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"github.com/maximotejeda/us_dop_api/adapters/dolar"
"github.com/maximotejeda/us_dop_api/handlers"
"github.com/maximotejeda/us_dop_api/middlewares"
"github.com/maximotejeda/us_dop_api/static"
"github.com/maximotejeda/us_dop_bot/config"
"github.com/maximotejeda/us_dop_db/db"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
hostSTR = os.Getenv("HOST)")
portSTR = os.Getenv("PORT")
templ template.Template
)
func main() {
// Log creation
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}))
var opts []grpc.DialOption
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dolarConn, err := grpc.Dial(config.GetDollarServiceURL(), opts...)
if err != nil {
log.Error("creating gerpc conn", "error", err)
panic(err)
}
dol, err := dolar.NewAdapter(dolarConn)
if err != nil {
log.Error("creating service adapter", "error", err)
panic(err)
}
// templates
tmplDirStr := "assets/templates/"
ftmpl := os.DirFS(tmplDirStr)
toParseFiles := []string{}
// wlaks the Fs looking for templates
err = fs.WalkDir(ftmpl, ".", func(p string, d fs.DirEntry, err error) error {
log.Info("parsing", "file", p)
if filepath.Ext(p) == ".tmpl" {
toParseFiles = append(toParseFiles, tmplDirStr+p)
}
return nil
})
if err != nil {
panic(err)
}
// init database
dbe := db.Dial("dolardb/crawler.db", log)
// handler containing all the necessary files
root := handlers.NewRoot(dbe, dol, log, toParseFiles)
s := middlewares.NewLogger(http.HandlerFunc(static.ServeFiles), log)
r := middlewares.NewLogger(root, log)
mux := http.NewServeMux()
server := http.Server{
Addr: hostSTR + ":" + portSTR,
Handler: mux,
}
mux.Handle("/", r)
mux.Handle("GET /static/", s)
if err := server.ListenAndServe(); err != nil {
log.Error("while serving", "error", err)
}
}

25
config/config.go Normal file
View File

@ -0,0 +1,25 @@
package config
import (
"log"
"os"
)
func GetNatsURI() string {
return getEnvVariable("NATSURI")
}
func GetDollarServiceURL() string {
return getEnvVariable("DOLLAR_SERVICE_URL")
}
func GetEnvironment() string {
return getEnvVariable("ENV")
}
func getEnvVariable(key string) string {
if os.Getenv(key) == "" {
log.Fatal("error getting key", key)
}
return os.Getenv(key)
}

0
db/db.go Normal file
View File

17
domain/history.go Normal file
View File

@ -0,0 +1,17 @@
package domain
type History struct {
ID int64 `json:"id"`
Institution Institution `json:"institution"`
Compra float64 `json:"compra,omitempty"`
Venta float64 `json:"venta,omitempty"`
Parser string `json:"parser,omitempty"`
Parsed string `json:"parsed,omitempty"`
}
type Institution struct {
ID int64 `json:"id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Created int64 `json:"created"`
}

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module github.com/maximotejeda/us_dop_api
go 1.22.0
require (
github.com/maximotejeda/msvc-proto/golang/dolar v0.0.0-8 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 // indirect
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.34.0 // indirect
)

14
go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/maximotejeda/msvc-proto/golang/dolar v0.0.0-8 h1:ldphxrQiAhmctWBMCaNShDphZfHmOeKuoSWwCxV62Ho=
github.com/maximotejeda/msvc-proto/golang/dolar v0.0.0-8/go.mod h1:bAs0mlC1Vyn/BkHONL2Ik8ox9px9s9bhbJWgUQFMMWo=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 h1:DujSIu+2tC9Ht0aPNA7jgj23Iq8Ewi5sgkQ++wdvonE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=

52
handlers/api/api.go Normal file
View File

@ -0,0 +1,52 @@
package api
import (
"html/template"
"log/slog"
"net/http"
"time"
"github.com/maximotejeda/us_dop_api/domain"
"github.com/maximotejeda/us_dop_api/ports"
"github.com/maximotejeda/us_dop_db/db"
)
type API struct {
http.ServeMux
db *db.DB
dolar ports.DolarService
log *slog.Logger
templ template.Template
}
type SearchResult struct {
InstRequested string `json:"institution_requested"`
Date time.Time `json:"date"`
TimeAmount string `json:"time_amount"`
TimeFrame string `json:"time_frame"`
Results []*domain.History `josn:"results"`
}
func NewApiHandler(db *db.DB, dolar ports.DolarService, log *slog.Logger, templ template.Template) *API {
log = log.With("api", "insti")
api := &API{
db: db,
log: log,
dolar: dolar,
templ: templ,
}
api.HandleFunc("POST /api/instituciones/history", api.GetInstitutionHistory) // general handler to get banks, agents and others histories
// api.HandleFunc("GET /api/instituciones/{inst}", nil) // return the card of just one institution with details
api.HandleFunc("GET /api/{inst}/", api.GetInstNames)
api.HandleFunc("GET /api/latest/all", api.GetAllLatestPrice)
api.HandleFunc("GET /api/control/select", api.GetHistoryPage)
api.HandleFunc("GET /api/control/compare", api.GetComparisonPage)
return api
}
// instParseHTML
//
// Can be much tipe of html part of a list, a form, a card etc...
func instParseHTML(SearchResult) string {
return "html"
}

11
handlers/api/constrols.go Normal file
View File

@ -0,0 +1,11 @@
package api
import "net/http"
func (api *API) GetHistoryPage(w http.ResponseWriter, r *http.Request) {
api.templ.ExecuteTemplate(w, "hist-page", nil)
}
func (api *API) GetComparisonPage(w http.ResponseWriter, r *http.Request) {
api.templ.ExecuteTemplate(w, "compare-page", nil)
}

226
handlers/api/institution.go Normal file
View File

@ -0,0 +1,226 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"slices"
"strings"
"time"
"github.com/maximotejeda/us_dop_api/domain"
"github.com/maximotejeda/us_dop_api/helpers"
)
// handler to parse query to the api
func (api *API) GetInstitutionHistory(w http.ResponseWriter, r *http.Request) {
var (
timeSearch time.Duration
historyResult []*domain.History
)
err := r.ParseForm()
if err != nil {
api.log.Error("parsing form ", "error", err)
http.Error(w, "parsing form", http.StatusNotFound)
}
queryP := r.Form
timeAmount := queryP.Get("ta") // numeric amount
timeFrame := queryP.Get("tf") // minute, hour, day, week, month, now ago until now
resType := queryP.Get("format") // can be multiples html or a json
inst := queryP.Get("name")
resource := queryP.Get("res")
inst = strings.ToLower(inst)
// match time amount number
re := regexp.MustCompile(`[0-9]{1,3}`)
// api.log.Info("parsing inst", "name", inst)
if timeFrame != "now" {
if !re.Match([]byte(timeAmount)) {
api.log.Error("matching time", "error", "incorrect time amount")
http.Error(w, "incorrect time amount", http.StatusNotFound)
return
}
}
timeSearch, err = helpers.ParseTimeAmount(timeFrame, timeAmount)
if err != nil {
api.log.Error("parsing time ", "error", err)
http.Error(w, "incorrect time amount", http.StatusNotFound)
return
}
if timeFrame == "now" {
hist, err := api.dolar.GetLatest(inst)
//hist, err := api.db.GetLastPrice(inst)
if err != nil {
api.log.Error("GetLastPrice", "time Frame", "now", "error", err)
http.Error(w, "price not found", http.StatusNotFound)
return
}
historyResult = append(historyResult, hist)
} else {
historyResult, err = api.dolar.GetSince(inst, int64(timeSearch.Minutes()))
//historyResult, err = api.db.GetChangeSince(inst, timeSearch)
if err != nil {
api.log.Error("GetLastPrice", "time Frame", timeFrame, "amount", timeAmount, "error", err)
http.Error(w, "price not found for time frame", http.StatusNotFound)
return
}
api.log.Info("resultados", "=", historyResult)
}
searchRes := SearchResult{
InstRequested: inst,
Date: time.Now(),
TimeAmount: timeAmount,
TimeFrame: timeFrame,
Results: historyResult,
}
// check if we want a json
if resType == "html" {
switch resource {
case "card":
err = api.templ.ExecuteTemplate(w, "card-history", searchRes)
case "list":
// by default we crate simple query
// we need a way to distinguish from mixed or more complex query
err = api.templ.ExecuteTemplate(w, "list-history", searchRes)
case "table":
// by default we crate simple query
// we need a way to distinguish from mixed or more complex query
err = api.templ.ExecuteTemplate(w, "table-history", searchRes)
}
if err != nil {
api.log.Error("History", "error", err)
http.Error(w, "history error", http.StatusNotFound)
}
} else {
data, _ := json.Marshal(searchRes)
w.Write(data)
}
}
// GetBanks
// as bank will only return a list of names
// we can assume we need a list of some html to the app
func (api *API) GetInstNames(w http.ResponseWriter, r *http.Request) {
queryP := r.URL.Query()
fType := queryP.Get("format")
resource := queryP.Get("res")
inst := r.PathValue("inst")
w.Header().Set("Etag", "institutions")
w.Header().Set("Cache-Control", "max-age=3600")
var institutions, err = []string{}, *new(error)
switch inst {
case "bancos", "banco":
inst = "bancos"
institutions, err = api.dolar.GetInstByType("bancos")
case "caja", "cajas":
inst = "cajas"
institutions, err = api.dolar.GetInstByType("cajas")
case "agente", "agentes":
inst = "agentes"
institutions, err = api.dolar.GetInstByType("agentes")
case "all":
bancos, _ := api.dolar.GetInstByType("bancos")
cajas, _ := api.dolar.GetInstByType("cajas")
agentes, _ := api.dolar.GetInstByType("agentes")
inst = "all"
institutions = slices.Concat(bancos, cajas, agentes)
default:
http.NotFound(w, r)
return
}
if err != nil {
api.log.Error("GetInstName", "error", err)
http.Error(w, "", http.StatusNotFound)
}
searchResult := SearchResult{
InstRequested: inst,
Date: time.Now(),
TimeAmount: "none",
TimeFrame: "none",
}
removeDup := map[string]int{}
res := []string{}
for _, val := range institutions {
removeDup[val]++
if removeDup[val] == 1 {
res = append(res, val)
}
}
for _, banco := range res {
inst := domain.History{}
inst.Institution.Name = banco
searchResult.Results = append(searchResult.Results, &inst)
}
api.log.Info("institutions", "list", res)
if fType == "html" {
switch resource {
case "card":
err = api.templ.ExecuteTemplate(w, "card", searchResult)
case "list":
// by default we crate simple query
// we need a way to distinguish from mixed or more complex query
err = api.templ.ExecuteTemplate(w, "list", searchResult)
}
if err != nil {
api.log.Error("rendering institucion", "tipo", inst, "error", err)
}
} else {
dat, err := json.Marshal(searchResult)
if err != nil {
fmt.Println(err)
}
w.Write(dat)
}
}
func (api *API) GetLatestPrice(w http.ResponseWriter, r *http.Request) {
inst := r.PathValue("inst")
if inst == "" {
http.Error(w, "no inst in request", http.StatusNotFound)
}
price, err := api.dolar.GetLatest(inst)
if err != nil {
api.log.Error("geting latest price", "error", err)
http.NotFound(w, r)
}
api.templ.ExecuteTemplate(w, "latest-price", price)
}
func (api *API) GetAllLatestPrice(w http.ResponseWriter, r *http.Request) {
bancos, err := api.dolar.GetInstByType("bancos")
if err != nil {
api.log.Error("geting inst", "error", err)
http.NotFound(w, r)
}
cajas, err := api.dolar.GetInstByType("cajas")
if err != nil {
api.log.Error("geting inst cajas", "error", err)
http.NotFound(w, r)
}
agentes, err := api.dolar.GetInstByType("agentes")
if err != nil {
api.log.Error("geting inst agentes", "error", err)
http.NotFound(w, r)
}
instLastPriceList := []*domain.History{}
for _, inst := range slices.Concat(bancos, cajas, agentes) {
in, err := api.dolar.GetLatest(inst)
if err != nil {
api.log.Error("geting last price", "error", err)
continue
}
instLastPriceList = append(instLastPriceList, in)
}
api.templ.ExecuteTemplate(w, "latest-price", instLastPriceList)
}

1
handlers/app/app.go Normal file
View File

@ -0,0 +1 @@
package app

87
handlers/handlers.go Normal file
View File

@ -0,0 +1,87 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
"strings"
"time"
"github.com/maximotejeda/us_dop_api/handlers/api"
"github.com/maximotejeda/us_dop_api/ports"
"github.com/maximotejeda/us_dop_db/db"
)
var (
templ template.Template
)
// Mux will mix and match all sub routes
type Root struct {
http.ServeMux
db *db.DB
log *slog.Logger
dolar ports.DolarService
templates *template.Template
}
func NewRoot(dbe *db.DB, dolar ports.DolarService, log *slog.Logger, files []string) *Root {
funcMap := template.FuncMap{
"add": add[float64],
"div": div,
"mult": mult[float64],
"fdate": fdate,
"replace": replace,
}
templ = *template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
r := Root{
log: log,
dolar: dolar,
templates: &templ,
}
r.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
templ.ExecuteTemplate(w, "index", nil)
//w.Write([]byte("{'status': 'ok'}"))
})
inst := api.NewApiHandler(dbe, dolar, log, templ)
r.Handle("GET /api/{inst}/", inst)
r.Handle("POST /api/instituciones/history", inst)
r.Handle("GET /api/latest/{inst}", inst)
r.Handle("POST /api/control/", inst)
r.HandleFunc("/api/{$}", http.NotFound)
// telegram
r.HandleFunc("/telegram/{$}", func(w http.ResponseWriter, r *http.Request) {
templ.ExecuteTemplate(w, "index", nil)
//w.Write([]byte("{'status': 'ok'}"))
})
return &r
}
func add[v float64 | int](a, b v) v {
return a + b
}
func div(a float64, b int) float64 {
if a == 0 || b == 0 {
return 0
}
return a / float64(b)
}
func mult[v float64 | int](a, b v) v {
return a * b
}
func fdate(t time.Time) string {
return t.Format("15 02-01-06")
}
// replace
// replace string
func replace(rep, torep, original string) string {
return strings.ReplaceAll(original, rep, torep)
}

27
helpers/timehelpers.go Normal file
View File

@ -0,0 +1,27 @@
package helpers
import (
"strconv"
"strings"
"time"
)
func ParseTimeAmount(timeFrame, timeAmount string) (timeSearch time.Duration, err error) {
timeInt, err := strconv.ParseInt(strings.ReplaceAll(timeAmount, "-", ""), 10, 64)
if err != nil {
return timeSearch, err
}
switch timeFrame {
case "hour":
timeSearch = 60 * 1 * time.Duration(timeInt) * time.Hour
case "day":
timeSearch = 60 * 24 * time.Duration(timeInt) * time.Hour
case "week":
timeSearch = (60 * 24 * 7 * time.Duration(timeInt)) * time.Hour
case "month":
timeSearch = (60 * 24 * 7 * 4) * time.Hour
default:
}
return timeSearch, err
}

View File

@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: us-dop-api
labels:
app: us-dop-api
spec:
replicas: 1
selector:
matchLabels:
app: us-dop-api
template:
metadata:
labels:
app: us-dop-api
name: us-dop-api
spec:
containers:
- name: us-dop-pi
image: localhost:32000/us-dop-api:latest
env:
- name: DOLLAR_SERVICE_URL
value: "dolar-grpc-svc:80"
- name: HOST
value: "0.0.0.0"
- name: NATSURI
value: "nats://nats-svc:4222"
- name: PORT
value: "8080"
volumeMounts:
- name: database
mountPath: /app/dolardb
volumes:
- name: database
persistentVolumeClaim:
claimName: bank-crawler-pvc
---
apiVersion: v1
kind: Service
metadata:
name: us-dop-api-svc
spec:
type: LoadBalancer
selector:
app: us-dop-api
ports:
- port: 80
targetPort: 8080
name: frontend
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
# add an annotation indicating the issuer to use.
cert-manager.io/cluster-issuer: letsencrypt
name: us-dop-api-ingress
spec:
rules:
- host: cambio.maximotejeda.com
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: us-dop-api-svc
port:
number: 80
tls: # < placing a host in the TLS config will determine what ends up in the cert's subjectAltNames
- hosts:
- cambio.maximotejeda.com
secretName: cambio.maximotejeda-cert # < cert-manager will store the created certificate in this secret.

View File

@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: us-dop-bot
labels:
app: us-dop-bot
spec:
replicas: 1
selector:
matchLabels:
app: us-dop-bot
template:
metadata:
labels:
app: us-dop-bot
name: us-dop-bot
spec:
containers:
- name: us-dop-bot
image: localhost:32000/us-dop-bot:latest
env:
- name: DBURINST
value: $DBURINST
- name: DBURIUSER
value: $DBURIUSER
- name: NATSURI
value: "nats://nats-svc:4222"
- name: TOKEN
value: "$TOKEN"
volumeMounts:
- name: database
mountPath: /app/dolardb
volumes:
- name: database
persistentVolumeClaim:
claimName: bank-crawler-pvc

63
middlewares/logs.go Normal file
View File

@ -0,0 +1,63 @@
package middlewares
import (
"log/slog"
"net/http"
"time"
)
type logMiddleware struct {
handler http.Handler
log *slog.Logger
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func NewLogger(h http.Handler, log *slog.Logger) *logMiddleware {
return &logMiddleware{
handler: h,
log: log,
}
}
func (l *logMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := statusRecorder{w, 200}
l.handler.ServeHTTP(&rec, r)
if rec.status < 299 {
l.log.Info(
"incoming request",
"status", rec.status,
"method", r.Method,
"time_spent_ms", time.Since(start).Milliseconds(),
"path", r.URL.Path,
"user_agent", r.Header.Get("User-Agent"),
)
} else if rec.status > 299 && rec.status < 399 {
l.log.Warn(
"redirecting request",
"status", rec.status,
"method", r.Method,
"time_spent_ms", time.Since(start).Milliseconds(),
"path", r.URL.Path,
"user_agent", r.Header.Get("User-Agent"),
)
} else {
l.log.Warn(
"error on request",
"status", rec.status,
"method", r.Method,
"time_spent_ms", time.Since(start).Milliseconds(),
"path", r.URL.Path,
"user_agent", r.Header.Get("User-Agent"),
)
}
}
func (rec *statusRecorder) WriteHeader(code int) {
rec.status = code
rec.ResponseWriter.WriteHeader(code)
}

View File

@ -0,0 +1 @@
package middlewares

13
ports/dolar.go Normal file
View File

@ -0,0 +1,13 @@
package ports
import "github.com/maximotejeda/us_dop_api/domain"
type DolarService interface {
GetLatest(name string) (*domain.History, error)
GetSince(name string, duration int64) ([]*domain.History, error)
GetInstByType(name string) ([]string, error)
Subscribe(int64, string) (bool, error)
Unsubscribe(int64, string) (bool, error)
GetSubscribedUsers(string) ([]int64, error)
GetSubscribedInsts(int64) ([]string, error)
}

37
static/static.go Normal file
View File

@ -0,0 +1,37 @@
// package containing the static server
package static
import (
"fmt"
"net/http"
"regexp"
"strings"
)
func ServeFiles(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
pl := strings.Split(path, ".")
l := len(pl)
reg := regexp.MustCompile(`.*\.(png|jpg|jpeg|css|js|html|gz)$`)
switch {
case l <= 1:
fmt.Println("path not found " + r.URL.Path)
http.NotFound(w, r)
return
case l >= 2:
if ok := reg.Match([]byte(r.URL.Path)); ok {
if pl[len(pl)-1] == "gz" {
w.Header().Set("Content-Encoding", "gzip")
}
hs := http.FileServer(http.Dir("./assets"))
http.StripPrefix("/static/", hs).ServeHTTP(w, r)
return
} else {
http.NotFound(w, r)
return
}
default:
http.NotFound(w, r)
return
}
}