Jose Salazar commited on
Commit
d9c6dfb
Β·
unverified Β·
2 Parent(s): 6112db73b52693

Merge branch 'main' into franco

Browse files
backend/docs/AUTH.md CHANGED
@@ -47,6 +47,14 @@ Sembrados por `prisma/seed.js` (idempotente, se puede re-ejecutar):
47
 
48
  ## 4. Endpoints
49
 
 
 
 
 
 
 
 
 
50
  ### `GET /api/v1/health`
51
 
52
  Sanity check. Respuesta:
@@ -107,6 +115,26 @@ Errores:
107
  |---|---|---|
108
  | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  ## 5. Ejemplos con `curl`
111
 
112
  ```bash
@@ -117,15 +145,24 @@ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
117
  | jq -r '.data.token')
118
 
119
  # 2) Llamar a /me con el token
 
 
 
 
 
 
 
 
120
  curl -s http://localhost:7860/api/v1/auth/me \
121
  -H "Authorization: Bearer $TOKEN" | jq
122
  ```
123
 
124
- ## 6. Login desde el frontend (referencia)
125
 
126
- El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido.
127
 
128
  ```js
 
129
  const res = await fetch('/api/v1/auth/login', {
130
  method: 'POST',
131
  headers: { 'Content-Type': 'application/json' },
@@ -134,10 +171,17 @@ const res = await fetch('/api/v1/auth/login', {
134
  const json = await res.json();
135
  if (!json.ok) throw new Error(json.error.code);
136
  const { token, user } = json.data;
137
- // guardar token y user
138
 
139
- // requests autenticados
140
  fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
 
 
 
 
 
 
 
141
  ```
142
 
143
  > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya estΓ‘ configurado por si el front llama directo.
 
47
 
48
  ## 4. Endpoints
49
 
50
+ > Resumen rΓ‘pido:
51
+ >
52
+ > | MΓ©todo | Path | Auth | DescripciΓ³n |
53
+ > |---|---|---|---|
54
+ > | `POST` | `/api/v1/auth/login` | No | Obtener JWT |
55
+ > | `GET` | `/api/v1/auth/me` | Bearer | Perfil del usuario autenticado |
56
+ > | `POST` | `/api/v1/auth/logout` | Bearer | Invalidar token activo |
57
+
58
  ### `GET /api/v1/health`
59
 
60
  Sanity check. Respuesta:
 
115
  |---|---|---|
116
  | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
117
 
118
+ ### `POST /api/v1/auth/logout`
119
+
120
+ Requiere header `Authorization: Bearer <token>`. Invalida el token activo aΓ±adiendo su `jti` a la denylist en memoria β€” cualquier request posterior con ese token recibirΓ‘ `401 UNAUTHORIZED`.
121
+
122
+ El cliente debe descartar el token en cuanto reciba la respuesta.
123
+
124
+ Respuesta `200`:
125
+
126
+ ```json
127
+ { "ok": true, "data": { "message": "Logged out successfully" } }
128
+ ```
129
+
130
+ Errores:
131
+
132
+ | HTTP | code | cuΓ‘ndo |
133
+ |---|---|---|
134
+ | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o ya invalidado |
135
+
136
+ > **Nota:** la denylist es en memoria; si el servidor se reinicia los tokens previos al reinicio quedan tecnicamente activos, pero expirarΓ‘n solos en mΓ‘x 1 h (TTL del JWT).
137
+
138
  ## 5. Ejemplos con `curl`
139
 
140
  ```bash
 
145
  | jq -r '.data.token')
146
 
147
  # 2) Llamar a /me con el token
148
+ curl -s http://localhost:7860/api/v1/auth/me \
149
+ -H "Authorization: Bearer $TOKEN" | jq
150
+
151
+ # 3) Logout (invalida el token)
152
+ curl -s -X POST http://localhost:7860/api/v1/auth/logout \
153
+ -H "Authorization: Bearer $TOKEN" | jq
154
+
155
+ # 4) Verificar que el token ya no funciona (debe devolver 401)
156
  curl -s http://localhost:7860/api/v1/auth/me \
157
  -H "Authorization: Bearer $TOKEN" | jq
158
  ```
159
 
160
+ ## 6. Login / Logout desde el frontend (referencia)
161
 
162
+ El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido. Al hacer logout, llamar al endpoint **antes** de descartar el token localmente para que quede invalidado en el servidor.
163
 
164
  ```js
165
+ // Login
166
  const res = await fetch('/api/v1/auth/login', {
167
  method: 'POST',
168
  headers: { 'Content-Type': 'application/json' },
 
171
  const json = await res.json();
172
  if (!json.ok) throw new Error(json.error.code);
173
  const { token, user } = json.data;
174
+ // guardar token y user en estado / sessionStorage
175
 
176
+ // Requests autenticados
177
  fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
178
+
179
+ // Logout
180
+ await fetch('/api/v1/auth/logout', {
181
+ method: 'POST',
182
+ headers: { Authorization: `Bearer ${token}` },
183
+ });
184
+ // descartar token del estado / sessionStorage
185
  ```
186
 
187
  > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya estΓ‘ configurado por si el front llama directo.
backend/package.json CHANGED
@@ -14,6 +14,7 @@
14
  "db:migrate": "prisma migrate dev",
15
  "db:generate": "prisma generate",
16
  "db:seed": "node prisma/seed.js",
 
17
  "db:studio": "prisma studio"
18
  },
19
  "dependencies": {
 
14
  "db:migrate": "prisma migrate dev",
15
  "db:generate": "prisma generate",
16
  "db:seed": "node prisma/seed.js",
17
+ "db:reset": "prisma migrate reset --force",
18
  "db:studio": "prisma studio"
19
  },
20
  "dependencies": {
backend/prisma/migrations/20260516071158_init_auth/migration.sql CHANGED
@@ -3,7 +3,6 @@ CREATE TABLE "User" (
3
  "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4
  "email" TEXT NOT NULL,
5
  "passwordHash" TEXT NOT NULL,
6
- "role" TEXT NOT NULL DEFAULT 'user',
7
  "isActive" BOOLEAN NOT NULL DEFAULT true,
8
  "telegramChatId" TEXT,
9
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -20,6 +19,11 @@ CREATE TABLE "Market" (
20
  "noPrice" REAL,
21
  "volumeEur" REAL,
22
  "liquidityEur" REAL,
 
 
 
 
 
23
  "status" TEXT NOT NULL DEFAULT 'active',
24
  "closesAt" DATETIME,
25
  "lastSynced" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -35,6 +39,9 @@ CREATE TABLE "AISignal" (
35
  "keyRisk" TEXT,
36
  "newsCount" INTEGER NOT NULL DEFAULT 0,
37
  "modelVersion" TEXT NOT NULL DEFAULT 'Qwen3-8B',
 
 
 
38
  "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
39
  CONSTRAINT "AISignal_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
40
  );
 
3
  "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4
  "email" TEXT NOT NULL,
5
  "passwordHash" TEXT NOT NULL,
 
6
  "isActive" BOOLEAN NOT NULL DEFAULT true,
7
  "telegramChatId" TEXT,
8
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 
19
  "noPrice" REAL,
20
  "volumeEur" REAL,
21
  "liquidityEur" REAL,
22
+ "spread" REAL,
23
+ "bestBid" REAL,
24
+ "bestAsk" REAL,
25
+ "clobTokenId" TEXT,
26
+ "analyzable" BOOLEAN NOT NULL DEFAULT true,
27
  "status" TEXT NOT NULL DEFAULT 'active',
28
  "closesAt" DATETIME,
29
  "lastSynced" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
 
39
  "keyRisk" TEXT,
40
  "newsCount" INTEGER NOT NULL DEFAULT 0,
41
  "modelVersion" TEXT NOT NULL DEFAULT 'Qwen3-8B',
42
+ "impliedProb" REAL,
43
+ "fairProb" REAL,
44
+ "edgePoints" REAL,
45
  "generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
  CONSTRAINT "AISignal_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
47
  );
backend/src/app.js CHANGED
@@ -34,6 +34,7 @@ import positionsRoutes from './positions/positions.routes.js';
34
  import watchlistRoutes from './watchlist/watchlist.routes.js';
35
  import alertsRoutes from './alerts/alerts.routes.js';
36
  import statsRoutes from './stats/stats.routes.js';
 
37
  import { notFound } from './middlewares/notFound.js';
38
  import { errorHandler } from './middlewares/errorHandler.js';
39
 
@@ -63,11 +64,12 @@ app.use('/api/v1/positions', positionsRoutes);
63
  app.use('/api/v1/watchlist', watchlistRoutes);
64
  app.use('/api/v1/alerts', alertsRoutes);
65
  app.use('/api/v1/stats', statsRoutes);
 
66
 
67
  // Servir frontend estΓ‘tico en producciΓ³n (HuggingFace Spaces)
68
  if (config.NODE_ENV === 'production') {
69
  app.use(express.static('../frontend/dist'));
70
- app.get('*', (_req, res) => {
71
  res.sendFile('index.html', { root: '../frontend/dist' });
72
  });
73
  }
 
34
  import watchlistRoutes from './watchlist/watchlist.routes.js';
35
  import alertsRoutes from './alerts/alerts.routes.js';
36
  import statsRoutes from './stats/stats.routes.js';
37
+ import preferencesRoutes from './preferences/preferences.routes.js';
38
  import { notFound } from './middlewares/notFound.js';
39
  import { errorHandler } from './middlewares/errorHandler.js';
40
 
 
64
  app.use('/api/v1/watchlist', watchlistRoutes);
65
  app.use('/api/v1/alerts', alertsRoutes);
66
  app.use('/api/v1/stats', statsRoutes);
67
+ app.use('/api/v1/preferences', preferencesRoutes);
68
 
69
  // Servir frontend estΓ‘tico en producciΓ³n (HuggingFace Spaces)
70
  if (config.NODE_ENV === 'production') {
71
  app.use(express.static('../frontend/dist'));
72
+ app.get('/{*path}', (_req, res) => {
73
  res.sendFile('index.html', { root: '../frontend/dist' });
74
  });
75
  }
backend/src/auth/auth.controller.js CHANGED
@@ -2,17 +2,20 @@
2
  * Controladores del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
- * - login β†’ recibir credenciales, delegar validacion a auth.service.js,
6
- * responder con { token, user }.
7
- * - me β†’ devolver el usuario autenticado extraido del JWT (req.user).
 
 
8
  *
9
  * Errores:
10
  * - 401 INVALID_CREDENTIALS β†’ email o password incorrectos (mensaje generico).
11
- * - 401 UNAUTHORIZED β†’ token invalido o ausente (en requireAuth).
12
  */
13
 
14
  import * as authService from './auth.service.js';
15
  import { ok } from '../utils/apiResponse.js';
 
16
 
17
  export const login = async (req, res) => {
18
  const data = await authService.login(req.body);
@@ -28,7 +31,9 @@ export const me = async (req, res) => {
28
  ok(res, { user: req.user });
29
  };
30
 
31
- export const updateTelegram = async (req, res) => {
32
- const data = await authService.updateTelegram(req.user.id, req.body);
33
- ok(res, data);
 
 
34
  };
 
2
  * Controladores del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
+ * - login β†’ recibir credenciales, delegar validacion a auth.service.js,
6
+ * responder con { token, user }.
7
+ * - me β†’ devolver el usuario autenticado extraido del JWT (req.user).
8
+ * - logout β†’ invalidar el token activo (jti + exp extraidos del payload JWT
9
+ * ya verificado por requireAuth); responder 200 con mensaje.
10
  *
11
  * Errores:
12
  * - 401 INVALID_CREDENTIALS β†’ email o password incorrectos (mensaje generico).
13
+ * - 401 UNAUTHORIZED β†’ token invalido, ausente o ya invalidado (en requireAuth).
14
  */
15
 
16
  import * as authService from './auth.service.js';
17
  import { ok } from '../utils/apiResponse.js';
18
+ import { verifyToken } from './jwt.js';
19
 
20
  export const login = async (req, res) => {
21
  const data = await authService.login(req.body);
 
31
  ok(res, { user: req.user });
32
  };
33
 
34
+ export const logout = (req, res) => {
35
+ const token = req.headers.authorization.slice('Bearer '.length).trim();
36
+ const payload = verifyToken(token);
37
+ authService.logout({ jti: payload.jti, exp: payload.exp });
38
+ ok(res, { message: 'Logged out successfully' });
39
  };
backend/src/auth/auth.routes.js CHANGED
@@ -12,6 +12,11 @@
12
  * β†’ requireAuth
13
  * β†’ authController.me
14
  * β†’ Devuelve el usuario autenticado (req.user).
 
 
 
 
 
15
  */
16
 
17
  import { Router } from 'express';
@@ -26,6 +31,6 @@ const router = Router();
26
  router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
27
  router.post('/register', validate(registerSchema), ctrl.register);
28
  router.get('/me', requireAuth, ctrl.me);
29
- router.put('/telegram', requireAuth, ctrl.updateTelegram);
30
 
31
  export default router;
 
12
  * β†’ requireAuth
13
  * β†’ authController.me
14
  * β†’ Devuelve el usuario autenticado (req.user).
15
+ *
16
+ * POST /api/v1/auth/logout
17
+ * β†’ requireAuth (verifica token valido y no bloqueado)
18
+ * β†’ authController.logout
19
+ * β†’ Invalida el jti del token en la denylist; responde 200.
20
  */
21
 
22
  import { Router } from 'express';
 
31
  router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
32
  router.post('/register', validate(registerSchema), ctrl.register);
33
  router.get('/me', requireAuth, ctrl.me);
34
+ router.post('/logout', requireAuth, ctrl.logout);
35
 
36
  export default router;
backend/src/auth/auth.service.js CHANGED
@@ -2,23 +2,27 @@
2
  * Logica de negocio del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
- * - login({ email, password }) β†’ buscar usuario, comparar hash con bcrypt,
6
  * verificar que este activo (isActive) y firmar JWT.
 
 
7
  *
8
  * Seguridad:
9
- * - Mensaje generico en fallo ("Email or password is incorrect")
10
  * para no revelar si el email existe.
11
  * - Bcrypt con salt rounds configurable (BCRYPT_ROUNDS, default 10).
12
  * - JWT firmado con HS256 y expiracion (JWT_EXPIRES_IN).
 
13
  *
14
  * Devuelve:
15
- * { token: string, user: { id, email } }
 
16
  */
17
 
18
  import bcrypt from 'bcryptjs';
19
  import { prisma } from '../utils/prisma.js';
20
  import { HttpError } from '../utils/apiResponse.js';
21
- import { signToken } from './jwt.js';
22
 
23
  const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
24
 
@@ -66,16 +70,6 @@ export const register = async ({ email, password }) => {
66
  };
67
  };
68
 
69
- export const updateTelegram = async (userId, { telegramBotToken, telegramChatId, telegramAlertsEnabled }) => {
70
- const data = {};
71
- if (telegramBotToken !== undefined) data.telegramBotToken = telegramBotToken || null;
72
- if (telegramChatId !== undefined) data.telegramChatId = telegramChatId || null;
73
- if (telegramAlertsEnabled !== undefined) data.telegramAlertsEnabled = !!telegramAlertsEnabled;
74
-
75
- const user = await prisma.user.update({
76
- where: { id: userId },
77
- data,
78
- });
79
-
80
- return { user: buildUserResponse(user) };
81
  };
 
2
  * Logica de negocio del modulo de autenticacion.
3
  *
4
  * Responsabilidades:
5
+ * - login({ email, password }) β†’ buscar usuario, comparar hash con bcrypt,
6
  * verificar que este activo (isActive) y firmar JWT.
7
+ * - logout({ jti, exp }) β†’ invalidar el token activo anadiendo su jti
8
+ * a la denylist en memoria hasta su expiracion original.
9
  *
10
  * Seguridad:
11
+ * - Mensaje generico en fallo de login ("Email or password is incorrect")
12
  * para no revelar si el email existe.
13
  * - Bcrypt con salt rounds configurable (BCRYPT_ROUNDS, default 10).
14
  * - JWT firmado con HS256 y expiracion (JWT_EXPIRES_IN).
15
+ * - Logout invalida el jti del token; el cliente debe descartar el token.
16
  *
17
  * Devuelve:
18
+ * login β†’ { token: string, user: { id, email } }
19
+ * logout β†’ void
20
  */
21
 
22
  import bcrypt from 'bcryptjs';
23
  import { prisma } from '../utils/prisma.js';
24
  import { HttpError } from '../utils/apiResponse.js';
25
+ import { signToken, addToDenylist } from './jwt.js';
26
 
27
  const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
28
 
 
70
  };
71
  };
