Spaces:
Sleeping
Sleeping
germansango01 commited on
Commit ·
b6ee67e
1
Parent(s): ce80584
Creacion de modulo auth
Browse files- backend/.env.example +20 -0
- backend/docs/AUTH.md +172 -0
- backend/package.json +15 -4
- backend/prisma/migrations/20260516071158_init_auth/migration.sql +105 -0
- backend/prisma/migrations/20260516072006_remove_user_role/migration.sql +24 -0
- backend/prisma/migrations/migration_lock.toml +3 -0
- backend/prisma/schema.prisma +5 -1
- backend/prisma/seed.js +32 -0
- backend/src/app.js +22 -0
- backend/src/auth/auth.controller.js +11 -0
- backend/src/auth/auth.routes.js +13 -0
- backend/src/auth/auth.service.js +21 -0
- backend/src/auth/auth.validators.js +6 -0
- backend/src/auth/jwt.js +11 -0
- backend/src/config.js +23 -9
- backend/src/index.js +20 -9
- backend/src/middlewares/errorHandler.js +18 -0
- backend/src/middlewares/notFound.js +5 -0
- backend/src/middlewares/rateLimitLogin.js +12 -0
- backend/src/middlewares/requireAuth.js +29 -0
- backend/src/middlewares/validate.js +12 -0
- backend/src/utils/apiResponse.js +15 -0
- backend/src/utils/logger.js +5 -0
- backend/src/utils/prisma.js +13 -0
backend/.env.example
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Variables de entorno del backend PolySignal.
|
| 2 |
+
# Copiar este archivo a backend/.env y rellenar los valores.
|
| 3 |
+
# El archivo .env real NO debe commitearse (asegurar entrada en .gitignore).
|
| 4 |
+
|
| 5 |
+
NODE_ENV=development
|
| 6 |
+
PORT=7860
|
| 7 |
+
|
| 8 |
+
# SQLite path relativo al archivo schema.prisma (backend/prisma/).
|
| 9 |
+
DATABASE_URL=file:./polysignal.db
|
| 10 |
+
|
| 11 |
+
# Auth — generar con: openssl rand -hex 48
|
| 12 |
+
JWT_SECRET=replace-with-64-plus-random-chars
|
| 13 |
+
JWT_EXPIRES_IN=1h
|
| 14 |
+
BCRYPT_ROUNDS=10
|
| 15 |
+
|
| 16 |
+
# CORS del frontend Vite en desarrollo.
|
| 17 |
+
CORS_ORIGIN=http://localhost:5173
|
| 18 |
+
|
| 19 |
+
# Logger pino (trace | debug | info | warn | error | fatal).
|
| 20 |
+
LOG_LEVEL=info
|
backend/docs/AUTH.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auth — Guía de uso
|
| 2 |
+
|
| 3 |
+
Login con email + password. El backend emite un JWT (HS256, default 1h) que viaja en `Authorization: Bearer <token>`. No hay registro público en esta fase: los usuarios se crean vía el seeder. **No hay roles** — `requireAuth` solo valida que el usuario esté logueado y activo, para que pueda mantener sus preferencias.
|
| 4 |
+
|
| 5 |
+
## 1. Variables de entorno
|
| 6 |
+
|
| 7 |
+
Copiar `backend/.env.example` a `backend/.env` y rellenar:
|
| 8 |
+
|
| 9 |
+
| Variable | Default | Notas |
|
| 10 |
+
|---|---|---|
|
| 11 |
+
| `NODE_ENV` | `development` | `development` \| `test` \| `production` |
|
| 12 |
+
| `PORT` | `7860` | Puerto del backend (HF Spaces requiere 7860) |
|
| 13 |
+
| `DATABASE_URL` | `file:./polysignal.db` | Path SQLite **relativo a `prisma/schema.prisma`** |
|
| 14 |
+
| `JWT_SECRET` | — (obligatorio) | Mínimo 32 chars. Generar con `openssl rand -hex 48` |
|
| 15 |
+
| `JWT_EXPIRES_IN` | `1h` | Formato `jsonwebtoken` (`1h`, `15m`, `7d`, ...) |
|
| 16 |
+
| `BCRYPT_ROUNDS` | `10` | Entre 4 y 15 |
|
| 17 |
+
| `CORS_ORIGIN` | `http://localhost:5173` | Origen del frontend en dev |
|
| 18 |
+
| `LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
|
| 19 |
+
|
| 20 |
+
> Si el `.env` falta una variable obligatoria o un valor es inválido, el backend imprime el error y aborta el arranque (validación con Zod en `src/config.js`).
|
| 21 |
+
|
| 22 |
+
## 2. Primer arranque
|
| 23 |
+
|
| 24 |
+
Desde `backend/`:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
npm install # instala deps
|
| 28 |
+
npm run db:migrate -- --name init_auth # solo la primera vez (ya hecho)
|
| 29 |
+
npm run db:seed # crea los 2 usuarios de prueba
|
| 30 |
+
npm run dev # arranca en http://localhost:7860
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Para inspeccionar la DB en una UI:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
npm run db:studio
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## 3. Usuarios de prueba
|
| 40 |
+
|
| 41 |
+
Sembrados por `prisma/seed.js` (idempotente, se puede re-ejecutar):
|
| 42 |
+
|
| 43 |
+
| Email | Password |
|
| 44 |
+
|---|---|
|
| 45 |
+
| `admin@polysignal.test` | `Admin123!` |
|
| 46 |
+
| `user@polysignal.test` | `User123!` |
|
| 47 |
+
|
| 48 |
+
## 4. Endpoints
|
| 49 |
+
|
| 50 |
+
### `GET /api/v1/health`
|
| 51 |
+
|
| 52 |
+
Sanity check. Respuesta:
|
| 53 |
+
|
| 54 |
+
```json
|
| 55 |
+
{ "ok": true, "data": { "status": "up" } }
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### `POST /api/v1/auth/login`
|
| 59 |
+
|
| 60 |
+
Body:
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{ "email": "admin@polysignal.test", "password": "Admin123!" }
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Respuesta `200`:
|
| 67 |
+
|
| 68 |
+
```json
|
| 69 |
+
{
|
| 70 |
+
"ok": true,
|
| 71 |
+
"data": {
|
| 72 |
+
"token": "eyJhbGciOiJIUzI1NiJ9...",
|
| 73 |
+
"user": { "id": 1, "email": "admin@polysignal.test" }
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Errores:
|
| 79 |
+
|
| 80 |
+
| HTTP | code | cuándo |
|
| 81 |
+
|---|---|---|
|
| 82 |
+
| `400` | `VALIDATION_ERROR` | Email inválido o password < 8 chars |
|
| 83 |
+
| `401` | `INVALID_CREDENTIALS` | Email no existe, usuario desactivado, o password incorrecta |
|
| 84 |
+
| `429` | `TOO_MANY_REQUESTS` | Más de 5 intentos en 15 min desde la misma IP |
|
| 85 |
+
|
| 86 |
+
### `GET /api/v1/auth/me`
|
| 87 |
+
|
| 88 |
+
Requiere header `Authorization: Bearer <token>`. Respuesta `200`:
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{
|
| 92 |
+
"ok": true,
|
| 93 |
+
"data": {
|
| 94 |
+
"user": {
|
| 95 |
+
"id": 1,
|
| 96 |
+
"email": "admin@polysignal.test",
|
| 97 |
+
"isActive": true,
|
| 98 |
+
"createdAt": "2026-05-16T07:11:43.000Z"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Errores:
|
| 105 |
+
|
| 106 |
+
| HTTP | code | cuándo |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
|
| 109 |
+
|
| 110 |
+
## 5. Ejemplos con `curl`
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
# 1) Login y guardar token en variable
|
| 114 |
+
TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
|
| 115 |
+
-H 'Content-Type: application/json' \
|
| 116 |
+
-d '{"email":"admin@polysignal.test","password":"Admin123!"}' \
|
| 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' },
|
| 132 |
+
body: JSON.stringify({ email, password }),
|
| 133 |
+
});
|
| 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.
|
| 144 |
+
|
| 145 |
+
## 7. Cómo proteger nuevos endpoints
|
| 146 |
+
|
| 147 |
+
```js
|
| 148 |
+
import { requireAuth } from '../middlewares/requireAuth.js';
|
| 149 |
+
|
| 150 |
+
router.get('/positions', requireAuth, controller.list);
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
Dentro del controller, `req.user` ya está disponible con `{ id, email, isActive, createdAt }` — suficiente para filtrar datos del usuario logueado (ej. sus preferencias, posiciones, watchlist).
|
| 154 |
+
|
| 155 |
+
## 8. Rotar `JWT_SECRET`
|
| 156 |
+
|
| 157 |
+
Genera uno nuevo y reemplaza el valor de `JWT_SECRET` en `backend/.env`:
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
openssl rand -hex 48
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
Al cambiar el secreto, todos los tokens emitidos previamente quedan invalidados (los clientes deben volver a hacer login).
|
| 164 |
+
|
| 165 |
+
## 9. Notas técnicas
|
| 166 |
+
|
| 167 |
+
- **Hashing:** `bcryptjs` (puro JS, sin compilación nativa — más portable a HF Spaces) con coste `BCRYPT_ROUNDS` (default 10).
|
| 168 |
+
- **JWT:** algoritmo `HS256`, claim `sub = user.id`, `email`, `iat`, `exp`.
|
| 169 |
+
- **Rate limit en `/auth/login`:** `express-rate-limit`, 5 intentos / 15 min / IP.
|
| 170 |
+
- **Validación de body:** zod schema en `src/auth/auth.validators.js`.
|
| 171 |
+
- **Errores formateados:** todas las respuestas siguen `{ ok, data }` o `{ ok:false, error:{ code, message, details? } }` vía `src/utils/apiResponse.js` y el middleware `errorHandler`.
|
| 172 |
+
- **Prisma:** singleton en `src/utils/prisma.js` para no abrir conexiones de más con `node --watch`.
|
backend/package.json
CHANGED
|
@@ -2,23 +2,34 @@
|
|
| 2 |
"name": "polysignal-backend",
|
| 3 |
"version": "1.0.0",
|
| 4 |
"description": "Backend API de PolySignal — Hackathon CIFO Barcelona La Violeta",
|
|
|
|
| 5 |
"main": "src/index.js",
|
| 6 |
"prisma": {
|
| 7 |
-
"schema": "prisma/schema.prisma"
|
|
|
|
| 8 |
},
|
| 9 |
"scripts": {
|
| 10 |
"start": "node src/index.js",
|
| 11 |
"dev": "node --watch src/index.js",
|
| 12 |
"db:migrate": "prisma migrate dev",
|
| 13 |
"db:generate": "prisma generate",
|
|
|
|
| 14 |
"db:studio": "prisma studio"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"express": "^5.2.1",
|
| 18 |
-
"
|
|
|
|
|
|
|
| 19 |
"node-cron": "^4.2.1",
|
| 20 |
-
"
|
| 21 |
-
"prisma": "^6.19.2"
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
"engines": {
|
| 24 |
"node": ">=24.0.0"
|
|
|
|
| 2 |
"name": "polysignal-backend",
|
| 3 |
"version": "1.0.0",
|
| 4 |
"description": "Backend API de PolySignal — Hackathon CIFO Barcelona La Violeta",
|
| 5 |
+
"type": "module",
|
| 6 |
"main": "src/index.js",
|
| 7 |
"prisma": {
|
| 8 |
+
"schema": "prisma/schema.prisma",
|
| 9 |
+
"seed": "node prisma/seed.js"
|
| 10 |
},
|
| 11 |
"scripts": {
|
| 12 |
"start": "node src/index.js",
|
| 13 |
"dev": "node --watch src/index.js",
|
| 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": {
|
| 20 |
+
"@prisma/client": "^6.19.2",
|
| 21 |
+
"bcryptjs": "^2.4.3",
|
| 22 |
+
"cors": "^2.8.5",
|
| 23 |
+
"dotenv": "^16.4.5",
|
| 24 |
"express": "^5.2.1",
|
| 25 |
+
"express-rate-limit": "^7.4.0",
|
| 26 |
+
"helmet": "^8.0.0",
|
| 27 |
+
"jsonwebtoken": "^9.0.2",
|
| 28 |
"node-cron": "^4.2.1",
|
| 29 |
+
"pino": "^9.5.0",
|
| 30 |
+
"prisma": "^6.19.2",
|
| 31 |
+
"socket.io": "^4.8.3",
|
| 32 |
+
"zod": "^3.23.8"
|
| 33 |
},
|
| 34 |
"engines": {
|
| 35 |
"node": ">=24.0.0"
|
backend/prisma/migrations/20260516071158_init_auth/migration.sql
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- CreateTable
|
| 2 |
+
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,
|
| 10 |
+
"updatedAt" DATETIME NOT NULL
|
| 11 |
+
);
|
| 12 |
+
|
| 13 |
+
-- CreateTable
|
| 14 |
+
CREATE TABLE "Market" (
|
| 15 |
+
"id" TEXT NOT NULL PRIMARY KEY,
|
| 16 |
+
"question" TEXT NOT NULL,
|
| 17 |
+
"category" TEXT,
|
| 18 |
+
"countryCode" TEXT,
|
| 19 |
+
"yesPrice" REAL,
|
| 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
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
-- CreateTable
|
| 29 |
+
CREATE TABLE "AISignal" (
|
| 30 |
+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
| 31 |
+
"marketId" TEXT NOT NULL,
|
| 32 |
+
"signal" TEXT NOT NULL,
|
| 33 |
+
"confidence" REAL NOT NULL,
|
| 34 |
+
"summary" TEXT,
|
| 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 |
+
);
|
| 41 |
+
|
| 42 |
+
-- CreateTable
|
| 43 |
+
CREATE TABLE "Position" (
|
| 44 |
+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
| 45 |
+
"userId" INTEGER NOT NULL,
|
| 46 |
+
"marketId" TEXT NOT NULL,
|
| 47 |
+
"outcome" TEXT NOT NULL,
|
| 48 |
+
"amountEur" REAL NOT NULL,
|
| 49 |
+
"entryPrice" REAL NOT NULL,
|
| 50 |
+
"currentPrice" REAL,
|
| 51 |
+
"pnl" REAL NOT NULL DEFAULT 0,
|
| 52 |
+
"kellyFraction" REAL,
|
| 53 |
+
"status" TEXT NOT NULL DEFAULT 'open',
|
| 54 |
+
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 55 |
+
"closedAt" DATETIME,
|
| 56 |
+
CONSTRAINT "Position_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
| 57 |
+
CONSTRAINT "Position_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
| 58 |
+
);
|
| 59 |
+
|
| 60 |
+
-- CreateTable
|
| 61 |
+
CREATE TABLE "Watchlist" (
|
| 62 |
+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
| 63 |
+
"userId" INTEGER NOT NULL,
|
| 64 |
+
"marketId" TEXT NOT NULL,
|
| 65 |
+
"alertThreshold" REAL,
|
| 66 |
+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 67 |
+
CONSTRAINT "Watchlist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
| 68 |
+
CONSTRAINT "Watchlist_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
-- CreateTable
|
| 72 |
+
CREATE TABLE "Alert" (
|
| 73 |
+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
| 74 |
+
"userId" INTEGER NOT NULL,
|
| 75 |
+
"marketId" TEXT NOT NULL,
|
| 76 |
+
"type" TEXT NOT NULL,
|
| 77 |
+
"message" TEXT NOT NULL,
|
| 78 |
+
"sentAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 79 |
+
CONSTRAINT "Alert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
| 80 |
+
CONSTRAINT "Alert_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
-- CreateIndex
|
| 84 |
+
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
| 85 |
+
|
| 86 |
+
-- CreateIndex
|
| 87 |
+
CREATE INDEX "AISignal_marketId_generatedAt_idx" ON "AISignal"("marketId", "generatedAt");
|
| 88 |
+
|
| 89 |
+
-- CreateIndex
|
| 90 |
+
CREATE INDEX "Position_userId_status_idx" ON "Position"("userId", "status");
|
| 91 |
+
|
| 92 |
+
-- CreateIndex
|
| 93 |
+
CREATE INDEX "Position_marketId_idx" ON "Position"("marketId");
|
| 94 |
+
|
| 95 |
+
-- CreateIndex
|
| 96 |
+
CREATE INDEX "Watchlist_userId_idx" ON "Watchlist"("userId");
|
| 97 |
+
|
| 98 |
+
-- CreateIndex
|
| 99 |
+
CREATE UNIQUE INDEX "Watchlist_userId_marketId_key" ON "Watchlist"("userId", "marketId");
|
| 100 |
+
|
| 101 |
+
-- CreateIndex
|
| 102 |
+
CREATE INDEX "Alert_userId_sentAt_idx" ON "Alert"("userId", "sentAt");
|
| 103 |
+
|
| 104 |
+
-- CreateIndex
|
| 105 |
+
CREATE INDEX "Alert_marketId_idx" ON "Alert"("marketId");
|
backend/prisma/migrations/20260516072006_remove_user_role/migration.sql
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Warnings:
|
| 3 |
+
|
| 4 |
+
- You are about to drop the column `role` on the `User` table. All the data in the column will be lost.
|
| 5 |
+
|
| 6 |
+
*/
|
| 7 |
+
-- RedefineTables
|
| 8 |
+
PRAGMA defer_foreign_keys=ON;
|
| 9 |
+
PRAGMA foreign_keys=OFF;
|
| 10 |
+
CREATE TABLE "new_User" (
|
| 11 |
+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
| 12 |
+
"email" TEXT NOT NULL,
|
| 13 |
+
"passwordHash" TEXT NOT NULL,
|
| 14 |
+
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
| 15 |
+
"telegramChatId" TEXT,
|
| 16 |
+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 17 |
+
"updatedAt" DATETIME NOT NULL
|
| 18 |
+
);
|
| 19 |
+
INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "passwordHash", "telegramChatId", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "passwordHash", "telegramChatId", "updatedAt" FROM "User";
|
| 20 |
+
DROP TABLE "User";
|
| 21 |
+
ALTER TABLE "new_User" RENAME TO "User";
|
| 22 |
+
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
| 23 |
+
PRAGMA foreign_keys=ON;
|
| 24 |
+
PRAGMA defer_foreign_keys=OFF;
|
backend/prisma/migrations/migration_lock.toml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Please do not edit this file manually
|
| 2 |
+
# It should be added in your version-control system (e.g., Git)
|
| 3 |
+
provider = "sqlite"
|
backend/prisma/schema.prisma
CHANGED
|
@@ -22,8 +22,12 @@ datasource db {
|
|
| 22 |
|
| 23 |
model User {
|
| 24 |
id Int @id @default(autoincrement())
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
createdAt DateTime @default(now())
|
|
|
|
| 27 |
|
| 28 |
positions Position[]
|
| 29 |
watchlist Watchlist[]
|
|
|
|
| 22 |
|
| 23 |
model User {
|
| 24 |
id Int @id @default(autoincrement())
|
| 25 |
+
email String @unique
|
| 26 |
+
passwordHash String
|
| 27 |
+
isActive Boolean @default(true)
|
| 28 |
+
telegramChatId String? // Configurado manualmente para demo
|
| 29 |
createdAt DateTime @default(now())
|
| 30 |
+
updatedAt DateTime @updatedAt
|
| 31 |
|
| 32 |
positions Position[]
|
| 33 |
watchlist Watchlist[]
|
backend/prisma/seed.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt from 'bcryptjs';
|
| 2 |
+
import { PrismaClient } from '@prisma/client';
|
| 3 |
+
|
| 4 |
+
const prisma = new PrismaClient();
|
| 5 |
+
|
| 6 |
+
const ROUNDS = Number(process.env.BCRYPT_ROUNDS ?? 10);
|
| 7 |
+
|
| 8 |
+
const users = [
|
| 9 |
+
{ email: 'admin@polysignal.test', password: 'Admin123!' },
|
| 10 |
+
{ email: 'user@polysignal.test', password: 'User123!' },
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
const run = async () => {
|
| 14 |
+
for (const u of users) {
|
| 15 |
+
const passwordHash = await bcrypt.hash(u.password, ROUNDS);
|
| 16 |
+
const record = await prisma.user.upsert({
|
| 17 |
+
where: { email: u.email },
|
| 18 |
+
update: { passwordHash, isActive: true },
|
| 19 |
+
create: { email: u.email, passwordHash, isActive: true },
|
| 20 |
+
});
|
| 21 |
+
console.log(`seeded user ${record.email} (id=${record.id})`);
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
run()
|
| 26 |
+
.catch((err) => {
|
| 27 |
+
console.error(err);
|
| 28 |
+
process.exit(1);
|
| 29 |
+
})
|
| 30 |
+
.finally(async () => {
|
| 31 |
+
await prisma.$disconnect();
|
| 32 |
+
});
|
backend/src/app.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express from 'express';
|
| 2 |
+
import cors from 'cors';
|
| 3 |
+
import helmet from 'helmet';
|
| 4 |
+
import { config } from './config.js';
|
| 5 |
+
import { ok } from './utils/apiResponse.js';
|
| 6 |
+
import authRoutes from './auth/auth.routes.js';
|
| 7 |
+
import { notFound } from './middlewares/notFound.js';
|
| 8 |
+
import { errorHandler } from './middlewares/errorHandler.js';
|
| 9 |
+
|
| 10 |
+
const app = express();
|
| 11 |
+
|
| 12 |
+
app.use(helmet());
|
| 13 |
+
app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
|
| 14 |
+
app.use(express.json({ limit: '1mb' }));
|
| 15 |
+
|
| 16 |
+
app.get('/api/v1/health', (_req, res) => ok(res, { status: 'up' }));
|
| 17 |
+
app.use('/api/v1/auth', authRoutes);
|
| 18 |
+
|
| 19 |
+
app.use(notFound);
|
| 20 |
+
app.use(errorHandler);
|
| 21 |
+
|
| 22 |
+
export default app;
|
backend/src/auth/auth.controller.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as authService from './auth.service.js';
|
| 2 |
+
import { ok } from '../utils/apiResponse.js';
|
| 3 |
+
|
| 4 |
+
export const login = async (req, res) => {
|
| 5 |
+
const data = await authService.login(req.body);
|
| 6 |
+
ok(res, data);
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
export const me = async (req, res) => {
|
| 10 |
+
ok(res, { user: req.user });
|
| 11 |
+
};
|
backend/src/auth/auth.routes.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router } from 'express';
|
| 2 |
+
import * as ctrl from './auth.controller.js';
|
| 3 |
+
import { loginSchema } from './auth.validators.js';
|
| 4 |
+
import { validate } from '../middlewares/validate.js';
|
| 5 |
+
import { requireAuth } from '../middlewares/requireAuth.js';
|
| 6 |
+
import { rateLimitLogin } from '../middlewares/rateLimitLogin.js';
|
| 7 |
+
|
| 8 |
+
const router = Router();
|
| 9 |
+
|
| 10 |
+
router.post('/login', rateLimitLogin, validate(loginSchema), ctrl.login);
|
| 11 |
+
router.get('/me', requireAuth, ctrl.me);
|
| 12 |
+
|
| 13 |
+
export default router;
|
backend/src/auth/auth.service.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt from 'bcryptjs';
|
| 2 |
+
import { prisma } from '../utils/prisma.js';
|
| 3 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 4 |
+
import { signToken } from './jwt.js';
|
| 5 |
+
|
| 6 |
+
const INVALID_CREDENTIALS = new HttpError(401, 'INVALID_CREDENTIALS', 'Email or password is incorrect');
|
| 7 |
+
|
| 8 |
+
export const login = async ({ email, password }) => {
|
| 9 |
+
const user = await prisma.user.findUnique({ where: { email } });
|
| 10 |
+
if (!user || !user.isActive) throw INVALID_CREDENTIALS;
|
| 11 |
+
|
| 12 |
+
const passwordOk = await bcrypt.compare(password, user.passwordHash);
|
| 13 |
+
if (!passwordOk) throw INVALID_CREDENTIALS;
|
| 14 |
+
|
| 15 |
+
const token = signToken({ sub: user.id, email: user.email });
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
token,
|
| 19 |
+
user: { id: user.id, email: user.email },
|
| 20 |
+
};
|
| 21 |
+
};
|
backend/src/auth/auth.validators.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod';
|
| 2 |
+
|
| 3 |
+
export const loginSchema = z.object({
|
| 4 |
+
email: z.string().email('Invalid email').toLowerCase().trim(),
|
| 5 |
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
| 6 |
+
});
|
backend/src/auth/jwt.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import jwt from 'jsonwebtoken';
|
| 2 |
+
import { config } from '../config.js';
|
| 3 |
+
|
| 4 |
+
export const signToken = (payload) =>
|
| 5 |
+
jwt.sign(payload, config.JWT_SECRET, {
|
| 6 |
+
algorithm: 'HS256',
|
| 7 |
+
expiresIn: config.JWT_EXPIRES_IN,
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
export const verifyToken = (token) =>
|
| 11 |
+
jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'] });
|
backend/src/config.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
| 1 |
-
/
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dotenv/config';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
|
| 4 |
+
const schema = z.object({
|
| 5 |
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
| 6 |
+
PORT: z.coerce.number().int().positive().default(7860),
|
| 7 |
+
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
| 8 |
+
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
| 9 |
+
JWT_EXPIRES_IN: z.string().default('1h'),
|
| 10 |
+
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
| 11 |
+
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
| 12 |
+
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
const parsed = schema.safeParse(process.env);
|
| 16 |
+
|
| 17 |
+
if (!parsed.success) {
|
| 18 |
+
console.error('Invalid environment variables:');
|
| 19 |
+
console.error(parsed.error.flatten().fieldErrors);
|
| 20 |
+
process.exit(1);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export const config = Object.freeze(parsed.data);
|
backend/src/index.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import http from 'node:http';
|
| 2 |
+
import app from './app.js';
|
| 3 |
+
import { config } from './config.js';
|
| 4 |
+
import { logger } from './utils/logger.js';
|
| 5 |
+
import { prisma } from './utils/prisma.js';
|
| 6 |
+
|
| 7 |
+
const httpServer = http.createServer(app);
|
| 8 |
+
|
| 9 |
+
httpServer.listen(config.PORT, () => {
|
| 10 |
+
logger.info({ port: config.PORT, env: config.NODE_ENV }, 'PolySignal backend up');
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
| 14 |
+
process.on(sig, async () => {
|
| 15 |
+
logger.info({ sig }, 'shutting down');
|
| 16 |
+
httpServer.close();
|
| 17 |
+
await prisma.$disconnect();
|
| 18 |
+
process.exit(0);
|
| 19 |
+
});
|
| 20 |
+
}
|
backend/src/middlewares/errorHandler.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 2 |
+
import { logger } from '../utils/logger.js';
|
| 3 |
+
|
| 4 |
+
export const errorHandler = (err, req, res, _next) => {
|
| 5 |
+
if (err instanceof HttpError) {
|
| 6 |
+
return res.status(err.status).json({
|
| 7 |
+
ok: false,
|
| 8 |
+
error: { code: err.code, message: err.message, details: err.details },
|
| 9 |
+
});
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
logger.error({ err, path: req.path, method: req.method }, 'unhandled error');
|
| 13 |
+
|
| 14 |
+
return res.status(500).json({
|
| 15 |
+
ok: false,
|
| 16 |
+
error: { code: 'INTERNAL', message: 'Internal server error' },
|
| 17 |
+
});
|
| 18 |
+
};
|
backend/src/middlewares/notFound.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const notFound = (req, res) =>
|
| 2 |
+
res.status(404).json({
|
| 3 |
+
ok: false,
|
| 4 |
+
error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` },
|
| 5 |
+
});
|
backend/src/middlewares/rateLimitLogin.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import rateLimit from 'express-rate-limit';
|
| 2 |
+
|
| 3 |
+
export const rateLimitLogin = rateLimit({
|
| 4 |
+
windowMs: 15 * 60 * 1000,
|
| 5 |
+
max: 5,
|
| 6 |
+
standardHeaders: true,
|
| 7 |
+
legacyHeaders: false,
|
| 8 |
+
message: {
|
| 9 |
+
ok: false,
|
| 10 |
+
error: { code: 'TOO_MANY_REQUESTS', message: 'Too many login attempts, try again later' },
|
| 11 |
+
},
|
| 12 |
+
});
|
backend/src/middlewares/requireAuth.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { verifyToken } from '../auth/jwt.js';
|
| 2 |
+
import { prisma } from '../utils/prisma.js';
|
| 3 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 4 |
+
|
| 5 |
+
const UNAUTHORIZED = new HttpError(401, 'UNAUTHORIZED', 'Authentication required');
|
| 6 |
+
|
| 7 |
+
export const requireAuth = async (req, _res, next) => {
|
| 8 |
+
try {
|
| 9 |
+
const header = req.headers.authorization;
|
| 10 |
+
if (!header || !header.startsWith('Bearer ')) throw UNAUTHORIZED;
|
| 11 |
+
|
| 12 |
+
const token = header.slice('Bearer '.length).trim();
|
| 13 |
+
if (!token) throw UNAUTHORIZED;
|
| 14 |
+
|
| 15 |
+
const payload = verifyToken(token);
|
| 16 |
+
const user = await prisma.user.findUnique({
|
| 17 |
+
where: { id: payload.sub },
|
| 18 |
+
select: { id: true, email: true, isActive: true, createdAt: true },
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
if (!user || !user.isActive) throw UNAUTHORIZED;
|
| 22 |
+
|
| 23 |
+
req.user = user;
|
| 24 |
+
next();
|
| 25 |
+
} catch (err) {
|
| 26 |
+
if (err instanceof HttpError) return next(err);
|
| 27 |
+
next(UNAUTHORIZED);
|
| 28 |
+
}
|
| 29 |
+
};
|
backend/src/middlewares/validate.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 2 |
+
|
| 3 |
+
export const validate = (schema, source = 'body') => (req, _res, next) => {
|
| 4 |
+
const result = schema.safeParse(req[source]);
|
| 5 |
+
if (!result.success) {
|
| 6 |
+
return next(
|
| 7 |
+
new HttpError(400, 'VALIDATION_ERROR', 'Invalid request', result.error.flatten().fieldErrors),
|
| 8 |
+
);
|
| 9 |
+
}
|
| 10 |
+
req[source] = result.data;
|
| 11 |
+
next();
|
| 12 |
+
};
|
backend/src/utils/apiResponse.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const ok = (res, data, meta) =>
|
| 2 |
+
res.status(200).json(meta ? { ok: true, data, meta } : { ok: true, data });
|
| 3 |
+
|
| 4 |
+
export const created = (res, data) => res.status(201).json({ ok: true, data });
|
| 5 |
+
|
| 6 |
+
export const noContent = (res) => res.status(204).end();
|
| 7 |
+
|
| 8 |
+
export class HttpError extends Error {
|
| 9 |
+
constructor(status, code, message, details) {
|
| 10 |
+
super(message);
|
| 11 |
+
this.status = status;
|
| 12 |
+
this.code = code;
|
| 13 |
+
this.details = details;
|
| 14 |
+
}
|
| 15 |
+
}
|
backend/src/utils/logger.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pino from 'pino';
|
| 2 |
+
|
| 3 |
+
export const logger = pino({
|
| 4 |
+
level: process.env.LOG_LEVEL ?? 'info',
|
| 5 |
+
});
|
backend/src/utils/prisma.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PrismaClient } from '@prisma/client';
|
| 2 |
+
|
| 3 |
+
const globalForPrisma = globalThis;
|
| 4 |
+
|
| 5 |
+
export const prisma =
|
| 6 |
+
globalForPrisma.__prisma__ ??
|
| 7 |
+
new PrismaClient({
|
| 8 |
+
log: ['warn', 'error'],
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
if (process.env.NODE_ENV !== 'production') {
|
| 12 |
+
globalForPrisma.__prisma__ = prisma;
|
| 13 |
+
}
|