diff --git a/deployment/database/migrations/1.0.0_base.sql b/deployment/database/migrations/1.0.0_base.sql index 1ff31df..6de6489 100644 --- a/deployment/database/migrations/1.0.0_base.sql +++ b/deployment/database/migrations/1.0.0_base.sql @@ -1,5 +1,5 @@ CREATE EXTENSION pgcrypto; -- para los random bytes --- 1. Función de generacion de uuidv7 copiada porque no esta en postgre 19 +-- 1. Función de generacion de uuidv7 copiada de github porque no está en postgre 16 CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS @@ -44,6 +44,8 @@ CREATE TABLE activation_codes ( card_id UUID REFERENCES payment_cards(card_id), -- Una tarjeta, maximo un un código activo borrar o solo con expires_at? code_hash TEXT NOT NULL, -- Guardar el código hasheado, el original se imprime y se manda is_used BOOLEAN DEFAULT FALSE, + is_blocked BOOLEAN DEFAULT FALSE, + failed_attempts INT DEFAULT 0, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); @@ -59,3 +61,27 @@ CREATE TABLE activation_logs ( geo_location VARCHAR(100), -- Opcional: Ciudad/País derivado de IP created_at TIMESTAMPTZ DEFAULT NOW() ); + +CREATE OR REPLACE FUNCTION log_activation_attempt() +RETURNS TRIGGER AS $$ +BEGIN + -- Si el intento falló (esto lo controlas desde tu lógica de UPDATE) + -- Hay que barajar si es viable hacer los updates de failed_attempts desde aquí + -- y nó desde código. + IF NEW.failed_attempts > OLD.failed_attempts THEN + INSERT INTO activation_logs (card_id, action_type, created_at) + VALUES (NEW.card_id, 'FAILED_ATTEMPT', NOW()); + END IF; + + -- Bloqueo automático si llega al límite + IF NEW.failed_attempts >= 3 THEN + NEW.is_blocked := TRUE; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_check_attempts +BEFORE UPDATE ON activation_codes +FOR EACH ROW EXECUTE FUNCTION log_activation_attempt(); diff --git a/deployment/database/migrations/1.0.1_Proc-activate-card.sql b/deployment/database/migrations/1.0.1_Proc-activate-card.sql new file mode 100644 index 0000000..308e31b --- /dev/null +++ b/deployment/database/migrations/1.0.1_Proc-activate-card.sql @@ -0,0 +1,95 @@ +-- He separado el procedimiento de activacion en otro archivo por comodidad de desarrollo +-- creo que es mas seguro meter la lógica de activaciones directamente en la bdd que en +-- un server node y asi evitamos problemas de consistencia entre versiones. + +CREATE OR REPLACE FUNCTION activate_payment_card( + p_card_id UUID, -- uuidv7 de la tarjeta + p_input_code TEXT, -- El código que mete el usuario, puede ser incorrecto en este punto + p_ip_address INET, -- Datos de origen de la activación + p_device_info JSONB -- Datos del dispositivo de origen +) +RETURNS TABLE ( + success BOOLEAN, + message TEXT +) AS $$ +DECLARE + v_code_hash TEXT; + v_is_used BOOLEAN; + v_is_blocked BOOLEAN; + v_expires_at TIMESTAMPTZ; + v_failed_attempts INT; + v_max_attempts CONSTANT INT := 3; +BEGIN + -- 1. Obtener datos del código y bloquear la fila para actualización (FOR UPDATE) + -- sería raro que 2 clientes intentasen modificar la tarjeta a la vez. + SELECT code_hash, is_used, is_blocked, expires_at, failed_attempts + INTO v_code_hash, v_is_used, v_is_blocked, v_expires_at, v_failed_attempts + FROM activation_codes + WHERE card_id = p_card_id + FOR UPDATE; + + -- 2. Validaciones: + -- 2.1 Si no existe ninguna tarjeta con ese card_id + IF NOT FOUND THEN + RETURN QUERY SELECT FALSE, 'CARD_NOT_FOUND'; + RETURN; + END IF; + + -- 2.2 Si el código introducido ya ha sido usado + IF v_is_used THEN + RETURN QUERY SELECT FALSE, 'ALREADY_ACTIVATED'; + RETURN; + END IF; + + -- 2.3 Si el código ya ha sido bloqueado o se ha intentado demasiadas veces + IF v_is_blocked OR v_failed_attempts >= v_max_attempts THEN + RETURN QUERY SELECT FALSE, 'CODE_BLOCKED'; + RETURN; + END IF; + + + IF v_expires_at < NOW() THEN + RETURN QUERY SELECT FALSE, 'CODE_EXPIRED'; + RETURN; + END IF; + + -- 3. Verificación del Hash del codigo que ha introducido el usuario + -- En la bdd guardo el hash, al procedimiento se introduce el codigo per-se + -- Si el código NO coincide: + IF v_code_hash != crypt(p_input_code, v_code_hash) THEN + UPDATE activation_codes + -- 3.1 Control de intentos + SET failed_attempts = failed_attempts + 1, + is_blocked = (failed_attempts + 1 >= v_max_attempts) + WHERE card_id = p_card_id; + -- 3.2 Se loguea el fallo + INSERT INTO activation_logs (card_id, action_type, ip_address, device_info) + VALUES (p_card_id, 'FAILED_ATTEMPT', p_ip_address, p_device_info); + + RETURN QUERY SELECT FALSE, 'INVALID_CODE'; + RETURN; + END IF; + + -- 4. Si el código ES correcto: + -- Marcar código como usado + UPDATE activation_codes + SET is_used = TRUE + WHERE card_id = p_card_id; + + -- Activar la tarjeta + UPDATE payment_cards + SET status = 'ACTIVE', + activated_at = NOW() + WHERE card_id = p_card_id; + + -- Registrar éxito en auditoría + INSERT INTO activation_logs (card_id, action_type, ip_address, device_info) + VALUES (p_card_id, 'ACTIVATION_SUCCESS', p_ip_address, p_device_info); + + RETURN QUERY SELECT TRUE, 'SUCCESS'; + +EXCEPTION WHEN OTHERS THEN + -- En caso de error inesperado, Postgres hace rollback automático + RETURN QUERY SELECT FALSE, 'INTERNAL_ERROR: ' || SQLERRM; +END; +$$ LANGUAGE plpgsql; diff --git a/package-lock.json b/package-lock.json index 0cc8cdf..cf3e57d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.13.6", "db-migrate": "^0.11.14", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -295,6 +296,23 @@ "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -425,6 +443,18 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -576,6 +606,15 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -672,6 +711,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -782,6 +836,63 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -879,6 +990,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1425,6 +1551,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", diff --git a/package.json b/package.json index 7100b83..32caac2 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "axios": "^1.13.6", "db-migrate": "^0.11.14", "dotenv": "^17.3.1", "express": "^5.2.1", diff --git a/src/adapters/HTTPClient.adapter.ts b/src/adapters/HTTPClient.adapter.ts new file mode 100644 index 0000000..055e90f --- /dev/null +++ b/src/adapters/HTTPClient.adapter.ts @@ -0,0 +1,28 @@ +import axios, { type AxiosInstance } from "axios" + + +export class HttpClient { + + public client: AxiosInstance + + constructor(args: { + baseURL: string, + headers: Object, + }) { + this.client = axios.create({ + ...args + }) + + + this.client.interceptors.request.use( + // Plantilla + ) + + this.client.interceptors.response.use( + // Plantilla + ) + + } + + +} diff --git a/src/adapters/PGClient.adapter.ts b/src/adapters/PGClient.adapter.ts new file mode 100644 index 0000000..ca3b472 --- /dev/null +++ b/src/adapters/PGClient.adapter.ts @@ -0,0 +1,41 @@ +import { Pool, type QueryResult, type QueryResultRow } from "pg"; + +export class PgClient { + private pgPool: Pool; + + constructor(args: { + pool: Pool + }) { + this.pgPool = args.pool + } + + public connect() { + return this.pgPool.connect() + } + + /** + * Wrapper para ejecutar consultas con tipado fuerte. + * T es el formato de la respusta. + * @param text - La consulta SQL (ej. 'SELECT * FROM users WHERE id = $1') + * @param params - Los valores para los placeholders $1, $2, etc. + */ + public async query( + text: string, + params?: any[] + ): Promise> { + return await this.pgPool.query(text, params); + }; + + + /** + * Función para validar la conexión al inicio. + */ + public async checkDatabaseConnection(): Promise { + const client = await this.pgPool.connect(); + const res = await client.query('SELECT NOW()'); + console.log(`[o] Database connected successfully at: ${res.rows[0].now}`); + client.release(); // Liberamos el cliente de vuelta al pool + return; + // Si algo falla se tiene que propagar + }; +} diff --git a/src/aplication/Nfc.controller.ts b/src/aplication/Nfc.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/aplication/Nfc.usecases.ts b/src/aplication/Nfc.usecases.ts new file mode 100644 index 0000000..87403e9 --- /dev/null +++ b/src/aplication/Nfc.usecases.ts @@ -0,0 +1,9 @@ +import type { ServerContext } from "domain/ServerContext.js"; + +export class NfcUsecases { + constructor( + serverContext: ServerContext + ) { + + } +} diff --git a/src/config/env.config.ts b/src/config/env.config.ts index a2e011f..db34be8 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -6,7 +6,7 @@ export const env = { HOST: process.env.HOST, POSTGRES_HOST: process.env.POSTGRES_HOST, - POSTGRES_PORT: process.env.POSTGRES_PORT, + POSTGRES_PORT: Number(process.env.POSTGRES_PORT), POSTGRES_USER: process.env.POSTGRES_USER, POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD } diff --git a/src/config/httpclient.config.ts b/src/config/httpclient.config.ts new file mode 100644 index 0000000..d6458fc --- /dev/null +++ b/src/config/httpclient.config.ts @@ -0,0 +1,3 @@ +import { HttpClient } from "adapters/HTTPClient.adapter.js"; + +export const httpclient = new HttpClient({ baseURL: "", headers: {} }) diff --git a/src/config/pgclient.config.ts b/src/config/pgclient.config.ts new file mode 100644 index 0000000..a76e209 --- /dev/null +++ b/src/config/pgclient.config.ts @@ -0,0 +1,20 @@ +import { Pool } from "pg"; +import { PgClient } from "adapters/PGClient.adapter.js"; +import { env } from "./env.config.js"; +import assert from "node:assert"; + +const { POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD } = env + +assert(POSTGRES_HOST != undefined) +assert(Number.isInteger(POSTGRES_PORT)) + +const pool = new Pool({ + host: POSTGRES_HOST, + port: POSTGRES_PORT, + user: POSTGRES_USER, + password: POSTGRES_PASSWORD +}) + +export const pgClient = new PgClient({ + pool +}) diff --git a/src/domain/nfcRegistry.ts b/src/domain/NfcRegistry.ts similarity index 100% rename from src/domain/nfcRegistry.ts rename to src/domain/NfcRegistry.ts diff --git a/src/domain/ServerContext.ts b/src/domain/ServerContext.ts new file mode 100644 index 0000000..b723f07 --- /dev/null +++ b/src/domain/ServerContext.ts @@ -0,0 +1,7 @@ +import type { HttpClient } from "adapters/HTTPClient.adapter.js" +import type { PgClient } from "adapters/PGClient.adapter.js" + +export type ServerContext = { + PostgresClient: PgClient, + HttpClient: HttpClient +} diff --git a/src/infrastructure/Nfc.repository.ts b/src/infrastructure/Nfc.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/main.ts b/src/main.ts index 69af987..92ce9b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,22 @@ +import assert from 'assert'; import express, { Router, type Request, type Response } from 'express'; import { errorHandler } from './aplication/middleware.js'; import { env } from './config/env.config.js'; -import assert from 'assert'; + +import type { ServerContext } from 'domain/ServerContext.js'; +import { httpclient } from 'config/httpclient.config.js'; +import { pgClient } from 'config/pgclient.config.js'; const PORT = env.PORT const HOSTNAME = env.HOST assert(HOSTNAME != undefined) +const serverContext: ServerContext = { + HttpClient: httpclient, + PostgresClient: pgClient +} + const router = Router(); router.get("/health", (req: Request, res: Response) => { diff --git a/tsconfig.json b/tsconfig.json index 550a82c..1a43722 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { // File Layout - "baseUrl": "./", + "baseUrl": "./src", "rootDir": "./src", "outDir": "./dist", // Environment Settings