72
 
73
+ export const logout = ({ jti, exp }) => {
74
+ if (jti && exp) addToDenylist(jti, exp);
 
 
 
 
 
 
 
 
 
 
75
  };
backend/src/auth/jwt.js CHANGED
@@ -2,26 +2,58 @@
2
  * Helpers para firmar y verificar tokens JWT.
3
  *
4
  * Responsabilidades:
5
- * - signToken(payload) β†’ firma un token HS256 con expiracion configurable.
6
- * - verifyToken(token) β†’ verifica firma, expiracion y algoritmo (solo HS256).
 
 
 
 
 
 
 
 
 
7
  *
8
  * Consumido por:
9
- * - auth.service.js β†’ al hacer login exitoso.
10
- * - requireAuth.js β†’ en cada peticion protegida.
11
  *
12
  * Configuracion:
13
  * - JWT_SECRET : minimo 32 chars (validado en config.js).
14
  * - JWT_EXPIRES_IN: default '1h'.
15
  */
16
 
 
17
  import jwt from 'jsonwebtoken';
18
  import { config } from '../config.js';
19
 
 
 
 
20
  export const signToken = (payload) =>
21
- jwt.sign(payload, config.JWT_SECRET, {
22
  algorithm: 'HS256',
23
  expiresIn: config.JWT_EXPIRES_IN,
24
  });
25
 
26
  export const verifyToken = (token) =>
27
  jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  * Helpers para firmar y verificar tokens JWT.
3
  *
4
  * Responsabilidades:
5
+ * - signToken(payload) β†’ firma un token HS256 con expiracion configurable.
6
+ * Incluye claim `jti` (UUID v4) para poder invalidarlo.
7
+ * - verifyToken(token) β†’ verifica firma, expiracion y algoritmo (solo HS256).
8
+ * - addToDenylist(jti, exp) β†’ anota el jti como invalidado hasta su expiracion.
9
+ * - isBlocked(jti) β†’ true si el jti esta en la denylist.
10
+ * - pruneExpiredDenylist() β†’ elimina entradas ya expiradas (llamado en cada logout).
11
+ *
12
+ * Denylist:
13
+ * - En memoria (Map<jti, expEpochMs>). Sin persistencia entre reinicios.
14
+ * - Aceptable porque el TTL del token es 1h; al reiniciar el servidor
15
+ * cualquier token antiguo expirara por si solo antes de que importe.
16
  *
17
  * Consumido por:
18
+ * - auth.service.js β†’ signToken al hacer login; addToDenylist al hacer logout.
19
+ * - requireAuth.js β†’ verifyToken + isBlocked en cada peticion protegida.
20
  *
21
  * Configuracion:
22
  * - JWT_SECRET : minimo 32 chars (validado en config.js).
23
  * - JWT_EXPIRES_IN: default '1h'.
24
  */
25
 
26
+ import { randomUUID } from 'node:crypto';
27
  import jwt from 'jsonwebtoken';
28
  import { config } from '../config.js';
29
 
30
+ /** @type {Map<string, number>} jti β†’ expiry timestamp (ms) */
31
+ const denylist = new Map();
32
+
33
  export const signToken = (payload) =>
34
+ jwt.sign({ ...payload, jti: randomUUID() }, config.JWT_SECRET, {
35
  algorithm: 'HS256',
36
  expiresIn: config.JWT_EXPIRES_IN,
37
  });
38
 
39
  export const verifyToken = (token) =>
40
  jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
41
+
42
+ export const addToDenylist = (jti, exp) => {
43
+ pruneExpiredDenylist();
44
+ denylist.set(jti, exp * 1000);
45
+ };
46
+
47
+ export const isBlocked = (jti) => {
48
+ const exp = denylist.get(jti);
49
+ if (exp === undefined) return false;
50
+ if (Date.now() > exp) { denylist.delete(jti); return false; }
51
+ return true;
52
+ };
53
+
54
+ export const pruneExpiredDenylist = () => {
55
+ const now = Date.now();
56
+ for (const [jti, exp] of denylist) {
57
+ if (now > exp) denylist.delete(jti);
58
+ }
59
+ };
backend/src/config.js CHANGED
@@ -32,6 +32,7 @@ const schema = z.object({
32
  HF_SPACE_MODERNFINBERT_URL: z.string().optional(),
33
  HF_SPACE_QWEN_URL: z.string().optional(),
34
  OPENROUTER_API_KEY: z.string().optional(),
 
35
  FINNHUB_API_KEY: z.string().optional(),
36
  });
37
 
 
32
  HF_SPACE_MODERNFINBERT_URL: z.string().optional(),
33
  HF_SPACE_QWEN_URL: z.string().optional(),
34
  OPENROUTER_API_KEY: z.string().optional(),
35
+ DEEPSEEK_API_KEY: z.string().optional(),
36
  FINNHUB_API_KEY: z.string().optional(),
37
  });
38
 
backend/src/middlewares/requireAuth.js CHANGED
@@ -4,19 +4,22 @@
4
  * Responsabilidades:
5
  * - Extraer el header Authorization: Bearer <token>.
6
  * - Verificar la firma y expiracion del token con jwt.verify().
 
7
  * - Buscar el usuario en la base de datos y comprobar que esta activo (isActive).
8
  * - Adjuntar req.user para que controladores y servicios posteriores lo usen.
9
  *
10
  * Rutas protegidas:
11
  * - Todas bajo /positions, /watchlist, /alerts.
12
  * - GET /auth/me.
 
13
  *
14
- * Si falta token, es invalido o el usuario no existe/inactivo β†’ 401 UNAUTHORIZED.
 
15
  */
16
 
17
- import { verifyToken } from "../auth/jwt.js";
18
- import { prisma } from "../utils/prisma.js";
19
- import { HttpError } from "../utils/apiResponse.js";
20
 
21
  const UNAUTHORIZED = new HttpError(
22
  401,
@@ -33,6 +36,9 @@ export const requireAuth = async (req, _res, next) => {
33
  if (!token) throw UNAUTHORIZED;
34
 
35
  const payload = verifyToken(token);
 
 
 
36
  const user = await prisma.user.findUnique({
37
  where: { id: payload.sub },
38
  //select: { id: true, email: true, isActive: true, createdAt: true },
 
4
  * Responsabilidades:
5
  * - Extraer el header Authorization: Bearer <token>.
6
  * - Verificar la firma y expiracion del token con jwt.verify().
7
+ * - Comprobar que el jti no este en la denylist (logout previo).
8
  * - Buscar el usuario en la base de datos y comprobar que esta activo (isActive).
9
  * - Adjuntar req.user para que controladores y servicios posteriores lo usen.
10
  *
11
  * Rutas protegidas:
12
  * - Todas bajo /positions, /watchlist, /alerts.
13
  * - GET /auth/me.
14
+ * - POST /auth/logout.
15
  *
16
+ * Si falta token, es invalido, fue invalidado por logout, o el usuario
17
+ * no existe/inactivo β†’ 401 UNAUTHORIZED.
18
  */
19
 
20
+ import { verifyToken, isBlocked } from '../auth/jwt.js';
21
+ import { prisma } from '../utils/prisma.js';
22
+ import { HttpError } from '../utils/apiResponse.js';
23
 
24
  const UNAUTHORIZED = new HttpError(
25
  401,
 
36
  if (!token) throw UNAUTHORIZED;
37
 
38
  const payload = verifyToken(token);
39
+
40
+ if (payload.jti && isBlocked(payload.jti)) throw UNAUTHORIZED;
41
+
42
  const user = await prisma.user.findUnique({
43
  where: { id: payload.sub },
44
  //select: { id: true, email: true, isActive: true, createdAt: true },
backend/src/preferences/preferences.routes.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { getPrefs, setPrefs } from './preferences.store.js';
3
+ import { ok } from '../utils/apiResponse.js';
4
+
5
+ const router = Router();
6
+
7
+ router.get('/', (_req, res) => {
8
+ const prefs = getPrefs();
9
+ ok(res, { ...prefs, apiKey: prefs.apiKey ? '***' : '' });
10
+ });
11
+
12
+ router.put('/', (req, res) => {
13
+ const { mode, provider, apiKey, endpoint, model } = req.body;
14
+ const updated = setPrefs({ mode, provider, apiKey, endpoint, model });
15
+ ok(res, { ...updated, apiKey: updated.apiKey ? '***' : '' });
16
+ });
17
+
18
+ export default router;
backend/src/preferences/preferences.store.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const defaults = {
2
+ mode: 'auto', // 'auto' | 'external' | 'local' | 'custom'
3
+ provider: 'deepseek', // for external: 'deepseek' | 'openrouter' | 'huggingface'
4
+ apiKey: '',
5
+ endpoint: 'http://localhost:11434',
6
+ model: 'qwen3:8b',
7
+ };
8
+
9
+ let _prefs = { ...defaults };
10
+
11
+ export function getPrefs() {
12
+ return { ..._prefs };
13
+ }
14
+
15
+ export function setPrefs(updates) {
16
+ const allowed = ['mode', 'provider', 'apiKey', 'endpoint', 'model'];
17
+ for (const key of allowed) {
18
+ if (key in updates && updates[key] != null) {
19
+ _prefs[key] = String(updates[key]);
20
+ }
21
+ }
22
+ if (!['auto', 'external', 'local', 'custom'].includes(_prefs.mode)) _prefs.mode = 'auto';
23
+ if (!['deepseek', 'openrouter', 'huggingface'].includes(_prefs.provider)) _prefs.provider = 'deepseek';
24
+ return { ..._prefs };
25
+ }
backend/src/signals/aiPipeline.js CHANGED
@@ -23,12 +23,17 @@ import { config } from '../config.js';
23
  import { logger } from '../utils/logger.js';
24
  import { fetchFinancialNewsForMarket, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
25
  import { analyzeCryptoTarget } from '../utils/coingecko.client.js';
 
26
 
27
  const HF_API = 'https://api-inference.huggingface.co/models';
28
  const FINBERT_MODEL = 'ProsusAI/finbert';
29
  const QWEN_MODEL = 'Qwen/Qwen3-8B';
30
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
31
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat';
 
 
 
 
32
 
33
  // Clientes Gradio en cache para Spaces
34
  let modernFinBERTClient = null;
@@ -239,6 +244,63 @@ async function generateWithOpenRouter(market, headlines, cryptoContext = null) {
239
  return data;
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  // ── rule-based fallback ───────────────────────────────────────────────────────
243
 
244
  function ruleBasedSignal(market) {
@@ -291,6 +353,65 @@ function normalizeSignal(result) {
291
  return result;
292
  }
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  // ── public API ────────────────────────────────────────────────────────────────
295
 
296
  /**
@@ -333,10 +454,44 @@ export async function run(market) {
333
  }
334
 
335
  // Paso 2: generacion de senal LLM con cadena de respaldo
 
336
  let result = null;
337
 
338
- // Intenta Space primero
339
- if (config.HF_SPACE_QWEN_URL) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  try {
341
  result = await generateWithQwenSpace(market, headlines, cryptoContext);
342
  result = normalizeSignal(result);
@@ -346,23 +501,23 @@ export async function run(market) {
346
  }
347
  }
348
 
349
- // Respaldo a API directa de HF
350
  if (!result && config.HF_TOKEN) {
351
  try {
352
  result = await generateWithQwen3Direct(market, headlines, cryptoContext);
353
  if (!validateSignal(result)) result = null;
354
  } catch (err) {
355
- logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 direct API failed, trying OpenRouter');
356
  }
357
  }
358
 
359
- // Respaldo a OpenRouter
360
- if (!result && config.OPENROUTER_API_KEY) {
361
  try {
362
- result = await generateWithOpenRouter(market, headlines, cryptoContext);
363
  if (!validateSignal(result)) result = null;
364
  } catch (err) {
365
- logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'OpenRouter failed, using rule-based');
366
  }
367
  }
368
 
 
23
  import { logger } from '../utils/logger.js';
24
  import { fetchFinancialNewsForMarket, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
25
  import { analyzeCryptoTarget } from '../utils/coingecko.client.js';
26
+ import { getPrefs } from '../preferences/preferences.store.js';
27
 
28
  const HF_API = 'https://api-inference.huggingface.co/models';
29
  const FINBERT_MODEL = 'ProsusAI/finbert';
30
  const QWEN_MODEL = 'Qwen/Qwen3-8B';
31
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
32
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat';
33
+ const DEEPSEEK_API = 'https://api.deepseek.com/chat/completions';
34
+ const DEEPSEEK_MODEL = 'deepseek-chat';
35
+ const OLLAMA_API = 'http://localhost:11434/api/generate';
36
+ const OLLAMA_MODEL = 'qwen3.5:9b';
37
 
38
  // Clientes Gradio en cache para Spaces
39
  let modernFinBERTClient = null;
 
244
  return data;
245
  }
246
 
247
+ async function generateWithDeepSeek(market, headlines, cryptoContext = null) {
248
+ const content = await callChatCompletion(
249
+ DEEPSEEK_API,
250
+ DEEPSEEK_MODEL,
251
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
252
+ `Bearer ${config.DEEPSEEK_API_KEY}`,
253
+ );
254
+ const data = content ? extractJson(content) : null;
255
+ if (data) data.modelVersion = 'DeepSeek API';
256
+ return data;
257
+ }
258
+
259
+ function buildOllamaPrompt(market, headlines, cryptoContext = null) {
260
+ const newsSection = headlines.length
261
+ ? `News:\n${headlines.map((h) => `- ${h.headline}`).join('\n')}`
262
+ : 'No relevant news.';
263
+
264
+ const yes = market.yesPrice;
265
+ const priceContext = yes != null
266
+ ? `Implied YES probability: ${(yes * 100).toFixed(1)}%.`
267
+ : '';
268
+
269
+ const daysToClose = market.closesAt
270
+ ? Math.max(0, Math.ceil((new Date(market.closesAt) - Date.now()) / (1000 * 60 * 60 * 24)))
271
+ : null;
272
+ const timeSection = daysToClose != null ? `Days to resolution: ${daysToClose}.` : '';
273
+
274
+ return [
275
+ `Market: "${market.question}"`,
276
+ `Category: ${market.category ?? 'general'}`,
277
+ `YES price: ${yes ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
278
+ priceContext,
279
+ timeSection,
280
+ newsSection,
281
+ ``,
282
+ `Return ONLY JSON: {"signal":"bullish"|"bearish"|"neutral","confidence":0.0-1.0,"summary":"<2 sentences>","keyRisk":"<1 sentence>"}`,
283
+ ].join('\n');
284
+ }
285
+
286
+ async function generateWithOllama(market, headlines, cryptoContext = null) {
287
+ const userPrompt = buildOllamaPrompt(market, headlines, cryptoContext);
288
+ const body = {
289
+ model: OLLAMA_MODEL,
290
+ messages: [
291
+ { role: 'system', content: 'You are a concise prediction-market trader. Always respond with ONLY the requested JSON. DO NOT think. DO NOT use <think> tags. DO NOT explain your reasoning. Output raw JSON only.' },
292
+ { role: 'user', content: userPrompt },
293
+ ],
294
+ stream: false,
295
+ options: { temperature: 0.3, num_predict: 1200 },
296
+ };
297
+ const res = await httpPost('http://localhost:11434/api/chat', body, { timeout: 180_000, retries: 0 });
298
+ const content = res?.message?.content ?? null;
299
+ const data = content ? extractJson(content) : null;
300
+ if (data) data.modelVersion = 'Ollama Qwen3.5';
301
+ return data;
302
+ }
303
+
304
  // ── rule-based fallback ───────────────────────────────────────────────────────
305
 
306
  function ruleBasedSignal(market) {
 
353
  return result;
354
  }
355
 
356
+ // ── User-configured model ────────────────────────────────────────────────────
357
+
358
+ async function generateWithUserPrefs(market, headlines, cryptoContext, prefs) {
359
+ const { mode, provider, apiKey, endpoint, model } = prefs;
360
+
361
+ if (mode === 'external') {
362
+ const cfgMap = {
363
+ deepseek: { url: DEEPSEEK_API, mdl: DEEPSEEK_MODEL, label: 'DeepSeek (usuario)' },
364
+ openrouter: { url: OPENROUTER_API, mdl: OPENROUTER_MODEL, label: 'OpenRouter (usuario)' },
365
+ huggingface: { url: `${HF_API}/${QWEN_MODEL}/v1/chat/completions`, mdl: QWEN_MODEL, label: 'HuggingFace (usuario)' },
366
+ };
367
+ const cfg = cfgMap[provider];
368
+ if (!cfg || !apiKey) return null;
369
+ const content = await callChatCompletion(
370
+ cfg.url,
371
+ cfg.mdl,
372
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
373
+ `Bearer ${apiKey}`,
374
+ );
375
+ const data = content ? extractJson(content) : null;
376
+ if (data) data.modelVersion = cfg.label;
377
+ return data;
378
+ }
379
+
380
+ if (mode === 'local') {
381
+ const base = (endpoint || 'http://localhost:11434').replace(/\/$/, '');
382
+ const mdl = model || OLLAMA_MODEL;
383
+ const body = {
384
+ model: mdl,
385
+ messages: [
386
+ { role: 'system', content: 'You are a concise prediction-market trader. Always respond with ONLY the requested JSON. DO NOT use <think> tags. Output raw JSON only.' },
387
+ { role: 'user', content: buildOllamaPrompt(market, headlines, cryptoContext) },
388
+ ],
389
+ stream: false,
390
+ options: { temperature: 0.3, num_predict: 1200 },
391
+ };
392
+ const res = await httpPost(`${base}/api/chat`, body, { timeout: 180_000, retries: 0 });
393
+ const content = res?.message?.content ?? null;
394
+ const data = content ? extractJson(content) : null;
395
+ if (data) data.modelVersion = `Local ${mdl}`;
396
+ return data;
397
+ }
398
+
399
+ if (mode === 'custom') {
400
+ if (!endpoint) return null;
401
+ const content = await callChatCompletion(
402
+ endpoint,
403
+ model || 'default',
404
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
405
+ apiKey ? `Bearer ${apiKey}` : '',
406
+ );
407
+ const data = content ? extractJson(content) : null;
408
+ if (data) data.modelVersion = `Custom ${model || 'endpoint'}`;
409
+ return data;
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
  // ── public API ────────────────────────────────────────────────────────────────
416
 
417
  /**
 
454
  }
455
 
456
  // Paso 2: generacion de senal LLM con cadena de respaldo
457
+ // ORDEN: preferencias usuario β†’ DeepSeek β†’ OpenRouter β†’ HF Space β†’ HF directa β†’ Ollama β†’ regla
458
  let result = null;
459
 
460
+ // 0. Modelo configurado por el usuario (si no es 'auto')
461
+ const prefs = getPrefs();
462
+ if (prefs.mode !== 'auto') {
463
+ try {
464
+ result = await generateWithUserPrefs(market, headlines, cryptoContext, prefs);
465
+ result = normalizeSignal(result);
466
+ if (!validateSignal(result)) result = null;
467
+ } catch (err) {
468
+ logger.warn({ err: err.message, mode: prefs.mode, marketId: market.id }, 'User model failed, falling back to auto chain');
469
+ result = null;
470
+ }
471
+ }
472
+
473
+ // 1. DeepSeek API directa primero
474
+ if (!result && config.DEEPSEEK_API_KEY) {
475
+ try {
476
+ result = await generateWithDeepSeek(market, headlines, cryptoContext);
477
+ if (!validateSignal(result)) result = null;
478
+ } catch (err) {
479
+ logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'DeepSeek API failed, trying OpenRouter');
480
+ }
481
+ }
482
+
483
+ // 2. OpenRouter DeepSeek
484
+ if (!result && config.OPENROUTER_API_KEY) {
485
+ try {
486
+ result = await generateWithOpenRouter(market, headlines, cryptoContext);
487
+ if (!validateSignal(result)) result = null;
488
+ } catch (err) {
489
+ logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'OpenRouter DeepSeek failed, trying HF Space');
490
+ }
491
+ }
492
+
493
+ // 3. Respaldo a HF Space
494
+ if (!result && config.HF_SPACE_QWEN_URL) {
495
  try {
496
  result = await generateWithQwenSpace(market, headlines, cryptoContext);
497
  result = normalizeSignal(result);
 
501
  }
502
  }
503
 
504
+ // 4. Respaldo a API directa de HF
505
  if (!result && config.HF_TOKEN) {
506
  try {
507
  result = await generateWithQwen3Direct(market, headlines, cryptoContext);
508
  if (!validateSignal(result)) result = null;
509
  } catch (err) {
510
+ logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 direct API failed, trying Ollama');
511
  }
512
  }
513
 
514
+ // 5. Ollama local (lento pero gratuito)
515
+ if (!result) {
516
  try {
517
+ result = await generateWithOllama(market, headlines, cryptoContext);
518
  if (!validateSignal(result)) result = null;
519
  } catch (err) {
520
+ logger.warn({ err: err.message, marketId: market.id }, 'Ollama failed, using rule-based');
521
  }
522
  }
523
 
frontend/dist/index.html CHANGED
@@ -5,11 +5,65 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal β€” Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
- <script type="module" crossorigin src="/assets/index-CjgKC2Lf.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Bq1dV2vj.css">
10
  </head>
11
  <body>
12
- <div id="app" class="layout">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  <!-- Sidebar -->
15
  <aside class="sidebar" id="sidebar">
@@ -335,51 +389,5 @@
335
  </div>
336
  </div>
337
 
338
- <!-- Auth Modal -->
339
- <div class="modal-overlay hidden" id="auth-modal">
340
- <div class="modal">
341
- <div class="modal-header">
342
- <div class="modal-tabs">
343
- <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
344
- <button class="modal-tab" data-tab="register">Registrarse</button>
345
- </div>
346
- <button class="modal-close" id="modal-close" title="Cerrar">βœ•</button>
347
- </div>
348
- <div class="modal-body">
349
- <!-- Login Form -->
350
- <form class="modal-form active" id="form-login">
351
- <div class="form-group">
352
- <label for="login-email">Correo electrΓ³nico</label>
353
- <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
354
- </div>
355
- <div class="form-group">
356
- <label for="login-password">ContraseΓ±a</label>
357
- <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" required />
358
- </div>
359
- <div class="form-error" id="login-error"></div>
360
- <button type="submit" class="modal-submit">Entrar</button>
361
- </form>
362
-
363
- <!-- Register Form -->
364
- <form class="modal-form" id="form-register">
365
- <div class="form-group">
366
- <label for="register-email">Correo electrΓ³nico</label>
367
- <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
368
- </div>
369
- <div class="form-group">
370
- <label for="register-password">ContraseΓ±a</label>
371
- <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" required minlength="8" />
372
- </div>
373
- <div class="form-group">
374
- <label for="register-password-confirm">Confirmar contraseΓ±a</label>
375
- <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" required />
376
- </div>
377
- <div class="form-error" id="register-error"></div>
378
- <button type="submit" class="modal-submit">Crear cuenta</button>
379
- </form>
380
- </div>
381
- </div>
382
- </div>
383
-
384
  </body>
385
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal β€” Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
+ <script type="module" crossorigin src="/assets/index-DlrMXyLG.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-o2OJjwyL.css">
10
  </head>
11
  <body>
12
+ <!-- Auth Page (shown when unauthenticated) -->
13
+ <div id="view-auth" class="auth-view hidden">
14
+ <div class="auth-card">
15
+ <div class="auth-header">
16
+ <div class="auth-logo">
17
+ <div class="logo-dot"></div>
18
+ <span class="logo-text">PolySignal</span>
19
+ </div>
20
+ <p class="auth-claim">Inteligencia en tiempo real para mercados de predicciΓ³n de Polymarket β€” seΓ±ales IA, mapa global y simulador.</p>
21
+ </div>
22
+ <div class="auth-body">
23
+ <div class="modal-tabs">
24
+ <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
25
+ <button class="modal-tab" data-tab="register">Registrarse</button>
26
+ </div>
27
+ <!-- Login Form -->
28
+ <form class="modal-form active" id="form-login" novalidate>
29
+ <div class="form-group">
30
+ <label for="login-email">Correo electrΓ³nico</label>
31
+ <input type="text" id="login-email" placeholder="usuario@ejemplo.com" autocomplete="email" />
32
+ <small class="field-error" id="login-email-error"></small>
33
+ </div>
34
+ <div class="form-group">
35
+ <label for="login-password">ContraseΓ±a</label>
36
+ <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password" />
37
+ <small class="field-error" id="login-password-error"></small>
38
+ </div>
39
+ <div class="form-error" id="login-error"></div>
40
+ <button type="submit" class="modal-submit">Entrar</button>
41
+ </form>
42
+ <!-- Register Form -->
43
+ <form class="modal-form" id="form-register" novalidate>
44
+ <div class="form-group">
45
+ <label for="register-email">Correo electrΓ³nico</label>
46
+ <input type="text" id="register-email" placeholder="usuario@ejemplo.com" autocomplete="email" />
47
+ <small class="field-error" id="register-email-error"></small>
48
+ </div>
49
+ <div class="form-group">
50
+ <label for="register-password">ContraseΓ±a</label>
51
+ <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" autocomplete="new-password" />
52
+ <small class="field-error" id="register-password-error"></small>
53
+ </div>
54
+ <div class="form-group">
55
+ <label for="register-password-confirm">Confirmar contraseΓ±a</label>
56
+ <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" autocomplete="new-password" />
57
+ <small class="field-error" id="register-password-confirm-error"></small>
58
+ </div>
59
+ <div class="form-error" id="register-error"></div>
60
+ <button type="submit" class="modal-submit">Crear cuenta</button>
61
+ </form>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <div id="app" class="layout hidden">
67
 
68
  <!-- Sidebar -->
69
  <aside class="sidebar" id="sidebar">
 
389
  </div>
390
  </div>
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  </body>
393
  </html>
frontend/index.html CHANGED
@@ -7,7 +7,61 @@
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
  </head>
9
  <body>
10
- <div id="app" class="layout">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  <!-- Sidebar -->
13
  <aside class="sidebar" id="sidebar">
@@ -29,6 +83,10 @@
29
  <span class="nav-icon">⚑</span>
30
  <span class="nav-label">Alertas</span>
31
  </div>
 
 
 
 
32
  </nav>
33
  <div class="sidebar-footer">
34
  v0.1.0 Β· HF Spaces
@@ -100,12 +158,12 @@
100
  </div>
101
  <div class="topbar-filters desktop-only">
102
  <select class="filter-select" id="filter-trend" title="Tendencias">
103
- <option value="">πŸ”₯ Todos los mercados</option>
104
- <option value="hot">πŸ”₯ MΓ‘s activos</option>
105
- <option value="bullish-trend">πŸ“ˆ Tendencia alcista</option>
106
- <option value="bearish-trend">πŸ“‰ Tendencia bajista</option>
107
- <option value="volatile">⚑ MÑs volÑtiles</option>
108
- <option value="high-volume">πŸ“Š Alto volumen</option>
109
  </select>
110
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
111
  <option value="">Todas las categorΓ­as</option>
@@ -115,7 +173,9 @@
115
  <div class="topbar-actions">
116
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
117
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
118
- <button class="icon-btn" id="btn-notif" title="Notificaciones">β—‰</button>
 
 
119
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
120
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
121
  </div>
@@ -266,6 +326,82 @@
266
  </div>
267
  </section>
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </main>
270
  </div>
271
 
@@ -379,6 +515,7 @@
379
  </div>
380
  </div>
381
 
382
- <script type="module" src="/src/main.js"></script>
 
383
  </body>
384
  </html>
 
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
  </head>
9
  <body>
10
+ <!-- Auth Page (shown when unauthenticated) -->
11
+ <div id="view-auth" class="auth-view hidden">
12
+ <div class="auth-card">
13
+ <div class="auth-header">
14
+ <div class="auth-logo">
15
+ <div class="logo-dot"></div>
16
+ <span class="logo-text">PolySignal</span>
17
+ </div>
18
+ <p class="auth-claim">Inteligencia en tiempo real para mercados de predicciΓ³n de Polymarket β€” seΓ±ales IA, mapa global y simulador.</p>
19
+ </div>
20
+ <div class="auth-body">
21
+ <div class="modal-tabs">
22
+ <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
23
+ <button class="modal-tab" data-tab="register">Registrarse</button>
24
+ </div>
25
+ <!-- Login Form -->
26
+ <form class="modal-form active" id="form-login" novalidate>
27
+ <div class="form-group">
28
+ <label for="login-email">Correo electrΓ³nico</label>
29
+ <input type="text" id="login-email" placeholder="usuario@ejemplo.com" autocomplete="email" />
30
+ <small class="field-error" id="login-email-error"></small>
31
+ </div>
32
+ <div class="form-group">
33
+ <label for="login-password">ContraseΓ±a</label>
34
+ <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password" />
35
+ <small class="field-error" id="login-password-error"></small>
36
+ </div>
37
+ <div class="form-error" id="login-error"></div>
38
+ <button type="submit" class="modal-submit">Entrar</button>
39
+ </form>
40
+ <!-- Register Form -->
41
+ <form class="modal-form" id="form-register" novalidate>
42
+ <div class="form-group">
43
+ <label for="register-email">Correo electrΓ³nico</label>
44
+ <input type="text" id="register-email" placeholder="usuario@ejemplo.com" autocomplete="email" />
45
+ <small class="field-error" id="register-email-error"></small>
46
+ </div>
47
+ <div class="form-group">
48
+ <label for="register-password">ContraseΓ±a</label>
49
+ <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" autocomplete="new-password" />
50
+ <small class="field-error" id="register-password-error"></small>
51
+ </div>
52
+ <div class="form-group">
53
+ <label for="register-password-confirm">Confirmar contraseΓ±a</label>
54
+ <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" autocomplete="new-password" />
55
+ <small class="field-error" id="register-password-confirm-error"></small>
56
+ </div>
57
+ <div class="form-error" id="register-error"></div>
58
+ <button type="submit" class="modal-submit">Crear cuenta</button>
59
+ </form>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div id="app" class="layout hidden">
65
 
66
  <!-- Sidebar -->
67
  <aside class="sidebar" id="sidebar">
 
83
  <span class="nav-icon">⚑</span>
84
  <span class="nav-label">Alertas</span>
85
  </div>
86
+ <div class="nav-item" data-view="preferences">
87
+ <span class="nav-icon">βš™</span>
88
+ <span class="nav-label">Modelo IA</span>
89
+ </div>
90
  </nav>
91
  <div class="sidebar-footer">
92
  v0.1.0 Β· HF Spaces
 
158
  </div>
159
  <div class="topbar-filters desktop-only">
160
  <select class="filter-select" id="filter-trend" title="Tendencias">
161
+ <option value="">Todos los mercados</option>
162
+ <option value="hot">MΓ‘s activos</option>
163
+ <option value="bullish-trend">Tendencia alcista</option>
164
+ <option value="bearish-trend">Tendencia bajista</option>
165
+ <option value="high-volume">Alto volumen</option>
166
+ <option value="open-only">Mercados Abiertos</option>
167
  </select>
168
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
169
  <option value="">Todas las categorΓ­as</option>
 
173
  <div class="topbar-actions">
174
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
175
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
176
+ <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
177
+ <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
178
+ <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
179
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
180
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
181
  </div>
 
326
  </div>
327
  </section>
328
 
329
+ <!-- PREFERENCES VIEW (desktop only β€” mobile uses modal) -->
330
+ <section class="view" id="view-preferences">
331
+ <div class="panel full-height">
332
+ <div class="panel-header">
333
+ <div class="panel-title"><span>βš™</span> Modelo de IA</div>
334
+ </div>
335
+ <div class="panel-body">
336
+ <div class="prefs-view-body">
337
+
338
+ <div class="prefs-modes" id="view-prefs-modes">
339
+ <button class="prefs-mode-btn active" data-mode="auto">AutomΓ‘tico</button>
340
+ <button class="prefs-mode-btn" data-mode="external">Proveedor externo</button>
341
+ <button class="prefs-mode-btn" data-mode="local">Modelo local</button>
342
+ <button class="prefs-mode-btn" data-mode="custom">Personalizado</button>
343
+ </div>
344
+
345
+ <div class="prefs-section active" id="view-prefs-auto">
346
+ <p class="prefs-description">
347
+ El servidor usa su cadena de respaldo configurada por variables de entorno:<br>
348
+ <strong>DeepSeek β†’ OpenRouter β†’ HuggingFace β†’ Ollama local β†’ regla de precio.</strong>
349
+ </p>
350
+ </div>
351
+
352
+ <div class="prefs-section" id="view-prefs-external">
353
+ <div class="form-group">
354
+ <label for="view-prefs-provider">Proveedor</label>
355
+ <select class="filter-select prefs-select" id="view-prefs-provider">
356
+ <option value="deepseek">DeepSeek API</option>
357
+ <option value="openrouter">OpenRouter</option>
358
+ <option value="huggingface">HuggingFace Inference</option>
359
+ </select>
360
+ </div>
361
+ <div class="form-group">
362
+ <label for="view-prefs-api-key">Clave API</label>
363
+ <input type="password" id="view-prefs-api-key" placeholder="sk-..." autocomplete="off" />
364
+ </div>
365
+ </div>
366
+
367
+ <div class="prefs-section" id="view-prefs-local">
368
+ <p class="prefs-description">Compatible con Ollama. Usa <code>/api/chat</code> en la URL base.</p>
369
+ <div class="form-group">
370
+ <label for="view-prefs-local-url">URL del servidor</label>
371
+ <input type="text" id="view-prefs-local-url" placeholder="http://localhost:11434" />
372
+ </div>
373
+ <div class="form-group">
374
+ <label for="view-prefs-local-model">Nombre del modelo</label>
375
+ <input type="text" id="view-prefs-local-model" placeholder="qwen3:8b" />
376
+ </div>
377
+ </div>
378
+
379
+ <div class="prefs-section" id="view-prefs-custom">
380
+ <p class="prefs-description">Cualquier endpoint compatible con OpenAI Chat Completions.</p>
381
+ <div class="form-group">
382
+ <label for="view-prefs-custom-url">Endpoint URL</label>
383
+ <input type="text" id="view-prefs-custom-url" placeholder="https://api.example.com/v1/chat/completions" />
384
+ </div>
385
+ <div class="form-group">
386
+ <label for="view-prefs-custom-key">Clave API (opcional)</label>
387
+ <input type="password" id="view-prefs-custom-key" placeholder="sk-..." autocomplete="off" />
388
+ </div>
389
+ <div class="form-group">
390
+ <label for="view-prefs-custom-model">Nombre del modelo</label>
391
+ <input type="text" id="view-prefs-custom-model" placeholder="gpt-4o-mini" />
392
+ </div>
393
+ </div>
394
+
395
+ <div class="form-status" id="view-prefs-status"></div>
396
+ <div class="form-actions">
397
+ <button type="button" class="modal-submit" id="btn-view-save-prefs">Guardar</button>
398
+ </div>
399
+
400
+ </div>
401
+ </div>
402
+ </div>
403
+ </section>
404
+
405
  </main>
406
  </div>
407
 
 
515
  </div>
516
  </div>
517
 
518
+
519
+ <script type="module" src="/src/main.js"></script>
520
  </body>
521
  </html>
frontend/src/api.js CHANGED
@@ -83,8 +83,21 @@ export async function register(email, password) {
83
  return body
84
  }
85
 
86
- export function logout() {
87
- clearToken()
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
  export async function getMe() {
@@ -212,3 +225,15 @@ export async function getAlerts() {
212
  export async function getStats() {
213
  return fetchJson(`${BASE}/stats`)
214
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  return body
84
  }
85
 
86
+ export async function logout() {
87
+ const token = getToken()
88
+ try {
89
+ if (token) {
90
+ await fetch(`${BASE}/auth/logout`, {
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ Authorization: `Bearer ${token}`,
95
+ },
96
+ })
97
+ }
98
+ } finally {
99
+ clearToken()
100
+ }
101
  }
102
 
103
  export async function getMe() {
 
225
  export async function getStats() {
226
  return fetchJson(`${BASE}/stats`)
227
  }
228
+
229
+ /* ─── Preferences ─── */
230
+ export async function getPreferences() {
231
+ return fetchJson(`${BASE}/preferences`)
232
+ }
233
+
234
+ export async function savePreferences(prefs) {
235
+ return fetchJson(`${BASE}/preferences`, {
236
+ method: 'PUT',
237
+ body: JSON.stringify(prefs),
238
+ })
239
+ }
frontend/src/app.js CHANGED
@@ -32,6 +32,36 @@ import * as map from './map.js'
32
  import * as simulator from './simulator.js'
33
  import { extractFilterOptions, filterMarkets } from './filters.js'
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  /* ─── Estado global ─── */
36
  let state = {
37
  view: 'dashboard',
@@ -227,13 +257,6 @@ function filterByTrend(markets, trendType) {
227
  .sort((a, b) => a.momentum - b.momentum)
228
  .map((w) => w.market)
229
 
230
- case 'volatile':
231
- // MΓ‘s volΓ‘tiles = mayor desviaciΓ³n estΓ‘ndar de cambios
232
- return withTrend
233
- .filter((w) => w.volatility > 0.3)
234
- .sort((a, b) => b.volatility - a.volatility)
235
- .map((w) => w.market)
236
-
237
  case 'high-volume':
238
  // Alto volumen
239
  return withTrend
@@ -241,24 +264,33 @@ function filterByTrend(markets, trendType) {
241
  .sort((a, b) => b.volume - a.volume)
242
  .map((w) => w.market)
243
 
 
 
 
 
 
 
 
244
  default:
245
  return markets
246
  }
247
  }
248
 
249
- /* ─── Auth Modal ─── */
250
- function openAuthModal() {
251
- document.getElementById('auth-modal')?.classList.remove('hidden')
252
- }
253
-
254
- function closeAuthModal() {
255
- document.getElementById('auth-modal')?.classList.add('hidden')
256
  const loginError = document.getElementById('login-error')
257
  const registerError = document.getElementById('register-error')
258
  if (loginError) loginError.textContent = ''
259
  if (registerError) registerError.textContent = ''
260
  }
261
 
 
 
 
 
 
262
  function switchAuthTab(tab) {
263
  document.querySelectorAll('.modal-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab))
264
  document.querySelectorAll('#auth-modal .modal-form').forEach((f) => f.classList.toggle('active', f.id === `form-${tab}`))
@@ -268,6 +300,83 @@ function switchAuthTab(tab) {
268
  if (registerError) registerError.textContent = ''
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  /* ─── Telegram Modal ─── */
272
  function openTelegramModal() {
273
  const modal = document.getElementById('telegram-modal')
@@ -368,14 +477,14 @@ function updateAuthButton() {
368
  if (btn) {
369
  if (authed) {
370
  btn.textContent = 'Salir'
371
- btn.onclick = () => {
372
- api.logout()
373
  updateAuthButton()
374
- location.reload()
375
  }
376
  } else {
377
  btn.textContent = 'Entrar'
378
- btn.onclick = openAuthModal
379
  }
380
  }
381
 
@@ -383,12 +492,12 @@ function updateAuthButton() {
383
  indicator.classList.toggle('logged-in', authed)
384
  indicator.title = authed ? 'Salir' : 'Entrar'
385
  indicator.onclick = authed
386
- ? () => {
387
- api.logout()
388
  updateAuthButton()
389
- location.reload()
390
  }
391
- : openAuthModal
392
  }
393
  }
394
 
@@ -397,9 +506,31 @@ async function handleLogin(e) {
397
  const email = document.getElementById('login-email').value.trim()
398
  const password = document.getElementById('login-password').value
399
  const errorEl = document.getElementById('login-error')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  try {
401
  await api.login(email, password)
402
- closeAuthModal()
403
  updateAuthButton()
404
  await initAppData()
405
  } catch (err) {
@@ -407,6 +538,12 @@ async function handleLogin(e) {
407
  }
408
  }
409
 
 
 
 
 
 
 
410
  async function handleRegister(e) {
411
  e.preventDefault()
412
  const email = document.getElementById('register-email').value.trim()
@@ -414,25 +551,59 @@ async function handleRegister(e) {
414
  const confirm = document.getElementById('register-password-confirm').value
415
  const errorEl = document.getElementById('register-error')
416
 
417
- if (password !== confirm) {
418
- errorEl.textContent = 'Las contraseΓ±as no coinciden.'
419
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  }
421
- if (password.length < 8) {
422
- errorEl.textContent = 'La contraseΓ±a debe tener al menos 8 caracteres.'
 
 
 
 
 
 
 
 
423
  return
424
  }
425
 
426
  try {
427
  await api.register(email, password)
428
- closeAuthModal()
429
  updateAuthButton()
430
  await initAppData()
431
  } catch (err) {
432
- errorEl.textContent = 'Error al registrar. El correo podrΓ­a estar en uso.'
 
 
 
 
 
 
433
  }
434
  }
435
 
 
 
 
 
 
 
436
  async function ensureAuth() {
437
  if (!api.isAuthenticated()) return false
438
  try {
@@ -452,6 +623,7 @@ function switchView(viewName) {
452
  if (viewName === 'positions') renderPositions()
453
  if (viewName === 'watchlist') renderWatchlist()
454
  if (viewName === 'alerts') renderAlerts()
 
455
  }
456
 
457
  /* ─── Sidebar toggle ─── */
@@ -462,6 +634,7 @@ function toggleSidebar() {
462
 
463
  /* ─── Panel toggle ─── */
464
  function togglePanel(panelId) {
 
465
  const panel = document.getElementById(`panel-${panelId}`)
466
  if (!panel) return
467
  const isCollapsed = panel.classList.toggle('collapsed')
@@ -469,6 +642,71 @@ function togglePanel(panelId) {
469
  else state.collapsedPanels.delete(panelId)
470
  }
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  /* ─── Signal card factory ─── */
473
  function makeSignalCard(m) {
474
  const sig = state.signals.find((s) => s.marketId === m.id) || null
@@ -559,6 +797,55 @@ function makeSignalCard(m) {
559
  card.append(edgeRow)
560
  }
561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  card.addEventListener('click', () => selectMarket(card.dataset.market))
563
  return card
564
  }
@@ -1177,9 +1464,18 @@ export async function init() {
1177
  })
1178
  })
1179
 
 
 
 
 
 
 
 
1180
  // Telegram modal events
1181
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1182
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
 
 
1183
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1184
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1185
  if (e.target.id === 'telegram-modal') closeTelegramModal()
@@ -1187,27 +1483,26 @@ export async function init() {
1187
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1188
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1189
 
1190
- // Auth modal events
1191
- document.getElementById('btn-auth')?.addEventListener('click', openAuthModal)
1192
- document.getElementById('modal-close')?.addEventListener('click', closeAuthModal)
1193
- document.querySelectorAll('.modal-tab').forEach((tab) => {
1194
  tab.addEventListener('click', () => switchAuthTab(tab.dataset.tab))
1195
  })
1196
  document.getElementById('form-login')?.addEventListener('submit', handleLogin)
 
1197
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1198
- document.getElementById('auth-modal')?.addEventListener('click', (e) => {
1199
- if (e.target.id === 'auth-modal') closeAuthModal()
1200
- })
1201
 
1202
  updateAuthButton()
1203
 
1204
- // Si hay token, carga datos; si no, muestra el modal
1205
  const authed = await ensureAuth()
1206
  if (authed) {
 
1207
  await initAppData()
1208
  initFilters()
1209
  } else {
1210
- openAuthModal()
1211
  }
1212
 
1213
  const socket = io()
 
32
  import * as simulator from './simulator.js'
33
  import { extractFilterOptions, filterMarkets } from './filters.js'
34
 
35
+ /* ─── Helpers de validaciΓ³n de formularios ─── */
36
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
37
+
38
+ function isValidEmail(value) {
39
+ return EMAIL_RE.test(value.trim())
40
+ }
41
+
42
+ function showFieldError(inputId, msg) {
43
+ const input = document.getElementById(inputId)
44
+ const errorEl = document.getElementById(`${inputId}-error`)
45
+ if (input) input.classList.add('input-invalid')
46
+ if (errorEl) errorEl.textContent = msg
47
+ }
48
+
49
+ function clearFieldError(inputId) {
50
+ const input = document.getElementById(inputId)
51
+ const errorEl = document.getElementById(`${inputId}-error`)
52
+ if (input) input.classList.remove('input-invalid')
53
+ if (errorEl) errorEl.textContent = ''
54
+ }
55
+
56
+ function clearAllFieldErrors(...inputIds) {
57
+ inputIds.forEach(clearFieldError)
58
+ }
59
+
60
+ function focusFirstInvalid(form) {
61
+ const invalid = form.querySelector('.input-invalid')
62
+ if (invalid) invalid.focus()
63
+ }
64
+
65
  /* ─── Estado global ─── */
66
  let state = {
67
  view: 'dashboard',
 
257
  .sort((a, b) => a.momentum - b.momentum)
258
  .map((w) => w.market)
259
 
 
 
 
 
 
 
 
260
  case 'high-volume':
261
  // Alto volumen
262
  return withTrend
 
264
  .sort((a, b) => b.volume - a.volume)
265
  .map((w) => w.market)
266
 
267
+ case 'open-only':
268
+ // Solo mercados activos
269
+ return withTrend
270
+ .filter((w) => w.market.status === 'active')
271
+ .sort((a, b) => b.volume - a.volume)
272
+ .map((w) => w.market)
273
+
274
  default:
275
  return markets
276
  }
277
  }
278
 
279
+ /* ─── Auth Page ─── */
280
+ function showAuthView() {
281
+ document.getElementById('view-auth')?.classList.remove('hidden')
282
+ document.getElementById('app')?.classList.add('hidden')
 
 
 
283
  const loginError = document.getElementById('login-error')
284
  const registerError = document.getElementById('register-error')
285
  if (loginError) loginError.textContent = ''
286
  if (registerError) registerError.textContent = ''
287
  }
288
 
289
+ function showDashboardView() {
290
+ document.getElementById('view-auth')?.classList.add('hidden')
291
+ document.getElementById('app')?.classList.remove('hidden')
292
+ }
293
+
294
  function switchAuthTab(tab) {
295
  document.querySelectorAll('.modal-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab))
296
  document.querySelectorAll('#auth-modal .modal-form').forEach((f) => f.classList.toggle('active', f.id === `form-${tab}`))
 
300
  if (registerError) registerError.textContent = ''
301
  }
302
 
303
+ /* ─── Preferences ─── */
304
+ const PREFS_KEY = 'polysignal_prefs'
305
+
306
+ async function persistPrefs(payload, statusEl) {
307
+ try {
308
+ await api.savePreferences(payload)
309
+ localStorage.setItem(PREFS_KEY, JSON.stringify({
310
+ mode: payload.mode,
311
+ provider: payload.provider,
312
+ endpoint: payload.endpoint,
313
+ model: payload.model,
314
+ }))
315
+ statusEl.textContent = 'ConfiguraciΓ³n guardada. Aplicada en el prΓ³ximo ciclo de seΓ±ales.'
316
+ statusEl.className = 'form-status success'
317
+ return true
318
+ } catch {
319
+ statusEl.textContent = 'Error al guardar. Comprueba la conexiΓ³n con el servidor.'
320
+ statusEl.className = 'form-status error'
321
+ return false
322
+ }
323
+ }
324
+
325
+ function renderPreferencesView() {
326
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')
327
+ setViewPrefsMode(saved.mode || 'auto')
328
+ const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
329
+ set('view-prefs-provider', saved.provider || 'deepseek')
330
+ set('view-prefs-api-key', '')
331
+ set('view-prefs-local-url', saved.endpoint || 'http://localhost:11434')
332
+ set('view-prefs-local-model', saved.model || 'qwen3:8b')
333
+ set('view-prefs-custom-url', saved.endpoint || '')
334
+ set('view-prefs-custom-key', '')
335
+ set('view-prefs-custom-model', saved.model || '')
336
+ const statusEl = document.getElementById('view-prefs-status')
337
+ if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status' }
338
+ }
339
+
340
+ function setViewPrefsMode(mode) {
341
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
342
+ btn.classList.toggle('active', btn.dataset.mode === mode)
343
+ })
344
+ document.querySelectorAll('#view-preferences .prefs-section').forEach((sec) => {
345
+ sec.classList.toggle('active', sec.id === `view-prefs-${mode}`)
346
+ })
347
+ }
348
+
349
+ async function handleViewPrefsSave() {
350
+ const statusEl = document.getElementById('view-prefs-status')
351
+ const activeMode = document.querySelector('#view-prefs-modes .prefs-mode-btn.active')?.dataset.mode || 'auto'
352
+ const val = (id) => document.getElementById(id)?.value?.trim() || ''
353
+ const payload = { mode: activeMode }
354
+ if (activeMode === 'external') {
355
+ payload.provider = val('view-prefs-provider')
356
+ const key = val('view-prefs-api-key')
357
+ if (!key) {
358
+ statusEl.textContent = 'Introduce la clave API del proveedor seleccionado.'
359
+ statusEl.className = 'form-status error'
360
+ return
361
+ }
362
+ payload.apiKey = key
363
+ } else if (activeMode === 'local') {
364
+ payload.endpoint = val('view-prefs-local-url') || 'http://localhost:11434'
365
+ payload.model = val('view-prefs-local-model') || 'qwen3:8b'
366
+ } else if (activeMode === 'custom') {
367
+ const url = val('view-prefs-custom-url')
368
+ if (!url) {
369
+ statusEl.textContent = 'Introduce la URL del endpoint.'
370
+ statusEl.className = 'form-status error'
371
+ return
372
+ }
373
+ payload.endpoint = url
374
+ payload.apiKey = val('view-prefs-custom-key')
375
+ payload.model = val('view-prefs-custom-model')
376
+ }
377
+ await persistPrefs(payload, statusEl)
378
+ }
379
+
380
  /* ─── Telegram Modal ─── */
381
  function openTelegramModal() {
382
  const modal = document.getElementById('telegram-modal')
 
477
  if (btn) {
478
  if (authed) {
479
  btn.textContent = 'Salir'
480
+ btn.onclick = async () => {
481
+ await api.logout()
482
  updateAuthButton()
483
+ showAuthView()
484
  }
485
  } else {
486
  btn.textContent = 'Entrar'
487
+ btn.onclick = showAuthView
488
  }
489
  }
490
 
 
492
  indicator.classList.toggle('logged-in', authed)
493
  indicator.title = authed ? 'Salir' : 'Entrar'
494
  indicator.onclick = authed
495
+ ? async () => {
496
+ await api.logout()
497
  updateAuthButton()
498
+ showAuthView()
499
  }
500
+ : showAuthView
501
  }
502
  }
503
 
 
506
  const email = document.getElementById('login-email').value.trim()
507
  const password = document.getElementById('login-password').value
508
  const errorEl = document.getElementById('login-error')
509
+
510
+ clearAllFieldErrors('login-email', 'login-password')
511
+ errorEl.textContent = ''
512
+
513
+ let valid = true
514
+ if (!email) {
515
+ showFieldError('login-email', 'Introduce tu correo electrΓ³nico.')
516
+ valid = false
517
+ } else if (!isValidEmail(email)) {
518
+ showFieldError('login-email', 'El formato del correo no es vΓ‘lido.')
519
+ valid = false
520
+ }
521
+ if (!password) {
522
+ showFieldError('login-password', 'Introduce tu contraseΓ±a.')
523
+ valid = false
524
+ }
525
+
526
+ if (!valid) {
527
+ focusFirstInvalid(e.target)
528
+ return
529
+ }
530
+
531
  try {
532
  await api.login(email, password)
533
+ showDashboardView()
534
  updateAuthButton()
535
  await initAppData()
536
  } catch (err) {
 
538
  }
539
  }
540
 
541
+ function attachLoginInputListeners() {
542
+ ;['login-email', 'login-password'].forEach((id) => {
543
+ document.getElementById(id)?.addEventListener('input', () => clearFieldError(id))
544
+ })
545
+ }
546
+
547
  async function handleRegister(e) {
548
  e.preventDefault()
549
  const email = document.getElementById('register-email').value.trim()
 
551
  const confirm = document.getElementById('register-password-confirm').value
552
  const errorEl = document.getElementById('register-error')
553
 
554
+ clearAllFieldErrors('register-email', 'register-password', 'register-password-confirm')
555
+ errorEl.textContent = ''
556
+
557
+ let valid = true
558
+ if (!email) {
559
+ showFieldError('register-email', 'Introduce tu correo electrΓ³nico.')
560
+ valid = false
561
+ } else if (!isValidEmail(email)) {
562
+ showFieldError('register-email', 'El formato del correo no es vΓ‘lido.')
563
+ valid = false
564
+ }
565
+ if (!password) {
566
+ showFieldError('register-password', 'Introduce una contraseΓ±a.')
567
+ valid = false
568
+ } else if (password.length < 8) {
569
+ showFieldError('register-password', 'La contraseΓ±a debe tener al menos 8 caracteres.')
570
+ valid = false
571
  }
572
+ if (!confirm) {
573
+ showFieldError('register-password-confirm', 'Confirma tu contraseΓ±a.')
574
+ valid = false
575
+ } else if (confirm !== password) {
576
+ showFieldError('register-password-confirm', 'Las contraseΓ±as no coinciden.')
577
+ valid = false
578
+ }
579
+
580
+ if (!valid) {
581
+ focusFirstInvalid(e.target)
582
  return
583
  }
584
 
585
  try {
586
  await api.register(email, password)
587
+ showDashboardView()
588
  updateAuthButton()
589
  await initAppData()
590
  } catch (err) {
591
+ const isEmailTaken = err.message?.includes('EMAIL_EXISTS') || err.message?.includes('409')
592
+ if (isEmailTaken) {
593
+ showFieldError('register-email', 'Este correo ya estΓ‘ registrado.')
594
+ focusFirstInvalid(e.target)
595
+ } else {
596
+ errorEl.textContent = 'Error al registrar. IntΓ©ntalo de nuevo.'
597
+ }
598
  }
599
  }
600
 
601
+ function attachRegisterInputListeners() {
602
+ ;['register-email', 'register-password', 'register-password-confirm'].forEach((id) => {
603
+ document.getElementById(id)?.addEventListener('input', () => clearFieldError(id))
604
+ })
605
+ }
606
+
607
  async function ensureAuth() {
608
  if (!api.isAuthenticated()) return false
609
  try {
 
623
  if (viewName === 'positions') renderPositions()
624
  if (viewName === 'watchlist') renderWatchlist()
625
  if (viewName === 'alerts') renderAlerts()
626
+ if (viewName === 'preferences') renderPreferencesView()
627
  }
628
 
629
  /* ─── Sidebar toggle ─── */
 
634
 
635
  /* ─── Panel toggle ─── */
636
  function togglePanel(panelId) {
637
+ if (!window.matchMedia('(max-width: 640px)').matches) return
638
  const panel = document.getElementById(`panel-${panelId}`)
639
  if (!panel) return
640
  const isCollapsed = panel.classList.toggle('collapsed')
 
642
  else state.collapsedPanels.delete(panelId)
643
  }
644
 
645
+ /* ─── Watchlist / Alert helpers ─── */
646
+ function isInWatchlist(marketId) {
647
+ return state.watchlist.some((w) => w.marketId === marketId)
648
+ }
649
+
650
+ function hasAlert(marketId) {
651
+ return state.watchlist.some((w) => w.marketId === marketId && w.alertThreshold != null)
652
+ }
653
+
654
+ async function toggleWatchlistCard(marketId, wBtn, aBtn) {
655
+ if (isInWatchlist(marketId)) {
656
+ try {
657
+ await api.removeFromWatchlist(marketId)
658
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
659
+ wBtn.textContent = 'β˜† Seguimiento'
660
+ wBtn.classList.remove('active')
661
+ wBtn.title = 'AΓ±adir a seguimiento'
662
+ aBtn.textContent = '⚑ Alertas'
663
+ aBtn.classList.remove('active')
664
+ aBtn.title = 'Activar alerta de precio'
665
+ } catch (e) { console.warn('Error al quitar de watchlist:', e) }
666
+ } else {
667
+ try {
668
+ const entry = await api.addToWatchlist(marketId)
669
+ state.watchlist.push(entry ?? { marketId })
670
+ wBtn.textContent = 'β˜… Seguimiento'
671
+ wBtn.classList.add('active')
672
+ wBtn.title = 'Quitar de seguimiento'
673
+ } catch (e) { console.warn('Error al aΓ±adir a watchlist:', e) }
674
+ }
675
+ }
676
+
677
+ function toggleAlertCard(marketId, aBtn, thresholdRow) {
678
+ if (hasAlert(marketId)) {
679
+ api.removeFromWatchlist(marketId)
680
+ .then(() => api.addToWatchlist(marketId, null))
681
+ .then((entry) => {
682
+ const idx = state.watchlist.findIndex((w) => w.marketId === marketId)
683
+ if (idx >= 0) state.watchlist[idx] = { ...state.watchlist[idx], alertThreshold: null }
684
+ else state.watchlist.push(entry ?? { marketId })
685
+ aBtn.textContent = '⚑ Alertas'
686
+ aBtn.classList.remove('active')
687
+ aBtn.title = 'Activar alerta de precio'
688
+ })
689
+ .catch((e) => console.warn('Error al desactivar alerta:', e))
690
+ } else {
691
+ thresholdRow.classList.toggle('hidden')
692
+ }
693
+ }
694
+
695
+ async function setAlertThreshold(marketId, threshold, aBtn, thresholdRow) {
696
+ try {
697
+ if (isInWatchlist(marketId)) {
698
+ await api.removeFromWatchlist(marketId)
699
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
700
+ }
701
+ const entry = await api.addToWatchlist(marketId, threshold)
702
+ state.watchlist.push(entry ?? { marketId, alertThreshold: threshold })
703
+ aBtn.textContent = '⚑ Alerta activa'
704
+ aBtn.classList.add('active')
705
+ aBtn.title = 'Desactivar alerta'
706
+ thresholdRow.classList.add('hidden')
707
+ } catch (e) { console.warn('Error al configurar alerta:', e) }
708
+ }
709
+
710
  /* ─── Signal card factory ─── */
711
  function makeSignalCard(m) {
712
  const sig = state.signals.find((s) => s.marketId === m.id) || null
 
797
  card.append(edgeRow)
798
  }
799
 
800
+ // ── Action buttons (Seguimiento + Alertas) ──
801
+ const inWatchlist = isInWatchlist(m.id)
802
+ const alertActive = hasAlert(m.id)
803
+
804
+ const wBtn = el('button', `card-btn-watch${inWatchlist ? ' active' : ''}`)
805
+ wBtn.textContent = inWatchlist ? 'β˜… Seguimiento' : 'β˜† Seguimiento'
806
+ wBtn.title = inWatchlist ? 'Quitar de seguimiento' : 'AΓ±adir a seguimiento'
807
+
808
+ const aBtn = el('button', `card-btn-alert${alertActive ? ' active' : ''}`)
809
+ aBtn.textContent = alertActive ? '⚑ Alerta activa' : '⚑ Alertas'
810
+ aBtn.title = alertActive ? 'Desactivar alerta' : 'Activar alerta de precio'
811
+
812
+ // Inline threshold form
813
+ const thresholdRow = el('div', 'card-threshold-row hidden')
814
+ const thresholdInput = el('input', 'threshold-input')
815
+ thresholdInput.type = 'number'
816
+ thresholdInput.min = '1'
817
+ thresholdInput.max = '99'
818
+ thresholdInput.placeholder = 'Umbral Β’'
819
+ thresholdInput.addEventListener('click', (e) => e.stopPropagation())
820
+
821
+ const confirmThreshold = async () => {
822
+ const val = parseInt(thresholdInput.value, 10)
823
+ if (!val || val < 1 || val > 99) { thresholdInput.style.borderColor = 'var(--red)'; return }
824
+ await setAlertThreshold(m.id, val / 100, aBtn, thresholdRow)
825
+ }
826
+
827
+ thresholdInput.addEventListener('keydown', (e) => {
828
+ if (e.key === 'Enter') { e.stopPropagation(); confirmThreshold() }
829
+ if (e.key === 'Escape') { e.stopPropagation(); thresholdRow.classList.add('hidden') }
830
+ })
831
+
832
+ const confirmBtn = el('button', 'threshold-confirm', 'βœ“')
833
+ confirmBtn.title = 'Confirmar umbral'
834
+ confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); confirmThreshold() })
835
+
836
+ const cancelBtn = el('button', 'threshold-cancel', 'βœ•')
837
+ cancelBtn.title = 'Cancelar'
838
+ cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); thresholdRow.classList.add('hidden') })
839
+
840
+ thresholdRow.append(el('span', 'threshold-label', 'Umbral (Β’):'), thresholdInput, confirmBtn, cancelBtn)
841
+
842
+ wBtn.addEventListener('click', async (e) => { e.stopPropagation(); await toggleWatchlistCard(m.id, wBtn, aBtn) })
843
+ aBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleAlertCard(m.id, aBtn, thresholdRow) })
844
+
845
+ const actions = el('div', 'card-actions')
846
+ actions.append(wBtn, aBtn)
847
+ card.append(actions, thresholdRow)
848
+
849
  card.addEventListener('click', () => selectMarket(card.dataset.market))
850
  return card
851
  }
 
1464
  })
1465
  })
1466
 
1467
+ // Preferences view events (all screen sizes)
1468
+ document.getElementById('btn-prefs')?.addEventListener('click', () => switchView('preferences'))
1469
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
1470
+ btn.addEventListener('click', () => setViewPrefsMode(btn.dataset.mode))
1471
+ })
1472
+ document.getElementById('btn-view-save-prefs')?.addEventListener('click', handleViewPrefsSave)
1473
+
1474
  // Telegram modal events
1475
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1476
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
1477
+ document.getElementById('btn-watchlist-mobile')?.addEventListener('click', () => switchView('watchlist'))
1478
+ document.getElementById('btn-alerts-mobile')?.addEventListener('click', () => switchView('alerts'))
1479
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1480
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1481
  if (e.target.id === 'telegram-modal') closeTelegramModal()
 
1483
  document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
1484
  document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
1485
 
1486
+ // Auth page events
1487
+ document.getElementById('btn-auth')?.addEventListener('click', showAuthView)
1488
+ document.querySelectorAll('#view-auth .modal-tab').forEach((tab) => {
 
1489
  tab.addEventListener('click', () => switchAuthTab(tab.dataset.tab))
1490
  })
1491
  document.getElementById('form-login')?.addEventListener('submit', handleLogin)
1492
+ attachLoginInputListeners()
1493
  document.getElementById('form-register')?.addEventListener('submit', handleRegister)
1494
+ attachRegisterInputListeners()
 
 
1495
 
1496
  updateAuthButton()
1497
 
1498
+ // Si hay token, carga datos; si no, muestra la pΓ‘gina de auth
1499
  const authed = await ensureAuth()
1500
  if (authed) {
1501
+ showDashboardView()
1502
  await initAppData()
1503
  initFilters()
1504
  } else {
1505
+ showAuthView()
1506
  }
1507
 
1508
  const socket = io()
frontend/src/map.js CHANGED
@@ -27,6 +27,7 @@ import { getCoordsByCode, detectCountryInText } from './capitals.js'
27
 
28
  let mapInstance = null
29
  let bubbles = {} // marketId -> marcador de circulo
 
30
 
31
  // Hubs globales para mercados sin pais. Cubre TODOS los continentes para
32
  // que las bubbles no se concentren en US/EU/Asia. Cada hub tiene tambien un
@@ -158,6 +159,26 @@ function getCoords(market) {
158
  return jitter(pickFinancialHub(market.id), market.id, 3)
159
  }
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  function getSignalColor(signal) {
162
  if (signal === 'bullish') return '#22d37a'
163
  if (signal === 'bearish') return '#f04040'
@@ -193,6 +214,8 @@ export function init(containerId, markets, signals, onSelect) {
193
  maxZoom: 19,
194
  }).addTo(mapInstance)
195
 
 
 
196
  markets.forEach((m) => {
197
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
198
  const color = getSignalColor(sig.signal)
@@ -256,7 +279,15 @@ export function init(containerId, markets, signals, onSelect) {
256
  onSelect(m.id)
257
  })
258
 
259
- bubbles[m.id] = { circle, inner, label, color }
 
 
 
 
 
 
 
 
260
  })
261
  }
262
 
@@ -287,8 +318,10 @@ export function updateMarkers(markets, signals) {
287
  mapInstance.removeLayer(b.circle)
288
  mapInstance.removeLayer(b.inner)
289
  mapInstance.removeLayer(b.label)
 
290
  })
291
  bubbles = {}
 
292
 
293
  // Re-renderizar solo los mercados filtrados
294
  markets.forEach((m) => {
@@ -352,6 +385,14 @@ export function updateMarkers(markets, signals) {
352
  if (window.__onSelectMarket) window.__onSelectMarket(m.id)
353
  })
354
 
355
- bubbles[m.id] = { circle, inner, label, color }
 
 
 
 
 
 
 
 
356
  })
357
  }
 
27
 
28
  let mapInstance = null
29
  let bubbles = {} // marketId -> marcador de circulo
30
+ let diagonalLines = [] // { line, latLng, radiusPx }
31
 
32
  // Hubs globales para mercados sin pais. Cubre TODOS los continentes para
33
  // que las bubbles no se concentren en US/EU/Asia. Cada hub tiene tambien un
 
159
  return jitter(pickFinancialHub(market.id), market.id, 3)
160
  }
161
 
162
+ function createDiagonalLine(latLng, radiusPx) {
163
+ if (!mapInstance) return null
164
+ const center = mapInstance.latLngToLayerPoint(L.latLng(latLng))
165
+ const offset = radiusPx * 0.9
166
+ const p1 = mapInstance.layerPointToLatLng(L.point(center.x - offset, center.y - offset))
167
+ const p2 = mapInstance.layerPointToLatLng(L.point(center.x + offset, center.y + offset))
168
+ const line = L.polyline([p1, p2], { color: '#ffffff', weight: 2.5, opacity: 0.9 }).addTo(mapInstance)
169
+ return { line, latLng, radiusPx }
170
+ }
171
+
172
+ function updateDiagonalLines() {
173
+ diagonalLines.forEach(({ line, latLng, radiusPx }) => {
174
+ const center = mapInstance.latLngToLayerPoint(L.latLng(latLng))
175
+ const offset = radiusPx * 0.9
176
+ const p1 = mapInstance.layerPointToLatLng(L.point(center.x - offset, center.y - offset))
177
+ const p2 = mapInstance.layerPointToLatLng(L.point(center.x + offset, center.y + offset))
178
+ line.setLatLngs([p1, p2])
179
+ })
180
+ }
181
+
182
  function getSignalColor(signal) {
183
  if (signal === 'bullish') return '#22d37a'
184
  if (signal === 'bearish') return '#f04040'
 
214
  maxZoom: 19,
215
  }).addTo(mapInstance)
216
 
217
+ mapInstance.on('zoomend', updateDiagonalLines)
218
+
219
  markets.forEach((m) => {
220
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
221
  const color = getSignalColor(sig.signal)
 
279
  onSelect(m.id)
280
  })
281
 
282
+ const bubble = { circle, inner, label, color }
283
+ if (m.status !== 'active') {
284
+ const d = createDiagonalLine(coords, radius)
285
+ if (d) {
286
+ diagonalLines.push(d)
287
+ bubble.diagonal = d
288
+ }
289
+ }
290
+ bubbles[m.id] = bubble
291
  })
292
  }
293
 
 
318
  mapInstance.removeLayer(b.circle)
319
  mapInstance.removeLayer(b.inner)
320
  mapInstance.removeLayer(b.label)
321
+ if (b.diagonal) mapInstance.removeLayer(b.diagonal.line)
322
  })
323
  bubbles = {}
324
+ diagonalLines = []
325
 
326
  // Re-renderizar solo los mercados filtrados
327
  markets.forEach((m) => {
 
385
  if (window.__onSelectMarket) window.__onSelectMarket(m.id)
386
  })
387
 
388
+ const bubble = { circle, inner, label, color }
389
+ if (m.status !== 'active') {
390
+ const d = createDiagonalLine(coords, radius)
391
+ if (d) {
392
+ diagonalLines.push(d)
393
+ bubble.diagonal = d
394
+ }
395
+ }
396
+ bubbles[m.id] = bubble
397
  })
398
  }
frontend/src/style.css CHANGED
@@ -212,6 +212,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
212
  width: 0;
213
  }
214
 
 
215
  .sidebar-footer {
216
  padding: 14px;
217
  border-top: 1px solid var(--border);
@@ -323,8 +324,8 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
323
  font-family: var(--font-mono);
324
  outline: none;
325
  cursor: pointer;
326
- min-width: 140px;
327
- max-width: 180px;
328
  appearance: none;
329
  -webkit-appearance: none;
330
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b90a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
@@ -430,15 +431,9 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
430
  justify-content: space-between;
431
  padding: 14px 14px;
432
  border-bottom: 0.5px solid var(--border);
433
- cursor: pointer;
434
- user-select: none;
435
  transition: background 0.15s;
436
  }
437
 
438
- .panel-header:hover {
439
- background: rgba(255,255,255,0.02);
440
- }
441
-
442
  .panel-title {
443
  font-size: 0.875rem;
444
  color: var(--text3);
@@ -451,6 +446,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
451
  }
452
 
453
  .panel-toggle {
 
454
  font-size: 0.875rem;
455
  color: var(--text3);
456
  transition: transform 0.2s;
@@ -491,7 +487,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
491
  }
492
 
493
  .hidden {
494
- display: none;
495
  }
496
 
497
  .desktop-only { display: initial; }
@@ -687,6 +683,105 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
687
  .edge-neg { color: var(--red, #f04040); background: var(--red3, rgba(240,64,64,0.08)); }
688
  .edge-zero { color: var(--text3, #6e7681); }
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  /* Sugerencia de tamano (Kelly) bajo el simulador */
691
  .kelly-note {
692
  flex: 1;
@@ -1367,6 +1462,22 @@ td {
1367
  min-height: 18px;
1368
  }
1369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1370
  .modal-submit {
1371
  background: #2563eb;
1372
  border: none;
@@ -1497,6 +1608,95 @@ td {
1497
  opacity: 1;
1498
  }
1499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1500
  /* ─── Telegram Instructions ─── */
1501
  .telegram-instructions {
1502
  display: flex;
@@ -1703,6 +1903,19 @@ td {
1703
  }
1704
 
1705
  @media (max-width: 640px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  .layout {
1707
  grid-template-columns: 0 1fr;
1708
  grid-template-rows: auto 1fr;
@@ -1944,3 +2157,90 @@ td {
1944
  gap: 12px;
1945
  }
1946
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  width: 0;
213
  }
214
 
215
+
216
  .sidebar-footer {
217
  padding: 14px;
218
  border-top: 1px solid var(--border);
 
324
  font-family: var(--font-mono);
325
  outline: none;
326
  cursor: pointer;
327
+ min-width: 160px;
328
+ max-width: 240px;
329
  appearance: none;
330
  -webkit-appearance: none;
331
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b90a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
 
431
  justify-content: space-between;
432
  padding: 14px 14px;
433
  border-bottom: 0.5px solid var(--border);
 
 
434
  transition: background 0.15s;
435
  }
436
 
 
 
 
 
437
  .panel-title {
438
  font-size: 0.875rem;
439
  color: var(--text3);
 
446
  }
447
 
448
  .panel-toggle {
449
+ display: none;
450
  font-size: 0.875rem;
451
  color: var(--text3);
452
  transition: transform 0.2s;
 
487
  }
488
 
489
  .hidden {
490
+ display: none !important;
491
  }
492
 
493
  .desktop-only { display: initial; }
 
683
  .edge-neg { color: var(--red, #f04040); background: var(--red3, rgba(240,64,64,0.08)); }
684
  .edge-zero { color: var(--text3, #6e7681); }
685
 
686
+ /* Botones de accion de tarjeta (Seguimiento + Alertas) */
687
+ .card-actions {
688
+ display: flex;
689
+ gap: 6px;
690
+ margin-top: 8px;
691
+ padding-top: 8px;
692
+ border-top: 0.5px solid var(--border, rgba(255,255,255,0.08));
693
+ }
694
+
695
+ .card-btn-watch,
696
+ .card-btn-alert {
697
+ flex: 1;
698
+ padding: 4px 8px;
699
+ font-size: 0.72rem;
700
+ font-family: var(--font-mono);
701
+ font-weight: 500;
702
+ letter-spacing: 0.03em;
703
+ border: 0.5px solid var(--border2);
704
+ border-radius: 4px;
705
+ background: var(--bg4);
706
+ color: var(--text2);
707
+ cursor: pointer;
708
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
709
+ text-align: center;
710
+ white-space: nowrap;
711
+ }
712
+
713
+ .card-btn-watch:hover { border-color: var(--blue2); color: var(--blue); }
714
+ .card-btn-alert:hover { border-color: var(--amber2); color: var(--amber); }
715
+
716
+ .card-btn-watch.active {
717
+ background: var(--blue3);
718
+ color: var(--blue);
719
+ border-color: var(--blue2);
720
+ }
721
+
722
+ .card-btn-alert.active {
723
+ background: var(--amber3);
724
+ color: var(--amber);
725
+ border-color: var(--amber2);
726
+ }
727
+
728
+ /* Fila de umbral de alerta (inline en la tarjeta) */
729
+ .card-threshold-row {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 6px;
733
+ margin-top: 6px;
734
+ padding: 6px 8px;
735
+ background: rgba(240,160,32,0.05);
736
+ border: 0.5px solid var(--amber2);
737
+ border-radius: 4px;
738
+ }
739
+ .card-threshold-row.hidden { display: none; }
740
+
741
+ .threshold-label {
742
+ font-size: 0.7rem;
743
+ color: var(--text2);
744
+ font-family: var(--font-mono);
745
+ flex-shrink: 0;
746
+ }
747
+
748
+ .threshold-input {
749
+ width: 72px;
750
+ padding: 3px 6px;
751
+ font-size: 0.78rem;
752
+ font-family: var(--font-mono);
753
+ background: var(--bg3);
754
+ border: 0.5px solid var(--border2);
755
+ border-radius: 3px;
756
+ color: var(--text);
757
+ outline: none;
758
+ }
759
+ .threshold-input:focus { border-color: var(--amber2); }
760
+
761
+ .threshold-confirm,
762
+ .threshold-cancel {
763
+ padding: 2px 8px;
764
+ font-size: 0.78rem;
765
+ border-radius: 3px;
766
+ cursor: pointer;
767
+ border: 0.5px solid;
768
+ font-family: var(--font-mono);
769
+ }
770
+
771
+ .threshold-confirm {
772
+ background: var(--green3);
773
+ color: var(--green);
774
+ border-color: var(--green2);
775
+ }
776
+ .threshold-confirm:hover { background: rgba(34,211,122,0.12); }
777
+
778
+ .threshold-cancel {
779
+ background: var(--bg4);
780
+ color: var(--text3);
781
+ border-color: var(--border2);
782
+ }
783
+ .threshold-cancel:hover { background: var(--bg3); }
784
+
785
  /* Sugerencia de tamano (Kelly) bajo el simulador */
786
  .kelly-note {
787
  flex: 1;
 
1462
  min-height: 18px;
1463
  }
1464
 
1465
+ .field-error {
1466
+ display: block;
1467
+ font-size: 0.8125rem;
1468
+ color: var(--red);
1469
+ margin-top: 4px;
1470
+ min-height: 16px;
1471
+ }
1472
+
1473
+ .form-group input.input-invalid {
1474
+ border-color: var(--red);
1475
+ }
1476
+
1477
+ .form-group input.input-invalid:focus {
1478
+ border-color: var(--red);
1479
+ }
1480
+
1481
  .modal-submit {
1482
  background: #2563eb;
1483
  border: none;
 
1608
  opacity: 1;
1609
  }
1610
 
1611
+ /* ─── Auth Page (mobile-first) ─── */
1612
+ .auth-view {
1613
+ min-height: 100dvh;
1614
+ width: 100%;
1615
+ display: grid;
1616
+ place-items: center;
1617
+ padding: 24px 16px;
1618
+ background: var(--bg);
1619
+ }
1620
+
1621
+ .auth-card {
1622
+ width: 100%;
1623
+ max-width: 420px;
1624
+ background: var(--bg2);
1625
+ border: 0.5px solid var(--border2);
1626
+ border-radius: var(--radius);
1627
+ overflow: hidden;
1628
+ }
1629
+
1630
+ .auth-header {
1631
+ padding: 32px 28px 24px;
1632
+ text-align: center;
1633
+ border-bottom: 0.5px solid var(--border);
1634
+ }
1635
+
1636
+ .auth-logo {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ justify-content: center;
1640
+ gap: 10px;
1641
+ margin-bottom: 12px;
1642
+ }
1643
+
1644
+ .auth-logo .logo-dot {
1645
+ width: 10px;
1646
+ height: 10px;
1647
+ border-radius: 50%;
1648
+ background: var(--accent);
1649
+ box-shadow: 0 0 8px var(--accent);
1650
+ flex-shrink: 0;
1651
+ }
1652
+
1653
+ .auth-logo .logo-text {
1654
+ font-family: var(--font-sans);
1655
+ font-size: 1.5rem;
1656
+ font-weight: 700;
1657
+ color: var(--text);
1658
+ letter-spacing: -0.02em;
1659
+ }
1660
+
1661
+ .auth-claim {
1662
+ font-size: 0.8125rem;
1663
+ color: var(--text2);
1664
+ line-height: 1.55;
1665
+ max-width: 320px;
1666
+ margin: 0 auto;
1667
+ }
1668
+
1669
+ .auth-body {
1670
+ padding: 0 28px 28px;
1671
+ }
1672
+
1673
+ .auth-body .modal-tabs {
1674
+ border-bottom: 0.5px solid var(--border);
1675
+ margin: 0 -28px 24px;
1676
+ padding: 0 28px;
1677
+ }
1678
+
1679
+ @media (max-width: 480px) {
1680
+ .auth-view {
1681
+ padding: 16px 12px;
1682
+ align-items: flex-start;
1683
+ padding-top: clamp(40px, 10dvh, 80px);
1684
+ }
1685
+
1686
+ .auth-header {
1687
+ padding: 24px 20px 20px;
1688
+ }
1689
+
1690
+ .auth-body {
1691
+ padding: 0 20px 24px;
1692
+ }
1693
+
1694
+ .auth-body .modal-tabs {
1695
+ margin: 0 -20px 20px;
1696
+ padding: 0 20px;
1697
+ }
1698
+ }
1699
+
1700
  /* ─── Telegram Instructions ─── */
1701
  .telegram-instructions {
1702
  display: flex;
 
1903
  }
1904
 
1905
  @media (max-width: 640px) {
1906
+ .panel-header[data-panel] {
1907
+ cursor: pointer;
1908
+ user-select: none;
1909
+ }
1910
+
1911
+ .panel-header[data-panel]:hover {
1912
+ background: rgba(255,255,255,0.02);
1913
+ }
1914
+
1915
+ .panel-toggle {
1916
+ display: block;
1917
+ }
1918
+
1919
  .layout {
1920
  grid-template-columns: 0 1fr;
1921
  grid-template-rows: auto 1fr;
 
2157
  gap: 12px;
2158
  }
2159
  }
2160
+
2161
+ /* ─── Preferences View ─── */
2162
+
2163
+ .prefs-modes {
2164
+ display: flex;
2165
+ gap: 6px;
2166
+ margin-bottom: 20px;
2167
+ }
2168
+
2169
+ .prefs-mode-btn {
2170
+ flex: 1;
2171
+ background: var(--bg3);
2172
+ border: 0.5px solid var(--border2);
2173
+ color: var(--text2);
2174
+ font-size: 0.8125rem;
2175
+ font-family: var(--font-mono);
2176
+ text-transform: uppercase;
2177
+ letter-spacing: 0.05em;
2178
+ padding: 6px 14px;
2179
+ border-radius: var(--radius-sm);
2180
+ cursor: pointer;
2181
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
2182
+ }
2183
+
2184
+ .prefs-mode-btn:hover {
2185
+ color: var(--text);
2186
+ border-color: var(--text3);
2187
+ }
2188
+
2189
+ .prefs-mode-btn.active {
2190
+ background: var(--blue3);
2191
+ border-color: var(--blue2);
2192
+ color: var(--blue);
2193
+ }
2194
+
2195
+ .prefs-section {
2196
+ display: none;
2197
+ flex-direction: column;
2198
+ gap: 16px;
2199
+ margin-bottom: 8px;
2200
+ }
2201
+
2202
+ .prefs-section.active {
2203
+ display: flex;
2204
+ }
2205
+
2206
+ .prefs-description {
2207
+ font-size: 0.9375rem;
2208
+ color: var(--text2);
2209
+ line-height: 1.6;
2210
+ }
2211
+
2212
+ .prefs-description strong {
2213
+ color: var(--text);
2214
+ }
2215
+
2216
+ .prefs-description code {
2217
+ font-family: var(--font-mono);
2218
+ color: var(--blue);
2219
+ font-size: 0.875rem;
2220
+ }
2221
+
2222
+ .prefs-select {
2223
+ width: 100%;
2224
+ background: var(--bg3);
2225
+ border: 0.5px solid var(--border2);
2226
+ border-radius: var(--radius-sm);
2227
+ padding: 12px 14px;
2228
+ color: var(--text);
2229
+ font-size: 0.9375rem;
2230
+ font-family: var(--font-sans);
2231
+ outline: none;
2232
+ cursor: pointer;
2233
+ }
2234
+
2235
+ .prefs-select:focus {
2236
+ border-color: var(--blue2);
2237
+ }
2238
+
2239
+ .prefs-view-body {
2240
+ max-width: 520px;
2241
+ width: 100%;
2242
+ margin: 0 auto;
2243
+ display: flex;
2244
+ flex-direction: column;
2245
+ gap: 16px;
2246
+ }
package.json CHANGED
@@ -16,6 +16,7 @@
16
  "preview:frontend": "npm run preview --workspace=frontend",
17
  "db:migrate": "npm run db:migrate --workspace=backend",
18
  "db:generate": "npm run db:generate --workspace=backend",
 
19
  "db:studio": "npm run db:studio --workspace=backend"
20
  },
21
  "engines": {
 
16
  "preview:frontend": "npm run preview --workspace=frontend",
17
  "db:migrate": "npm run db:migrate --workspace=backend",
18
  "db:generate": "npm run db:generate --workspace=backend",
19
+ "db:reset": "npm run db:reset --workspace=backend",
20
  "db:studio": "npm run db:studio --workspace=backend"
21
  },
22
  "engines": {