Initial commit

This commit is contained in:
Lorenzo Sanesi
2026-03-26 22:46:03 +01:00
commit 18e0e04700
80 changed files with 13114 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Node
node_modules
npm-debug.log
yarn-error.log
package-lock.json
yarn.lock
# Environment
.env
.env.local
.env.*.local
# Logs
logs
*.log
# Testing
coverage
.nyc_output
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Documentation
*.md
!README.md
docs/
# CI/CD
.github
.gitlab-ci.yml
.travis.yml
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.bak
*.tmp
*.temp
.cache
+108
View File
@@ -0,0 +1,108 @@
# ========================================
# ENVIRONMENT CONFIGURATION TEMPLATE
# ========================================
# Copy this file to .env and fill in your values
# DO NOT commit .env to version control
# ========================================
# Application Configuration
# ========================================
NODE_ENV=development
PORT=3000
# ========================================
# PostgreSQL Database
# ========================================
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=amici_scommesse
POSTGRES_USER=admin
POSTGRES_PASSWORD=admin
POSTGRES_URI=postgres://admin:admin@localhost:5432/amici_scommesse
# ========================================
# MongoDB Database
# ========================================
MONGO_HOST=localhost
MONGO_PORT=27017
MONGO_DB=amici-scommesse
MONGO_USER=root
MONGO_PASSWORD=root@servovio
MONGO_AUTH_SOURCE=admin
MONGO_URI=mongodb://root:root@servovio@localhost:27017/amici-scommesse?authSource=admin
# ========================================
# Logging
# ========================================
LOG_LEVEL=debug
ENABLE_SQL_LOGGING=false
# ========================================
# CORS Configuration
# ========================================
CORS_ORIGIN=http://localhost:5173
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3001
# ========================================
# Mock Authentication (Development Only)
# ========================================
MOCK_USER_NAME=Mario Rossi
# ========================================
# API Configuration
# ========================================
API_VERSION=v1
API_PREFIX=/api
# ========================================
# Database Sync (Development Only)
# ========================================
# WARNING: force: true drops all tables and recreates them
# Set to false in production and use migrations instead
DB_SYNC_FORCE=true
# ========================================
# Future: JWT Authentication (Not Yet Implemented)
# ========================================
# JWT_SECRET=your-secret-key-change-this
# JWT_EXPIRES_IN=7d
# JWT_REFRESH_EXPIRES_IN=30d
# ========================================
# Future: Rate Limiting (Not Yet Implemented)
# ========================================
# RATE_LIMIT_WINDOW_MS=900000
# RATE_LIMIT_MAX_REQUESTS=100
# ========================================
# Future: Redis (Not Yet Implemented)
# ========================================
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_DB=0
# ========================================
# Future: File Upload (Not Yet Implemented)
# ========================================
# UPLOAD_MAX_FILE_SIZE=5242880
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif
# AWS_S3_BUCKET=
# AWS_S3_REGION=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# ========================================
# Future: Email Service (Not Yet Implemented)
# ========================================
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# EMAIL_FROM=noreply@amiciscommesse.com
# ========================================
# Future: Monitoring & Analytics (Not Yet Implemented)
# ========================================
# SENTRY_DSN=
# ANALYTICS_ENABLED=false
+3
View File
@@ -0,0 +1,3 @@
node_modules/
coverage/
dist/
+21
View File
@@ -0,0 +1,21 @@
{
"env": {
"node": true,
"es2021": true
},
"extends": ["airbnb-base", "prettier"],
"plugins": ["prettier"],
"parserOptions": {
"ecmaVersion": "latest"
},
"rules": {
"prettier/prettier": "error",
"no-console": "off",
"consistent-return": "warn",
"no-underscore-dangle": ["error", { "allow": ["_id"] }],
"import/no-extraneous-dependencies": [
"error",
{ "devDependencies": true }
]
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "avoid"
}
+431
View File
@@ -0,0 +1,431 @@
# API Documentation - Amici Scommesse
## 📚 Overview
Documentazione completa dell'API Amici Scommesse v1.1.0.
**Base URL**: `http://localhost:3000/api/v1`
## 🔗 Specifica OpenAPI
La specifica OpenAPI completa è disponibile nel file `openapi.yaml`.
### Visualizzare la Specifica
**Opzione 1: Swagger UI Online**
1. Vai su https://editor.swagger.io/
2. File → Import File → Seleziona `openapi.yaml`
3. Visualizza la documentazione interattiva
**Opzione 2: Swagger UI Locale**
```bash
npx swagger-ui-watcher openapi.yaml
```
**Opzione 3: Redoc**
```bash
npx @redocly/cli preview-docs openapi.yaml
```
### Generare Client SDK
**TypeScript/JavaScript:**
```bash
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./frontend/src/api
```
**Python:**
```bash
openapi-generator-cli generate \
-i openapi.yaml \
-g python \
-o ./python-client
```
## 🌐 Endpoints API v1
### 🎯 Groups
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/groups` | Lista tutti i gruppi |
| GET | `/groups/:id` | Dettaglio gruppo |
| POST | `/groups` | Crea gruppo |
| PUT | `/groups/:id` | Aggiorna gruppo |
| DELETE | `/groups/:id` | Elimina gruppo |
| PUT | `/groups/:id/members` | Aggiungi membri |
| DELETE | `/groups/:id/members/:userId` | Rimuovi membro |
### 🎲 Bets
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/bets` | Lista tutte le scommesse |
| GET | `/bets/:id` | Dettaglio scommessa |
| POST | `/bets` | Crea scommessa |
| PUT | `/bets/:id` | Aggiorna scommessa |
| DELETE | `/bets/:id` | Elimina scommessa |
### 👥 Friend Requests
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/friend-requests` | Lista amici |
| GET | `/friend-requests/pending` | Richieste pending |
| POST | `/friend-requests` | Invia richiesta |
| POST | `/friend-requests/:id/accept` | Accetta richiesta |
| DELETE | `/friend-requests/:id` | Rifiuta richiesta |
### 👤 Users
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| PUT | `/users/current` | Aggiorna profilo |
### 📦 Initial Data
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/initial-data` | Carica dati iniziali |
### ❤️ Health
| Metodo | Endpoint | Descrizione |
|--------|----------|-------------|
| GET | `/health` | Health check database |
## 📝 Formato Risposte
### Successo (2xx)
```json
{
"success": true,
"message": "Operation successful",
"data": { ... },
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Errore (4xx, 5xx)
```json
{
"success": false,
"message": "Error message",
"errors": [
{
"field": "name",
"message": "Name is required"
}
],
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## 🔐 Autenticazione
**Attuale**: Mock authentication (Mario Rossi hardcoded)
**Prossimo**: JWT authentication da implementare
## 🧪 Esempi di Chiamate
### Creare un Gruppo
```bash
curl -X POST http://localhost:3000/api/v1/groups \
-H "Content-Type: application/json" \
-d '{
"name": "Gruppo Calcio",
"members": [1, 2, 3]
}'
```
Risposta:
```json
{
"success": true,
"message": "Group created successfully",
"data": {
"id": 1,
"name": "Gruppo Calcio",
"members": [
{
"id": 1,
"name": "Mario Rossi",
"email": "mario.rossi@example.com",
"avatarUrl": null,
"score": 0
}
]
},
"timestamp": "2024-01-01T12:00:00.000Z"
}
```
### Creare una Scommessa
```bash
curl -X POST http://localhost:3000/api/v1/bets \
-H "Content-Type: application/json" \
-d '{
"title": "Chi vince il campionato?",
"description": "Scommessa sul vincitore del campionato 2024",
"stake": "50 euro",
"participants": [
{
"user": 1,
"role": "PARTICIPANT"
},
{
"user": 2,
"role": "PARTICIPANT"
}
],
"isPublic": false,
"groupId": "1"
}'
```
### Inviare Richiesta di Amicizia
```bash
curl -X POST http://localhost:3000/api/v1/friend-requests \
-H "Content-Type: application/json" \
-d '{
"identifier": 2
}'
```
oppure con email:
```bash
curl -X POST http://localhost:3000/api/v1/friend-requests \
-H "Content-Type: application/json" \
-d '{
"identifier": "luigi.verdi@example.com"
}'
```
### Aggiornare Profilo Utente
```bash
curl -X PUT http://localhost:3000/api/v1/users/current \
-H "Content-Type: application/json" \
-d '{
"name": "Mario Rossi",
"email": "mario.rossi.new@example.com",
"avatarUrl": "https://example.com/avatar.jpg"
}'
```
### Caricare Dati Iniziali
```bash
curl http://localhost:3000/api/v1/initial-data
```
### Health Check
```bash
curl http://localhost:3000/health
```
Risposta:
```json
{
"status": "healthy",
"timestamp": "2024-01-01T12:00:00.000Z",
"services": {
"postgres": "connected",
"mongodb": "connected"
}
}
```
## 🔄 Migrazione da API Legacy
### API Legacy (Backward Compatibility)
Le vecchie route sono ancora disponibili su `/api/legacy/*` per compatibilità:
```
/api/legacy/groups → legacy format
/api/legacy/bets → legacy format
/api/legacy/friend-requests → legacy format
```
### Differenze tra v1 e Legacy
| Aspetto | Legacy | v1 |
|---------|--------|-----|
| Formato risposta | Varia | Standardizzato |
| Validazione | Inline | Joi schema |
| Error handling | Inconsistente | Centralizzato |
| Success status | Varia | Sempre con wrapper |
| Timestamp | No | Sì |
### Piano di Migrazione Frontend
1. **Fase 1**: Testare endpoint v1 in sviluppo
2. **Fase 2**: Migrare chiamate API da `/api/*` a `/api/v1/*`
3. **Fase 3**: Aggiornare gestione risposte (nuovo formato)
4. **Fase 4**: Gestire nuovi errori standardizzati
5. **Fase 5**: Deprecare chiamate legacy
## 📊 Codici di Stato HTTP
| Codice | Significato | Quando |
|--------|-------------|--------|
| 200 | OK | Operazione riuscita |
| 201 | Created | Risorsa creata |
| 400 | Bad Request | Validation error |
| 404 | Not Found | Risorsa non trovata |
| 409 | Conflict | Conflitto (es: già amici) |
| 500 | Internal Error | Errore server |
| 503 | Service Unavailable | Database non disponibile |
## 🛠️ Testing con Postman
### Importare Specifica OpenAPI
1. Apri Postman
2. Import → Link → Incolla URL o File
3. Seleziona `openapi.yaml`
4. Postman genera automaticamente la collection
### Environment Variables
Crea un environment Postman con:
```json
{
"baseUrl": "http://localhost:3000/api/v1",
"legacyUrl": "http://localhost:3000/api/legacy"
}
```
## 🚀 Integrare con Frontend
### Axios Example (TypeScript)
```typescript
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api/v1',
headers: {
'Content-Type': 'application/json',
},
});
// Response Interceptor per formato standardizzato
api.interceptors.response.use(
(response) => response.data, // Ritorna solo { success, message, data, timestamp }
(error) => {
if (error.response) {
// Server ha risposto con errore
return Promise.reject(error.response.data);
}
return Promise.reject(error);
}
);
// Esempio uso
async function createGroup(name: string, members: number[]) {
try {
const response = await api.post('/groups', { name, members });
console.log('Success:', response.data);
return response.data;
} catch (error) {
console.error('Error:', error.message, error.errors);
throw error;
}
}
```
### Fetch Example (JavaScript)
```javascript
async function createGroup(name, members) {
const response = await fetch('http://localhost:3000/api/v1/groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, members }),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message);
}
return result.data;
}
```
### React Hook Example
```typescript
import { useState, useEffect } from 'react';
import api from './api';
function useGroups() {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchGroups() {
setLoading(true);
try {
const response = await api.get('/groups');
setGroups(response.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchGroups();
}, []);
return { groups, loading, error };
}
```
## 🐛 Troubleshooting
### Errore 404 su endpoint v1
Verifica che il server sia avviato e che stai usando `/api/v1/` e non `/api/`.
### Validation errors
Controlla la specifica OpenAPI per i campi required e i formati accettati.
### CORS errors
Il server ha CORS abilitato. Se riscontri problemi, verifica la configurazione del frontend.
### Health check fallisce
Verifica che PostgreSQL e MongoDB siano in esecuzione e accessibili.
## 📞 Supporto
Per problemi o domande:
- Consulta la specifica OpenAPI completa in `openapi.yaml`
- Vedi esempi in `IMPLEMENTATION_GUIDE.md`
- Controlla il CHANGELOG.md per ultime modifiche
## 🔗 Link Utili
- [Swagger Editor](https://editor.swagger.io/)
- [OpenAPI Generator](https://openapi-generator.tech/)
- [Postman](https://www.postman.com/)
- [Redoc](https://redocly.com/redoc/)
+417
View File
@@ -0,0 +1,417 @@
# Changelog - Backend Node.js
Tracciamento modifiche specifiche del backend.
---
## ✅ Completato
### Setup Iniziale
- [x] Inizializzazione progetto Node.js con npm
- [x] Installazione dipendenze: Express, Sequelize, Mongoose, CORS, pg
- [x] Configurazione server Express (server.js)
- [x] Setup doppia connessione database (PostgreSQL + MongoDB)
### Database PostgreSQL
#### Modelli Sequelize
- [x] **User Model** (`models/user.model.js`)
- Campi: id, name, email, avatarUrl, score
- Validazioni email unique
- Relazioni: belongsToMany Groups, hasMany FriendRequests
- [x] **Group Model** (`models/group.model.js`)
- Campi: id, name
- Relazioni: belongsToMany Users (many-to-many)
- [x] **Friendship Model** (`models/friendship.model.js`)
- Campi: id, user1Id, user2Id
- Vincolo: user1Id < user2Id (evita duplicati)
- Hook beforeValidate per garantire ordine corretto
- [x] **FriendRequest Model** (`models/friendRequest.model.js`)
- Campi: id, fromUserId, toUserId, status, createdAt, updatedAt
- Stati: PENDING, ACCEPTED, REJECTED
- Timestamps automatici
- [x] **Associations** (`models/associations.js`)
- User ↔ Group (many-to-many via UserGroups)
- User → FriendRequest (fromUser, toUser)
- Friendship → User (user1, user2)
#### Schema SQL
- [x] Script di inizializzazione (`init.sql`)
- [x] Definizione tabelle con constraint e foreign keys
- [x] Indici per performance su ricerche frequenti
- [x] Check constraints per data integrity
### Database MongoDB
- [x] **Bet Model** (`models/bet.model.js`)
- Schema: title, description, stake, participants[], status, startDate, endDate, isPublic, groupId, winner
- Partecipanti con ruolo (PARTICIPANT/OBSERVER)
- Stati: ACTIVE, AWAITING_VALIDATION, COMPLETED
- Timestamp automatici (createdAt, updatedAt)
### API Routes
#### Initial Data Route (`routes/initial-data.routes.js`)
- [x] `GET /api/initial-data` - Carica dati iniziali per l'app
- Utente corrente (hardcoded userId=1 per ora)
- Lista amici con info complete
- Gruppi dell'utente con membri
- Scommesse dell'utente (tutte, attive, passate)
#### Users Routes (`routes/users.routes.js`)
- [x] `GET /api/users` - Lista tutti gli utenti
- [x] `GET /api/users/:id` - Dettaglio utente singolo
#### Groups Routes (`routes/groups.routes.js`)
- [x] `GET /api/groups` - Lista tutti i gruppi
- [x] `GET /api/groups/:id` - Dettaglio gruppo con membri
- [x] `POST /api/groups` - Crea nuovo gruppo
- [x] `PUT /api/groups/:id` - Aggiorna gruppo
- [x] `DELETE /api/groups/:id` - Elimina gruppo
- [x] `POST /api/groups/:id/members` - Aggiungi membro al gruppo
- [x] `DELETE /api/groups/:id/members/:userId` - Rimuovi membro
#### Bets Routes (`routes/bets.routes.js`)
- [x] `GET /api/bets` - Lista tutte le scommesse
- [x] `GET /api/bets/:id` - Dettaglio scommessa
- [x] `POST /api/bets` - Crea nuova scommessa
- [x] `PUT /api/bets/:id` - Aggiorna scommessa
- [x] `DELETE /api/bets/:id` - Elimina scommessa
- [x] `GET /api/bets/by-time` - Filtra scommesse per periodo (today, week, month)
- [x] `GET /api/bets/by-group/:groupId` - Scommesse per gruppo specifico
#### Friends Routes (`routes/friends.routes.js`)
- [x] `GET /api/friends/:userId` - Lista amici di un utente
- [x] `GET /api/friends/requests/:userId` - Richieste di amicizia pending
- [x] `POST /api/friends/requests` - Invia richiesta di amicizia
- [x] `PATCH /api/friends/requests/:requestId` - Accetta/rifiuta richiesta
#### API Router (`routes/api.js`)
- [x] Router factory che accetta istanza Sequelize
- [x] Composizione di tutti i sub-router
### Utilities
- [x] **Seed Script** (`seed.js`)
- Popola PostgreSQL con 6 utenti di test
- Crea 3 amicizie e 2 richieste pending
- Crea 2 gruppi con membri
- Popola MongoDB con 5 scommesse di esempio
- Skip automatico se database già popolato
- [x] **Populate Bets** (`utils/populateBets.js`)
- Utility per popolare scommesse (se necessario)
### Configurazione
- [x] Sync automatico con `{ force: true }` in development
- [x] Seed automatico al primo avvio
- [x] CORS abilitato per frontend
- [x] JSON body parser
- [x] Gestione errori connessione database
---
## 📋 Da Fare
### Autenticazione & Autorizzazione
- [ ] Implementare JWT authentication
- [ ] Middleware di protezione route
- [ ] Login/Register endpoints
- [ ] Password hashing (bcrypt)
- [ ] Refresh token mechanism
- [ ] OAuth2 integration (Google, Facebook)
- [ ] Gestione sessioni
### Validazione & Sicurezza
- [ ] Validation middleware con Joi/express-validator
- [ ] Sanitizzazione input utente
- [ ] Rate limiting per API
- [ ] CORS configuration più restrittiva per production
- [ ] Helmet.js per security headers
- [ ] SQL injection prevention (già parzialmente coperto da Sequelize)
- [ ] XSS protection
### Funzionalità Scommesse
- [ ] Endpoint per dichiarare vincitore
- [ ] Sistema di votazione vincitori (per scommesse tra amici)
- [ ] Logica di calcolo punteggi utenti
- [ ] Notifiche per eventi scommessa
- [ ] Endpoint statistiche utente
- Win rate
- Streak attuali
- Scommesse vinte/perse
- Classifica generale
- [ ] Filtri avanzati per ricerca scommesse
- [ ] Paginazione scommesse
- [ ] Cronologia completa scommesse
### Funzionalità Sociali
- [ ] Endpoint per chat/commenti nelle scommesse
- [ ] WebSocket/SSE per notifiche real-time
- [ ] Sistema di notifiche persistenti
- [ ] Feed attività (chi ha creato cosa, chi ha vinto, ecc.)
- [ ] Richieste di amicizia: suggerimenti basati su amici in comune
- [ ] Blocco utenti
- [ ] Report/segnalazioni
### Funzionalità Gruppi
- [ ] Ruoli nei gruppi (admin, membro)
- [ ] Inviti al gruppo
- [ ] Statistiche gruppo
- [ ] Impostazioni privacy gruppo
### File & Media
- [ ] Upload avatar utente
- [ ] Upload immagini nelle scommesse
- [ ] Integrazione storage (S3, Cloudinary)
- [ ] Resize/compression immagini
### Performance & Optimization
- [ ] Caching con Redis
- Cache lista amici
- Cache gruppi utente
- Cache scommesse popolari
- [ ] Query optimization
- [ ] Indici database aggiuntivi dove necessario
- [ ] Paginazione su tutti gli endpoint liste
- [ ] Response compression (gzip)
- [ ] Database connection pooling optimization
### Testing
- [ ] Unit test per modelli
- [ ] Integration test per API endpoints
- [ ] Test copertura almeno 80%
- [ ] Mock database per test
- [ ] Setup test runner (Jest/Mocha)
### DevOps
- [ ] Logging strutturato (Winston/Pino)
- [ ] Health check endpoint (`/health`)
- [ ] Metrics endpoint (`/metrics`)
- [ ] Error tracking (Sentry)
- [ ] Environment variables validation
- [ ] Graceful shutdown
- [ ] Dockerfile per production
- [ ] Docker multi-stage build
### Database
- [ ] Migration system (invece di sync force)
- [ ] Backup automatici
- [ ] Soft delete per entità critiche
- [ ] Audit log per modifiche importanti
- [ ] Database indexing strategy review
### API Documentation
- [ ] Swagger/OpenAPI specification
- [ ] Endpoint documentation con esempi
- [ ] Postman collection
- [ ] API versioning strategy
### Monitoring
- [ ] Application Performance Monitoring (APM)
- [ ] Database query monitoring
- [ ] Error rate alerts
- [ ] Response time monitoring
---
## 🐛 Bug Conosciuti
- Nessuno al momento
---
## 🔧 Miglioramenti Tecnici Completati (v1.1.0 - 2024)
### Refactoring Architetturale
- [x] **Separazione logica business dai controller (service layer)**
- Creati 5 services: users, groups, friends, bets, data-stitching
- Business logic completamente spostata dai controller ai services
- `services/` directory con pattern consistente
- [x] **Dependency Injection Container**
- Implementato Awilix per DI
- Container setup in `di/container.js`
- Registrazione automatica di repositories, services, controllers
- Lifetime management: singleton per database, scoped per services
- [x] **Request/Response DTOs**
- Validation schemas con Joi
- 4 schemas: group, bet, user, friendRequest
- Middleware validation in `middleware/validation.js`
- Input sanitization automatico
- [x] **Standardizzazione formato risposte API**
- ResponseHandler class in `shared/response.handler.js`
- Formato consistente per successo e errori
- Timestamp automatico in ogni risposta
- Metodi: success, created, error, badRequest, notFound, etc.
- [x] **Error handling centralizzato**
- Custom errors: ValidationError, NotFoundError, ConflictError, etc.
- Global error handler middleware
- Gestione errori Sequelize e Mongoose
- Stack trace solo in development
- [x] **Constants file per magic numbers/strings**
- `config/constants.js`: HTTP_STATUS, BET_STATUS, FRIEND_REQUEST_STATUS, etc.
- `config/database.js`: configurazione database centralizzata
- `config/index.js`: export centralizzato
- [x] **Code linting (ESLint) e formatting (Prettier)**
- ESLint configurato con airbnb-base
- Prettier per code formatting
- Scripts npm: lint, lint:fix, format
- .eslintrc.json e .prettierrc
- [ ] **Type checking con TypeScript** - SKIPPED (troppo invasivo per questa fase)
### Nuova Struttura Architetturale
```
Routes (thin) → Validation → Controllers → Services → Repositories → Database
↓ ↓ ↓
ResponseHandler Business Logic DB Access
```
**Directory create:**
- `config/` - Configurazione centralizzata (3 files)
- `shared/errors/` - Custom errors (2 files)
- `shared/validators/schemas/` - Joi schemas (4 files)
- `shared/response.handler.js` - Standardized responses
- `middleware/` - errorHandler, validation, auth.mock (3 files)
- `di/` - Dependency injection container (1 file)
- `repositories/postgres/` - PostgreSQL repositories (4 files)
- `repositories/mongo/` - MongoDB repositories (1 file)
- `services/` - Business logic services (5 files)
- `controllers/` - Thin controllers (5 files)
- `routes/v1/` - API v1 routes (6 files)
- `routes/health.routes.js` - Health check endpoint
**File modificati:**
- `server.js` - Refactored per integrare DI container e nuova architettura
- `package.json` - Aggiunte dipendenze e scripts
### API Versioning
- **`/api/v1/*`** - Nuova API con architettura refactored
- Formato risposte standardizzato
- Validazione automatica input
- Error handling consistente
- **`/api/legacy/*`** - API legacy (backward compatibility)
- Routes originali spostate qui
- Mantiene formato originale
- Per garantire compatibilità durante migrazione
- **`/health`** - Health check endpoint
- Verifica connessioni PostgreSQL e MongoDB
- Status: healthy/unhealthy
### Endpoints API v1
#### Groups (`/api/v1/groups`)
- `GET /` - Lista tutti i gruppi
- `GET /:id` - Dettaglio gruppo con membri
- `POST /` - Crea gruppo (validato con Joi)
- `PUT /:id` - Aggiorna gruppo (validato)
- `DELETE /:id` - Elimina gruppo
- `PUT /:id/members` - Aggiungi membri (validato)
- `DELETE /:id/members/:userId` - Rimuovi membro
#### Bets (`/api/v1/bets`)
- `GET /` - Lista tutte le scommesse
- `GET /:id` - Dettaglio scommessa
- `POST /` - Crea scommessa (validato con Joi)
- `PUT /:id` - Aggiorna scommessa (validato)
- `DELETE /:id` - Elimina scommessa
#### Friend Requests (`/api/v1/friend-requests`)
- `GET /` - Lista amici dell'utente corrente
- `GET /pending` - Richieste pending
- `POST /` - Invia richiesta (validato con Joi)
- `POST /:id/accept` - Accetta richiesta
- `DELETE /:id` - Rifiuta/cancella richiesta
#### Users (`/api/v1/users`)
- `PUT /current` - Aggiorna profilo utente corrente (validato)
#### Initial Data (`/api/v1/initial-data`)
- `GET /` - Carica dati iniziali (currentUser, users, friends, groups, bets)
#### Health Check (`/health`)
- `GET /` - Status database (PostgreSQL, MongoDB)
### Formato Risposte Standardizzato
**Successo:**
```json
{
"success": true,
"message": "Operation successful",
"data": { ... },
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
**Errore:**
```json
{
"success": false,
"message": "Error message",
"errors": [{"field": "name", "message": "Required"}],
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Benefici dell'Architettura
- ✅ **Separation of Concerns**: Ogni layer ha responsabilità ben definite
- ✅ **Testability**: Services e repositories facilmente testabili
- ✅ **Maintainability**: Business logic centralizzata
- ✅ **Consistency**: Formato risposte e errori standardizzati
- ✅ **Scalability**: Facile aggiungere nuove feature
- ✅ **Type Safety**: Validation layer previene dati inconsistenti
- ✅ **Backward Compatibility**: Routes legacy funzionano ancora
---
## Note Tecniche
### Dipendenze Principali
- **express**: ^4.19.2
- **sequelize**: ^6.37.3 (PostgreSQL ORM)
- **mongoose**: ^8.4.0 (MongoDB ODM)
- **pg**: ^8.11.5 (PostgreSQL driver)
- **cors**: ^2.8.5
- **awilix**: ^10.0.2 (Dependency Injection)
- **joi**: ^17.12.1 (Validation)
### Dev Dependencies
- **eslint**: ^8.56.0
- **prettier**: ^3.2.4
- **eslint-config-airbnb-base**: ^15.0.0
- **eslint-config-prettier**: ^9.1.0
### Variabili Ambiente Richieste
```bash
PORT=3000
NODE_ENV=development
MONGO_URI=mongodb://root:root@servovio@localhost:27017/amici-scommesse
POSTGRES_URI=postgres://admin:admin@localhost:5432/amici_scommesse
```
### Porte
- Server: `3000`
- PostgreSQL: `5432`
- MongoDB: `27017`
### Scripts npm
```bash
npm start # Avvia server
npm run lint # Check linting
npm run lint:fix # Fix linting automaticamente
npm run format # Format codice con Prettier
```
+433
View File
@@ -0,0 +1,433 @@
# 🐳 Docker Guide - Amici Scommesse Backend
Guida completa per eseguire il backend con Docker e Docker Compose.
## 📋 Prerequisiti
- Docker >= 20.10
- Docker Compose >= 2.0
Verifica installazione:
```bash
docker --version
docker compose version
```
## 🚀 Quick Start
### Development (con Hot Reload)
```bash
# Avvia tutti i servizi in development
docker compose -f docker-compose.dev.yml up -d
# Visualizza logs
docker compose -f docker-compose.dev.yml logs -f backend
# Ferma tutti i servizi
docker compose -f docker-compose.dev.yml down
```
### Production
```bash
# Avvia tutti i servizi in production
docker compose up -d
# Visualizza logs
docker compose logs -f backend
# Ferma tutti i servizi
docker compose down
```
## 📦 Servizi Inclusi
### Docker Compose Production (`docker-compose.yml`)
| Servizio | Porta | Descrizione |
|----------|-------|-------------|
| `backend` | 3000 | Backend Node.js (production build) |
| `postgres` | 5432 | PostgreSQL 16 |
| `mongodb` | 27017 | MongoDB 7 |
### Docker Compose Development (`docker-compose.dev.yml`)
| Servizio | Porta | Descrizione |
|----------|-------|-------------|
| `backend` | 3000 | Backend Node.js (development + hot reload) |
| `postgres` | 5432 | PostgreSQL 16 |
| `mongodb` | 27017 | MongoDB 7 |
| `mongo-express` | 8081 | Web UI per MongoDB (opzionale) |
| `pgadmin` | 5050 | Web UI per PostgreSQL (opzionale) |
## 🔧 Configurazione
### File .env
Crea un file `.env` nella root del progetto:
```bash
# Copia il template
cp .env.example .env
# Modifica le variabili se necessario
nano .env
```
Le variabili d'ambiente sono automaticamente caricate da Docker Compose.
### Variabili Importanti
```env
# Application
NODE_ENV=development
PORT=3000
# PostgreSQL
POSTGRES_DB=amici_scommesse
POSTGRES_USER=admin
POSTGRES_PASSWORD=admin
# MongoDB
MONGO_DB=amici-scommesse
MONGO_USER=root
MONGO_PASSWORD=root@servovio
```
## 📝 Comandi Docker Compose
### Gestione Servizi
```bash
# Avvia tutti i servizi (production)
docker compose up -d
# Avvia tutti i servizi (development)
docker compose -f docker-compose.dev.yml up -d
# Avvia solo alcuni servizi
docker compose up -d postgres mongodb
# Ferma tutti i servizi
docker compose down
# Ferma e rimuovi volumi (ATTENZIONE: cancella i dati!)
docker compose down -v
# Restart di un servizio specifico
docker compose restart backend
```
### Build e Rebuild
```bash
# Build dell'immagine backend
docker compose build backend
# Rebuild forzato (ignora cache)
docker compose build --no-cache backend
# Build e avvia
docker compose up -d --build
```
### Logs e Debug
```bash
# Visualizza logs di tutti i servizi
docker compose logs
# Visualizza logs di un servizio specifico
docker compose logs backend
# Follow logs in real-time
docker compose logs -f backend
# Ultimi 100 log lines
docker compose logs --tail=100 backend
# Logs da timestamp specifico
docker compose logs --since 2024-01-01T00:00:00
```
### Shell nei Container
```bash
# Shell nel container backend
docker compose exec backend sh
# Shell nel container postgres
docker compose exec postgres psql -U admin -d amici_scommesse
# Shell nel container mongodb
docker compose exec mongodb mongosh -u root -p root@servovio --authenticationDatabase admin
```
### Health Check
```bash
# Verifica health di tutti i servizi
docker compose ps
# Health check specifico
curl http://localhost:3000/health
```
## 🔍 Troubleshooting
### Il backend non si avvia
**1. Verifica che i database siano healthy:**
```bash
docker compose ps
```
Dovresti vedere:
```
NAME STATUS HEALTH
amici-scommesse-backend Up 30 seconds healthy
amici-scommesse-postgres Up 45 seconds (healthy) healthy
amici-scommesse-mongodb Up 45 seconds (healthy) healthy
```
**2. Controlla i logs:**
```bash
docker compose logs backend
```
**3. Verifica la connessione ai database:**
```bash
# Test PostgreSQL
docker compose exec postgres pg_isready -U admin
# Test MongoDB
docker compose exec mongodb mongosh --eval "db.adminCommand('ping')"
```
### Errore "port already in use"
**Soluzione**: Cambia la porta nel file `.env`:
```env
PORT=3001
POSTGRES_PORT=5433
MONGO_PORT=27018
```
### I database non mantengono i dati
**Soluzione**: Verifica che i volumi esistano:
```bash
docker volume ls | grep amici-scommesse
```
Per ripristinare i volumi:
```bash
docker compose down
docker compose up -d
```
### Hot reload non funziona in development
**Verifica volumi**: Il file `docker-compose.dev.yml` deve avere:
```yaml
volumes:
- ./:/app
- /app/node_modules
```
**Rebuild del container**:
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
### Errore di connessione tra servizi
**Soluzione**: Usa i nomi dei servizi come hostname:
- Backend → Postgres: `postgres:5432`
- Backend → MongoDB: `mongodb:27017`
Non usare `localhost` quando sei dentro Docker!
## 🌐 Accesso ai Servizi
### Backend API
- **API v1**: http://localhost:3000/api/v1
- **Legacy API**: http://localhost:3000/api/legacy
- **Health Check**: http://localhost:3000/health
### Database Web UIs (Solo Development)
**Mongo Express** (MongoDB UI):
- URL: http://localhost:8081
- Username: `admin`
- Password: `admin`
**pgAdmin** (PostgreSQL UI):
- URL: http://localhost:5050
- Email: `admin@admin.com`
- Password: `admin`
**Connessione PostgreSQL in pgAdmin**:
1. Add New Server
2. General → Name: `Amici Scommesse`
3. Connection:
- Host: `postgres` (il nome del servizio Docker)
- Port: `5432`
- Username: `admin`
- Password: `admin`
- Database: `amici_scommesse`
## 📊 Gestione Dati
### Backup Database
**PostgreSQL**:
```bash
# Backup
docker compose exec postgres pg_dump -U admin amici_scommesse > backup.sql
# Restore
docker compose exec -T postgres psql -U admin amici_scommesse < backup.sql
```
**MongoDB**:
```bash
# Backup
docker compose exec mongodb mongodump --username root --password root@servovio --authenticationDatabase admin --db amici-scommesse --out /tmp/backup
# Export dal container
docker compose cp mongodb:/tmp/backup ./mongodb-backup
# Restore
docker compose cp ./mongodb-backup mongodb:/tmp/backup
docker compose exec mongodb mongorestore --username root --password root@servovio --authenticationDatabase admin --db amici-scommesse /tmp/backup/amici-scommesse
```
### Reset Completo
**ATTENZIONE: Cancella tutti i dati!**
```bash
# Ferma tutto e rimuovi volumi
docker compose down -v
# Rimuovi immagini
docker compose rm -f
# Rebuild e riavvia
docker compose up -d --build
```
## 🎯 Best Practices
### Development
1. **Usa docker-compose.dev.yml** per development
2. **Mount dei volumi** per hot reload
3. **Abilita SQL logging** per debug
4. **Usa le Web UI** per ispezionare i database
### Production
1. **Usa docker-compose.yml** per production
2. **Non montare volumi sorgente**
3. **Disabilita SQL logging**
4. **Usa secrets** per password sensibili
5. **Configura backup automatici**
### Sicurezza
1. **Non committare .env** nel repository
2. **Cambia password** di default per production
3. **Usa user non-root** (già configurato nel Dockerfile)
4. **Limita risorse** del container se necessario
## 🔨 Multi-Stage Build
Il Dockerfile usa multi-stage build per ottimizzare le immagini:
| Stage | Target | Uso |
|-------|--------|-----|
| `base` | - | Base comune |
| `dependencies` | - | Tutte le dipendenze |
| `development` | Development | Con devDependencies + hot reload |
| `production-dependencies` | - | Solo prod dependencies |
| `production` | Production | Ottimizzato, user non-root |
**Build target specifico**:
```bash
# Development
docker build --target development -t backend:dev .
# Production
docker build --target production -t backend:prod .
```
## 📈 Monitoraggio
### Resource Usage
```bash
# Uso risorse di tutti i container
docker stats
# Uso risorse di un container specifico
docker stats amici-scommesse-backend
```
### Container Logs in Produzione
Per produzione, considera l'uso di:
- **Docker logging drivers** (json-file, syslog, fluentd)
- **Log aggregators** (ELK Stack, Loki)
- **APM tools** (Datadog, New Relic)
## 🚀 Deploy in Production
### Docker Swarm
```bash
# Inizializza Swarm
docker swarm init
# Deploy lo stack
docker stack deploy -c docker-compose.yml amici-scommesse
# Lista servizi
docker service ls
# Scala il backend
docker service scale amici-scommesse_backend=3
```
### Kubernetes
Per Kubernetes, converti il docker-compose:
```bash
# Installa kompose
curl -L https://github.com/kubernetes/kompose/releases/download/v1.31.2/kompose-linux-amd64 -o kompose
chmod +x kompose
sudo mv kompose /usr/local/bin
# Converti
kompose convert -f docker-compose.yml
```
## 🔗 Link Utili
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [PostgreSQL Docker Image](https://hub.docker.com/_/postgres)
- [MongoDB Docker Image](https://hub.docker.com/_/mongo)
## 💡 Tips
1. **Cache delle dipendenze**: `COPY package*.json` prima di `COPY .` per ottimizzare il build
2. **Multi-stage**: Usa stage diversi per dev e prod
3. **Health checks**: Sempre configurare healthcheck per monitoraggio
4. **Named volumes**: Usa volumi nominati per persistenza dati
5. **Networks**: Isola i servizi in network dedicati
6. **Secrets**: Usa Docker secrets per dati sensibili in produzione
+89
View File
@@ -0,0 +1,89 @@
# ========================================
# Multi-stage Dockerfile for Backend Node.js
# ========================================
# ========================================
# Stage 1: Base
# ========================================
FROM node:20-alpine AS base
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Set working directory
WORKDIR /app
# Copy package files for dependency installation
COPY package*.json ./
# ========================================
# Stage 2: Dependencies
# ========================================
FROM base AS dependencies
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
# ========================================
# Stage 3: Development
# ========================================
FROM dependencies AS development
# Set NODE_ENV
ENV NODE_ENV=development
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start with nodemon for hot reload
CMD ["npm", "start"]
# ========================================
# Stage 4: Production Dependencies
# ========================================
FROM base AS production-dependencies
# Install only production dependencies
RUN npm ci --only=production && \
npm cache clean --force
# ========================================
# Stage 5: Production Build
# ========================================
FROM base AS production
# Set NODE_ENV
ENV NODE_ENV=production
# Copy production dependencies
COPY --from=production-dependencies /app/node_modules ./node_modules
# Copy application code
COPY . .
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "server.js"]
+277
View File
@@ -0,0 +1,277 @@
# Guida all'Implementazione - Miglioramenti Tecnici
## ✅ Implementazione Completata
Tutti i miglioramenti tecnici proposti nel CHANGELOG.md sono stati implementati:
1. ✅ Separazione logica business dai controller (service layer)
2. ✅ Dependency injection container (Awilix)
3. ✅ Request/Response DTOs (Joi validation)
4. ✅ Standardizzazione formato risposte API
5. ✅ Error handling centralizzato
6. ✅ Constants file per magic numbers/strings
7. ⏭️ Type checking con TypeScript - SKIPPED (come pianificato)
8. ✅ Code linting (ESLint) e formatting (Prettier)
## 🚀 Prossimi Passi per Avviare il Backend
### 1. Installare le Dipendenze
```bash
cd /home/claude/scomettiapp/backend-node
# Installare dipendenze di produzione
npm install awilix@^10.0.2 joi@^17.12.1
# Installare dipendenze di sviluppo
npm install --save-dev eslint@^8.56.0 eslint-config-airbnb-base@^15.0.0 eslint-plugin-import@^2.29.1 prettier@^3.2.4 eslint-config-prettier@^9.1.0 eslint-plugin-prettier@^5.1.3
```
### 2. Verificare Linting
```bash
# Controllare errori di linting
npm run lint
# Fixare automaticamente errori di linting
npm run lint:fix
# Formattare tutto il codice
npm run format
```
### 3. Avviare il Server
```bash
npm start
```
Output atteso:
```
Connected to MongoDB
Connected to PostgreSQL
PostgreSQL models synchronized
Server is running on http://localhost:3000
Environment: development
API v1 endpoints available at /api/v1/
Legacy endpoints available at /api/legacy/
Health check available at /health
```
## 🧪 Testing dell'Implementazione
### Test 1: Health Check
```bash
curl http://localhost:3000/health
```
Risposta attesa:
```json
{
"status": "healthy",
"timestamp": "2024-01-01T00:00:00.000Z",
"services": {
"postgres": "connected",
"mongodb": "connected"
}
}
```
### Test 2: Creare un Gruppo (v1 API)
```bash
curl -X POST http://localhost:3000/api/v1/groups \
-H "Content-Type: application/json" \
-d '{
"name": "Test Group",
"members": [1, 2]
}'
```
Risposta attesa (formato standardizzato):
```json
{
"success": true,
"message": "Group created successfully",
"data": {
"id": 3,
"name": "Test Group",
"members": [...]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Test 3: Validazione Errore
```bash
curl -X POST http://localhost:3000/api/v1/groups \
-H "Content-Type: application/json" \
-d '{
"name": "",
"members": []
}'
```
Risposta attesa (errore standardizzato):
```json
{
"success": false,
"message": "Validation failed",
"errors": [
{"field": "name", "message": "Group name is required"},
{"field": "members", "message": "At least one member is required"}
],
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Test 4: Not Found Error
```bash
curl http://localhost:3000/api/v1/groups/999
```
Risposta attesa:
```json
{
"success": false,
"message": "Group not found",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Test 5: Verificare Compatibilità Legacy
```bash
# Legacy route (vecchio formato, dovrebbe ancora funzionare)
curl http://localhost:3000/api/legacy/groups
```
### Test 6: Initial Data
```bash
curl http://localhost:3000/api/v1/initial-data
```
Risposta attesa:
```json
{
"success": true,
"message": "Initial data retrieved successfully",
"data": {
"currentUser": {...},
"users": [...],
"friends": [...],
"friendRequests": [...],
"groups": [...],
"userBets": [...],
"publicBets": [...]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## 📋 Checklist Verifica Completa
- [ ] Server si avvia senza errori
- [ ] PostgreSQL connesso
- [ ] MongoDB connesso
- [ ] Health check risponde correttamente
- [ ] Routes v1 rispondono con formato standardizzato
- [ ] Validation errors catturati e formattati
- [ ] Not found errors ritornano 404 consistente
- [ ] Routes legacy funzionano ancora (backward compatibility)
- [ ] ESLint passa senza errori critici
- [ ] Nessuna logica business nei controllers (solo nei services)
## 🎯 Endpoints API v1
### Groups
- `GET /api/v1/groups` - Lista tutti i gruppi
- `GET /api/v1/groups/:id` - Dettaglio gruppo
- `POST /api/v1/groups` - Crea gruppo (validato)
- `PUT /api/v1/groups/:id` - Aggiorna gruppo (validato)
- `DELETE /api/v1/groups/:id` - Elimina gruppo
- `PUT /api/v1/groups/:id/members` - Aggiungi membri (validato)
- `DELETE /api/v1/groups/:id/members/:userId` - Rimuovi membro
### Bets
- `GET /api/v1/bets` - Lista tutte le scommesse
- `GET /api/v1/bets/:id` - Dettaglio scommessa
- `POST /api/v1/bets` - Crea scommessa (validato)
- `PUT /api/v1/bets/:id` - Aggiorna scommessa (validato)
- `DELETE /api/v1/bets/:id` - Elimina scommessa
### Friend Requests
- `GET /api/v1/friend-requests` - Lista amici
- `GET /api/v1/friend-requests/pending` - Richieste pending
- `POST /api/v1/friend-requests` - Invia richiesta (validato)
- `POST /api/v1/friend-requests/:id/accept` - Accetta richiesta
- `DELETE /api/v1/friend-requests/:id` - Rifiuta richiesta
### Users
- `PUT /api/v1/users/current` - Aggiorna profilo (validato)
### Initial Data
- `GET /api/v1/initial-data` - Carica dati iniziali
### Health
- `GET /health` - Status database
## 🏗️ Architettura Implementata
```
Request → Routes → Validation → Controller → Service → Repository → Database
↓ ↓
Joi Schema Business Logic
↓
Custom Errors (ValidationError, etc.)
↓
Global Error Handler Middleware
↓
Standardized Response (ResponseHandler)
```
## 📊 Benefici dell'Architettura
1. **Separation of Concerns**: Ogni layer ha responsabilità ben definite
2. **Testability**: Services e repositories facilmente testabili con mock
3. **Maintainability**: Business logic centralizzata
4. **Consistency**: Formato risposte e errori standardizzati
5. **Scalability**: Facile aggiungere nuove feature
6. **Type Safety**: Validation layer previene dati inconsistenti
7. **Backward Compatibility**: Routes legacy funzionano ancora
8. **Flexibility**: DI container permette sostituzioni facili
## 🔧 Troubleshooting
### Errore: "Cannot find module 'awilix'"
Eseguire `npm install` per installare tutte le dipendenze.
### Errore: Database connection failed
Verificare che PostgreSQL e MongoDB siano in esecuzione e accessibili.
### Errore: ESLint errors
Eseguire `npm run lint:fix` per correggere automaticamente i problemi di formattazione.
### Routes legacy non funzionano
Le routes legacy sono state spostate su `/api/legacy/*` per mantenere compatibilità.
## 📝 Note Importanti
- **TypeScript**: Non implementato in questa fase (come pianificato)
- **Testing automatici**: Da implementare in fase successiva
- **Logging strutturato**: Opzionale, non implementato
- **Production**: Richiede ulteriori configurazioni (migrations, real auth, etc.)
## 🎉 Prossimi Step Consigliati
1. Installare dipendenze con npm
2. Testare tutti gli endpoint v1
3. Migrare frontend da `/api/*` a `/api/v1/*`
4. Eventualmente deprecare `/api/legacy/*`
5. Aggiungere test automatici (Jest)
6. Implementare logging strutturato (Winston)
7. Considerare migrazione a TypeScript in futuro
+46
View File
@@ -0,0 +1,46 @@
module.exports = {
// HTTP Status Codes
HTTP_STATUS: {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
},
// Bet Status
BET_STATUS: {
ACTIVE: 'ACTIVE',
AWAITING_VALIDATION: 'AWAITING_VALIDATION',
COMPLETED: 'COMPLETED',
EXPIRED: 'EXPIRED',
},
// Friend Request Status
FRIEND_REQUEST_STATUS: {
PENDING: 'PENDING',
ACCEPTED: 'ACCEPTED',
REJECTED: 'REJECTED',
},
// Participant Roles
PARTICIPANT_ROLES: {
PARTICIPANT: 'PARTICIPANT',
OBSERVER: 'OBSERVER',
},
// Pagination Defaults
PAGINATION: {
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100,
},
// Mock Auth (temporaneo)
MOCK_AUTH: {
DEFAULT_USER_NAME: 'Mario Rossi',
},
};
+28
View File
@@ -0,0 +1,28 @@
module.exports = {
postgres: {
uri:
process.env.POSTGRES_URI ||
'postgres://admin:admin@localhost:5432/amici_scommesse',
options: {
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? console.log : false,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000,
},
},
},
mongo: {
uri:
process.env.MONGO_URI ||
'mongodb://localhost:27017/amici-scommesse',
options: {
user: 'root',
pass: 'root@servovio',
authSource: 'admin',
},
},
};
+11
View File
@@ -0,0 +1,11 @@
const constants = require('./constants');
const database = require('./database');
module.exports = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production',
...constants,
database,
};
@@ -0,0 +1,54 @@
const ResponseHandler = require('../shared/response.handler');
class BetsController {
constructor({ betsService }) {
this.betsService = betsService;
}
async getAllBets(req, res, next) {
try {
const bets = await this.betsService.getAllBets();
return ResponseHandler.success(res, bets, 'Bets retrieved successfully');
} catch (error) {
next(error);
}
}
async getBetById(req, res, next) {
try {
const bet = await this.betsService.getBetById(req.params.id);
return ResponseHandler.success(res, bet, 'Bet retrieved successfully');
} catch (error) {
next(error);
}
}
async createBet(req, res, next) {
try {
const bet = await this.betsService.createBet(req.body);
return ResponseHandler.created(res, bet, 'Bet created successfully');
} catch (error) {
next(error);
}
}
async updateBet(req, res, next) {
try {
const bet = await this.betsService.updateBet(req.params.id, req.body);
return ResponseHandler.success(res, bet, 'Bet updated successfully');
} catch (error) {
next(error);
}
}
async deleteBet(req, res, next) {
try {
await this.betsService.deleteBet(req.params.id);
return ResponseHandler.success(res, null, 'Bet deleted successfully');
} catch (error) {
next(error);
}
}
}
module.exports = BetsController;
@@ -0,0 +1,67 @@
const ResponseHandler = require('../shared/response.handler');
class FriendsController {
constructor({ friendsService }) {
this.friendsService = friendsService;
}
async getFriends(req, res, next) {
try {
const friends = await this.friendsService.getFriendsByUserId(req.userId);
return ResponseHandler.success(res, friends, 'Friends retrieved successfully');
} catch (error) {
next(error);
}
}
async getPendingRequests(req, res, next) {
try {
const requests = await this.friendsService.getPendingRequests(req.userId);
return ResponseHandler.success(
res,
requests,
'Pending requests retrieved successfully'
);
} catch (error) {
next(error);
}
}
async sendRequest(req, res, next) {
try {
const request = await this.friendsService.sendFriendRequest(
req.userId,
req.body.identifier
);
return ResponseHandler.created(res, request, 'Friend request sent successfully');
} catch (error) {
next(error);
}
}
async acceptRequest(req, res, next) {
try {
const result = await this.friendsService.acceptFriendRequest(
req.params.id,
req.userId
);
return ResponseHandler.success(res, result, result.message);
} catch (error) {
next(error);
}
}
async rejectRequest(req, res, next) {
try {
const result = await this.friendsService.rejectFriendRequest(
req.params.id,
req.userId
);
return ResponseHandler.success(res, result, result.message);
} catch (error) {
next(error);
}
}
}
module.exports = FriendsController;
@@ -0,0 +1,78 @@
const ResponseHandler = require('../shared/response.handler');
class GroupsController {
constructor({ groupsService }) {
this.groupsService = groupsService;
}
async getAllGroups(req, res, next) {
try {
const groups = await this.groupsService.getAllGroups();
return ResponseHandler.success(res, groups, 'Groups retrieved successfully');
} catch (error) {
next(error);
}
}
async getGroupById(req, res, next) {
try {
const group = await this.groupsService.getGroupById(req.params.id);
return ResponseHandler.success(res, group, 'Group retrieved successfully');
} catch (error) {
next(error);
}
}
async createGroup(req, res, next) {
try {
const group = await this.groupsService.createGroup(req.body);
return ResponseHandler.created(res, group, 'Group created successfully');
} catch (error) {
next(error);
}
}
async updateGroup(req, res, next) {
try {
const group = await this.groupsService.updateGroup(req.params.id, req.body);
return ResponseHandler.success(res, group, 'Group updated successfully');
} catch (error) {
next(error);
}
}
async deleteGroup(req, res, next) {
try {
await this.groupsService.deleteGroup(req.params.id);
return ResponseHandler.success(res, null, 'Group deleted successfully');
} catch (error) {
next(error);
}
}
async addMembers(req, res, next) {
try {
const group = await this.groupsService.addMembersToGroup(
req.params.id,
req.body.userIds
);
return ResponseHandler.success(res, group, 'Members added successfully');
} catch (error) {
next(error);
}
}
async removeMember(req, res, next) {
try {
const group = await this.groupsService.removeMemberFromGroup(
req.params.id,
req.params.userId
);
return ResponseHandler.success(res, group, 'Member removed successfully');
} catch (error) {
next(error);
}
}
}
module.exports = GroupsController;
@@ -0,0 +1,38 @@
const ResponseHandler = require('../shared/response.handler');
class InitialDataController {
constructor({ usersService, friendsService, groupsService, betsService }) {
this.usersService = usersService;
this.friendsService = friendsService;
this.groupsService = groupsService;
this.betsService = betsService;
}
async getInitialData(req, res, next) {
try {
const currentUser = await this.usersService.getUserById(req.userId);
const users = await this.usersService.getAllUsers();
const friends = await this.friendsService.getFriendsByUserId(req.userId);
const friendRequests = await this.friendsService.getPendingRequests(req.userId);
const groups = await this.groupsService.getAllGroups();
const userBets = await this.betsService.getUserBets(req.userId);
const publicBets = await this.betsService.getPublicBetsExcludingUser(req.userId);
const data = {
currentUser,
users,
friends,
friendRequests,
groups,
userBets,
publicBets,
};
return ResponseHandler.success(res, data, 'Initial data retrieved successfully');
} catch (error) {
next(error);
}
}
}
module.exports = InitialDataController;
@@ -0,0 +1,18 @@
const ResponseHandler = require('../shared/response.handler');
class UsersController {
constructor({ usersService }) {
this.usersService = usersService;
}
async updateCurrentUser(req, res, next) {
try {
const user = await this.usersService.updateUserProfile(req.userId, req.body);
return ResponseHandler.success(res, user, 'User profile updated successfully');
} catch (error) {
next(error);
}
}
}
module.exports = UsersController;
+81
View File
@@ -0,0 +1,81 @@
const { createContainer, asClass, asFunction, asValue } = require('awilix');
const { Sequelize } = require('sequelize');
const mongoose = require('mongoose');
const fs = require('fs');
const path = require('path');
const config = require('../config');
function setupContainer() {
const container = createContainer();
container.register({
sequelize: asFunction(() => {
const sequelize = new Sequelize(
config.database.postgres.uri,
config.database.postgres.options
);
const modelsDir = path.join(__dirname, '../models');
fs.readdirSync(modelsDir)
.filter(
(file) =>
file.indexOf('.') !== 0 &&
file.slice(-3) === '.js' &&
file !== 'bet.model.js' &&
file !== 'associations.js'
)
.forEach((file) => {
require(path.join(modelsDir, file))(sequelize);
});
const defineAssociations = require('../models/associations');
defineAssociations(sequelize);
return sequelize;
}).singleton(),
mongoose: asValue(mongoose),
});
container.register({
usersRepository: asClass(
require('../repositories/postgres/users.repository')
).scoped(),
groupsRepository: asClass(
require('../repositories/postgres/groups.repository')
).scoped(),
friendshipsRepository: asClass(
require('../repositories/postgres/friendships.repository')
).scoped(),
friendRequestsRepository: asClass(
require('../repositories/postgres/friendRequests.repository')
).scoped(),
betsRepository: asClass(
require('../repositories/mongo/bets.repository')
).scoped(),
});
container.register({
dataStitchingService: asClass(
require('../services/data-stitching.service')
).scoped(),
usersService: asClass(require('../services/users.service')).scoped(),
groupsService: asClass(require('../services/groups.service')).scoped(),
friendsService: asClass(require('../services/friends.service')).scoped(),
betsService: asClass(require('../services/bets.service')).scoped(),
});
container.register({
betsController: asClass(require('../controllers/bets.controller')).scoped(),
groupsController: asClass(require('../controllers/groups.controller')).scoped(),
usersController: asClass(require('../controllers/users.controller')).scoped(),
friendsController: asClass(require('../controllers/friends.controller')).scoped(),
initialDataController: asClass(
require('../controllers/initialData.controller')
).scoped(),
});
return container;
}
module.exports = setupContainer;
+170
View File
@@ -0,0 +1,170 @@
version: '3.8'
services:
# ========================================
# PostgreSQL Database (Development)
# ========================================
postgres:
image: postgres:16-alpine
container_name: amici-scommesse-postgres-dev
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-amici_scommesse}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-amici_scommesse}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- amici-scommesse-network-dev
# ========================================
# MongoDB Database (Development)
# ========================================
mongodb:
image: mongo:7-jammy
container_name: amici-scommesse-mongodb-dev
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-root}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-root@servovio}
MONGO_INITDB_DATABASE: ${MONGO_DB:-amici-scommesse}
ports:
- "${MONGO_PORT:-27017}:27017"
volumes:
- mongodb_data_dev:/data/db
- mongodb_config_dev:/data/configdb
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
networks:
- amici-scommesse-network-dev
# ========================================
# Backend Node.js (Development with Hot Reload)
# ========================================
backend:
build:
context: .
dockerfile: Dockerfile
target: development
container_name: amici-scommesse-backend-dev
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
mongodb:
condition: service_healthy
environment:
NODE_ENV: development
PORT: ${PORT:-3000}
# PostgreSQL connection
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-amici_scommesse}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
POSTGRES_URI: postgres://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-admin}@postgres:5432/${POSTGRES_DB:-amici_scommesse}
# MongoDB connection
MONGO_HOST: mongodb
MONGO_PORT: 27017
MONGO_DB: ${MONGO_DB:-amici-scommesse}
MONGO_USER: ${MONGO_USER:-root}
MONGO_PASSWORD: ${MONGO_PASSWORD:-root@servovio}
MONGO_AUTH_SOURCE: ${MONGO_AUTH_SOURCE:-admin}
MONGO_URI: mongodb://${MONGO_USER:-root}:${MONGO_PASSWORD:-root@servovio}@mongodb:27017/${MONGO_DB:-amici-scommesse}?authSource=${MONGO_AUTH_SOURCE:-admin}
# Development config
LOG_LEVEL: debug
ENABLE_SQL_LOGGING: true
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
MOCK_USER_NAME: ${MOCK_USER_NAME:-Mario Rossi}
DB_SYNC_FORCE: true
ports:
- "${PORT:-3000}:3000"
volumes:
# Mount source code for hot reload
- ./:/app
- /app/node_modules
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
networks:
- amici-scommesse-network-dev
# ========================================
# Mongo Express (Optional - Web UI for MongoDB)
# ========================================
mongo-express:
image: mongo-express:latest
container_name: amici-scommesse-mongo-express
restart: unless-stopped
depends_on:
- mongodb
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_USER:-root}
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD:-root@servovio}
ME_CONFIG_MONGODB_URL: mongodb://${MONGO_USER:-root}:${MONGO_PASSWORD:-root@servovio}@mongodb:27017/
ME_CONFIG_BASICAUTH_USERNAME: admin
ME_CONFIG_BASICAUTH_PASSWORD: admin
ports:
- "8081:8081"
networks:
- amici-scommesse-network-dev
# ========================================
# pgAdmin (Optional - Web UI for PostgreSQL)
# ========================================
pgadmin:
image: dpage/pgadmin4:latest
container_name: amici-scommesse-pgadmin
restart: unless-stopped
depends_on:
- postgres
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
ports:
- "5050:80"
volumes:
- pgadmin_data_dev:/var/lib/pgadmin
networks:
- amici-scommesse-network-dev
# ========================================
# Networks
# ========================================
networks:
amici-scommesse-network-dev:
driver: bridge
name: amici-scommesse-network-dev
# ========================================
# Volumes
# ========================================
volumes:
postgres_data_dev:
name: amici-scommesse-postgres-data-dev
mongodb_data_dev:
name: amici-scommesse-mongodb-data-dev
mongodb_config_dev:
name: amici-scommesse-mongodb-config-dev
pgadmin_data_dev:
name: amici-scommesse-pgadmin-data-dev
+123
View File
@@ -0,0 +1,123 @@
version: '3.8'
services:
# ========================================
# PostgreSQL Database
# ========================================
postgres:
image: postgres:16-alpine
container_name: amici-scommesse-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-amici_scommesse}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-amici_scommesse}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- amici-scommesse-network
# ========================================
# MongoDB Database
# ========================================
mongodb:
image: mongo:7-jammy
container_name: amici-scommesse-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-root}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-root@servovio}
MONGO_INITDB_DATABASE: ${MONGO_DB:-amici-scommesse}
ports:
- "${MONGO_PORT:-27017}:27017"
volumes:
- mongodb_data:/data/db
- mongodb_config:/data/configdb
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
networks:
- amici-scommesse-network
# ========================================
# Backend Node.js (Production)
# ========================================
backend:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: amici-scommesse-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
mongodb:
condition: service_healthy
environment:
NODE_ENV: production
PORT: ${PORT:-3000}
# PostgreSQL connection (using service name as host)
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-amici_scommesse}
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
POSTGRES_URI: postgres://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-admin}@postgres:5432/${POSTGRES_DB:-amici_scommesse}
# MongoDB connection (using service name as host)
MONGO_HOST: mongodb
MONGO_PORT: 27017
MONGO_DB: ${MONGO_DB:-amici-scommesse}
MONGO_USER: ${MONGO_USER:-root}
MONGO_PASSWORD: ${MONGO_PASSWORD:-root@servovio}
MONGO_AUTH_SOURCE: ${MONGO_AUTH_SOURCE:-admin}
MONGO_URI: mongodb://${MONGO_USER:-root}:${MONGO_PASSWORD:-root@servovio}@mongodb:27017/${MONGO_DB:-amici-scommesse}?authSource=${MONGO_AUTH_SOURCE:-admin}
# Application config
LOG_LEVEL: ${LOG_LEVEL:-info}
ENABLE_SQL_LOGGING: ${ENABLE_SQL_LOGGING:-false}
CORS_ORIGIN: ${CORS_ORIGIN:-*}
MOCK_USER_NAME: ${MOCK_USER_NAME:-Mario Rossi}
DB_SYNC_FORCE: ${DB_SYNC_FORCE:-false}
ports:
- "${PORT:-3000}:3000"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
networks:
- amici-scommesse-network
# ========================================
# Networks
# ========================================
networks:
amici-scommesse-network:
driver: bridge
name: amici-scommesse-network
# ========================================
# Volumes
# ========================================
volumes:
postgres_data:
name: amici-scommesse-postgres-data
mongodb_data:
name: amici-scommesse-mongodb-data
mongodb_config:
name: amici-scommesse-mongodb-config
+77
View File
@@ -0,0 +1,77 @@
-- PostgreSQL schema initialization for core models
-- Derived from Sequelize models: User, Group, Friendship, FriendRequest, and the User<->Group join table
-- Drop tables in dependency order
DROP TABLE IF EXISTS "UserGroups" CASCADE;
DROP TABLE IF EXISTS friend_requests CASCADE;
DROP TABLE IF EXISTS friendships CASCADE;
DROP TABLE IF EXISTS groups CASCADE;
DROP TABLE IF EXISTS users CASCADE;
-- users table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
"email" VARCHAR(255) NOT NULL UNIQUE,
"avatarUrl" VARCHAR(255) NOT NULL,
score INTEGER NOT NULL DEFAULT 0
);
-- groups table
CREATE TABLE groups (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
-- friendships table
-- In-model hook ensures user1Id < user2Id at application level; we also add a DB check for safety.
CREATE TABLE friendships (
id SERIAL PRIMARY KEY,
"user1Id" INTEGER NOT NULL,
"user2Id" INTEGER NOT NULL,
CONSTRAINT friendships_user_order_chk CHECK ("user1Id" < "user2Id"),
CONSTRAINT friendships_user_pair_uniq UNIQUE ("user1Id", "user2Id"),
CONSTRAINT friendships_user1_fk FOREIGN KEY ("user1Id") REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT friendships_user2_fk FOREIGN KEY ("user2Id") REFERENCES users(id) ON DELETE CASCADE
);
-- friend_requests table (with Sequelize default timestamps)
CREATE TABLE friend_requests (
id SERIAL PRIMARY KEY,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
"fromUserId" INTEGER NOT NULL,
"toUserId" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT friend_requests_from_fk FOREIGN KEY ("fromUserId") REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT friend_requests_to_fk FOREIGN KEY ("toUserId") REFERENCES users(id) ON DELETE CASCADE
);
-- Optional: keep updatedAt current via trigger (Sequelize usually updates it in app code)
-- You can uncomment the below if you want DB-side auto update
-- CREATE OR REPLACE FUNCTION set_updated_at()
-- RETURNS TRIGGER AS $$
-- BEGIN
-- NEW."updatedAt" = NOW();
-- RETURN NEW;
-- END;
-- $$ LANGUAGE plpgsql;
-- CREATE TRIGGER friend_requests_set_updated_at
-- BEFORE UPDATE ON friend_requests
-- FOR EACH ROW
-- EXECUTE FUNCTION set_updated_at();
-- User <-> Group many-to-many join table
CREATE TABLE "UserGroups" (
"userId" INTEGER NOT NULL,
"groupId" INTEGER NOT NULL,
CONSTRAINT usergroups_user_fk FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT usergroups_group_fk FOREIGN KEY ("groupId") REFERENCES groups(id) ON DELETE CASCADE,
CONSTRAINT usergroups_user_group_uniq UNIQUE ("userId", "groupId")
);
-- Helpful indexes
CREATE INDEX IF NOT EXISTS idx_friend_requests_from ON friend_requests ("fromUserId");
CREATE INDEX IF NOT EXISTS idx_friend_requests_to ON friend_requests ("toUserId");
CREATE INDEX IF NOT EXISTS idx_friendships_user1 ON friendships ("user1Id");
CREATE INDEX IF NOT EXISTS idx_friendships_user2 ON friendships ("user2Id");
+19
View File
@@ -0,0 +1,19 @@
const config = require('../config');
module.exports = (sequelize) => {
const { User } = sequelize.models;
let MOCK_USER_ID = null;
return async (req, res, next) => {
if (!MOCK_USER_ID) {
const user = await User.findOne({
where: { name: config.MOCK_AUTH.DEFAULT_USER_NAME },
});
if (user) {
MOCK_USER_ID = user.id;
}
}
req.userId = MOCK_USER_ID;
next();
};
};
+40
View File
@@ -0,0 +1,40 @@
const { AppError } = require('../shared/errors');
const ResponseHandler = require('../shared/response.handler');
const config = require('../config');
const errorHandler = (err, req, res, next) => {
if (config.isDevelopment) {
console.error('Error caught by middleware:', err);
}
if (err instanceof AppError) {
return ResponseHandler.error(res, err.message, err.statusCode, err.errors);
}
if (
err.name === 'SequelizeValidationError' ||
err.name === 'SequelizeUniqueConstraintError'
) {
const errors = err.errors
? err.errors.map((e) => ({ field: e.path, message: e.message }))
: null;
return ResponseHandler.badRequest(res, 'Validation error', errors);
}
if (err.name === 'ValidationError' && err.errors) {
const errors = Object.keys(err.errors).map((key) => ({
field: key,
message: err.errors[key].message,
}));
return ResponseHandler.badRequest(res, 'Validation error', errors);
}
if (err.name === 'CastError') {
return ResponseHandler.badRequest(res, 'Invalid ID format');
}
const message = config.isDevelopment ? err.message : 'Internal server error';
return ResponseHandler.internalError(res, message, err);
};
module.exports = errorHandler;
+23
View File
@@ -0,0 +1,23 @@
const { ValidationError } = require('../shared/errors');
const validate = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true,
});
if (error) {
const errors = error.details.map((detail) => ({
field: detail.path.join('.'),
message: detail.message,
}));
return next(new ValidationError('Validation failed', errors));
}
req.body = value;
next();
};
};
module.exports = { validate };
+21
View File
@@ -0,0 +1,21 @@
module.exports = (sequelize) => {
const { User, Group, FriendRequest, Friendship } = sequelize.models;
// User <-> Group: Many-to-Many
// A User can be in many Groups, and a Group can have many Users (members).
const UserGroup = sequelize.define('UserGroup', {}, { timestamps: false });
User.belongsToMany(Group, { through: UserGroup, foreignKey: 'userId' });
Group.belongsToMany(User, { through: UserGroup, as: 'members', foreignKey: 'groupId' });
// Friendship is a direct link between two users.
// user1Id will always be less than user2Id to ensure uniqueness.
Friendship.belongsTo(User, { as: 'user1', foreignKey: 'user1Id' });
Friendship.belongsTo(User, { as: 'user2', foreignKey: 'user2Id' });
// User <-> FriendRequest: One-to-Many
// A User can send many friend requests and receive many friend requests.
User.hasMany(FriendRequest, { as: 'sentRequests', foreignKey: 'fromUserId', onDelete: 'CASCADE' });
User.hasMany(FriendRequest, { as: 'receivedRequests', foreignKey: 'toUserId', onDelete: 'CASCADE' });
FriendRequest.belongsTo(User, { as: 'fromUser', foreignKey: 'fromUserId' });
FriendRequest.belongsTo(User, { as: 'toUser', foreignKey: 'toUserId' });
};
+30
View File
@@ -0,0 +1,30 @@
const mongoose = require('mongoose');
const { Schema } = mongoose;
const BetParticipantSchema = new Schema({
user: { type: Number, required: true }, // Stores the PostgreSQL User ID
role: { type: String, enum: ['PARTICIPANT', 'OBSERVER'], required: true },
}, { _id: false });
const BetSchema = new mongoose.Schema({
title: { type: String, required: true },
description: { type: String },
stake: { type: String, required: true },
participants: [BetParticipantSchema],
status: { type: String, enum: ['ACTIVE', 'AWAITING_VALIDATION', 'COMPLETED', 'EXPIRED'], required: true },
startDate: { type: Date, default: Date.now },
endDate: { type: Date, default: null },
isPublic: { type: Boolean, default: false },
groupId: { type: String, default: null }, // Stores the PostgreSQL Group ID
winner: { type: Number, default: null }, // Stores the PostgreSQL User ID
});
BetSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: function (doc, ret) {
delete ret._id;
}
});
module.exports = mongoose.model('Bet', BetSchema);
@@ -0,0 +1,21 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const FriendRequest = sequelize.define('FriendRequest', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'PENDING',
allowNull: false
}
// Foreign keys 'fromUserId' and 'toUserId' are added via associations
}, {
// Uses default `createdAt` and `updatedAt` timestamps from Sequelize
tableName: 'friend_requests'
});
return FriendRequest;
};
+25
View File
@@ -0,0 +1,25 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Friendship = sequelize.define('Friendship', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
}
// Foreign keys 'user1Id' and 'user2Id' are added via associations
}, {
timestamps: false,
tableName: 'friendships',
hooks: {
// Ensure user1Id is always less than user2Id to guarantee
// a unique friendship pair regardless of creation order.
beforeValidate: (friendship) => {
if (friendship.user1Id > friendship.user2Id) {
[friendship.user1Id, friendship.user2Id] = [friendship.user2Id, friendship.user1Id];
}
}
}
});
return Friendship;
};
+19
View File
@@ -0,0 +1,19 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Group = sequelize.define('Group', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
}
}, {
timestamps: false,
tableName: 'groups'
});
return Group;
};
+35
View File
@@ -0,0 +1,35 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
avatarUrl: {
type: DataTypes.STRING,
allowNull: false
},
score: {
type: DataTypes.INTEGER,
defaultValue: 0
},
}, {
timestamps: false,
tableName: 'users'
});
return User;
};
File diff suppressed because it is too large Load Diff
+4780
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "amici-scommesse-backend",
"version": "1.0.0",
"description": "Backend for the Amici Scommesse app",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"**/*.js\"",
"docker:dev": "docker compose -f docker-compose.dev.yml up -d",
"docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f backend",
"docker:dev:down": "docker compose -f docker-compose.dev.yml down",
"docker:dev:rebuild": "docker compose -f docker-compose.dev.yml up -d --build",
"docker:prod": "docker compose up -d",
"docker:prod:logs": "docker compose logs -f backend",
"docker:prod:down": "docker compose down",
"docker:prod:rebuild": "docker compose up -d --build",
"docker:clean": "docker compose down -v && docker system prune -f",
"docker:ps": "docker compose ps",
"docker:restart": "docker compose restart backend"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"awilix": "^10.0.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"joi": "^17.12.1",
"mongoose": "^8.4.0",
"pg": "^8.11.5",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.3"
},
"devDependencies": {
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.4"
}
}
@@ -0,0 +1,102 @@
const Bet = require('../../models/bet.model');
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const config = require('../../config');
class BetsRepository {
async findAll(filter = {}, options = {}) {
try {
const { sort = { startDate: -1 }, limit, skip } = options;
let query = Bet.find(filter);
if (sort) query = query.sort(sort);
if (limit) query = query.limit(limit);
if (skip) query = query.skip(skip);
return await query.exec();
} catch (error) {
throw new DatabaseError('Failed to fetch bets', error);
}
}
async findById(id) {
try {
const bet = await Bet.findById(id);
if (!bet) {
throw new NotFoundError('Bet not found');
}
return bet;
} catch (error) {
if (error instanceof NotFoundError) throw error;
if (error.name === 'CastError') {
throw new NotFoundError('Bet not found');
}
throw new DatabaseError(`Failed to fetch bet with id ${id}`, error);
}
}
async findByUser(userId) {
try {
return await Bet.find({ 'participants.user': userId }).sort({ startDate: -1 });
} catch (error) {
throw new DatabaseError(`Failed to fetch bets for user ${userId}`, error);
}
}
async findByGroup(groupId) {
try {
return await Bet.find({ groupId }).sort({ startDate: -1 });
} catch (error) {
throw new DatabaseError(`Failed to fetch bets for group ${groupId}`, error);
}
}
async findPublicBetsExcludingUser(userId) {
try {
return await Bet.find({
isPublic: true,
'participants.user': { $ne: userId },
}).sort({ startDate: -1 });
} catch (error) {
throw new DatabaseError('Failed to fetch public bets', error);
}
}
async create(betData) {
try {
const bet = new Bet({
...betData,
startDate: new Date(),
status: config.BET_STATUS.ACTIVE,
});
return await bet.save();
} catch (error) {
throw new DatabaseError('Failed to create bet', error);
}
}
async update(id, updateData) {
try {
const bet = await this.findById(id);
Object.assign(bet, updateData);
return await bet.save();
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to update bet with id ${id}`, error);
}
}
async delete(id) {
try {
const result = await Bet.findByIdAndDelete(id);
if (!result) {
throw new NotFoundError('Bet not found');
}
return true;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to delete bet with id ${id}`, error);
}
}
}
module.exports = BetsRepository;
@@ -0,0 +1,99 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const { Op } = require('sequelize');
class FriendRequestsRepository {
constructor({ sequelize }) {
this.sequelize = sequelize;
this.FriendRequest = sequelize.models.FriendRequest;
this.User = sequelize.models.User;
}
async findById(id) {
try {
const request = await this.FriendRequest.findByPk(id, {
include: [
{ model: this.User, as: 'fromUser' },
{ model: this.User, as: 'toUser' },
],
});
if (!request) {
throw new NotFoundError('Friend request not found');
}
return request;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to fetch friend request with id ${id}`, error);
}
}
async findPendingByUserId(userId) {
try {
return await this.FriendRequest.findAll({
where: {
toUserId: userId,
status: 'PENDING',
},
include: [
{ model: this.User, as: 'fromUser' },
{ model: this.User, as: 'toUser' },
],
});
} catch (error) {
throw new DatabaseError(
`Failed to fetch pending requests for user ${userId}`,
error
);
}
}
async findExisting(fromUserId, toUserId) {
try {
return await this.FriendRequest.findOne({
where: {
[Op.or]: [
{ fromUserId, toUserId },
{ fromUserId: toUserId, toUserId: fromUserId },
],
status: 'PENDING',
},
});
} catch (error) {
throw new DatabaseError('Failed to check existing friend request', error);
}
}
async create(fromUserId, toUserId) {
try {
return await this.FriendRequest.create({
fromUserId,
toUserId,
status: 'PENDING',
});
} catch (error) {
throw new DatabaseError('Failed to create friend request', error);
}
}
async updateStatus(id, status) {
try {
const request = await this.findById(id);
return await request.update({ status });
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError('Failed to update friend request status', error);
}
}
async delete(id) {
try {
const request = await this.findById(id);
await request.destroy();
return true;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError('Failed to delete friend request', error);
}
}
}
module.exports = FriendRequestsRepository;
@@ -0,0 +1,78 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
const { Op } = require('sequelize');
class FriendshipsRepository {
constructor({ sequelize }) {
this.sequelize = sequelize;
this.Friendship = sequelize.models.Friendship;
this.User = sequelize.models.User;
}
async findByUserId(userId) {
try {
const friendships = await this.Friendship.findAll({
where: {
[Op.or]: [{ user1Id: userId }, { user2Id: userId }],
},
include: [
{ model: this.User, as: 'user1' },
{ model: this.User, as: 'user2' },
],
});
return friendships.map((friendship) => {
const friend =
friendship.user1.id === userId ? friendship.user2 : friendship.user1;
return friend;
});
} catch (error) {
throw new DatabaseError(`Failed to fetch friendships for user ${userId}`, error);
}
}
async exists(user1Id, user2Id) {
try {
const minId = Math.min(user1Id, user2Id);
const maxId = Math.max(user1Id, user2Id);
const friendship = await this.Friendship.findOne({
where: { user1Id: minId, user2Id: maxId },
});
return !!friendship;
} catch (error) {
throw new DatabaseError('Failed to check friendship existence', error);
}
}
async create(user1Id, user2Id) {
try {
const minId = Math.min(user1Id, user2Id);
const maxId = Math.max(user1Id, user2Id);
return await this.Friendship.create({
user1Id: minId,
user2Id: maxId,
});
} catch (error) {
throw new DatabaseError('Failed to create friendship', error);
}
}
async delete(user1Id, user2Id) {
try {
const minId = Math.min(user1Id, user2Id);
const maxId = Math.max(user1Id, user2Id);
const result = await this.Friendship.destroy({
where: { user1Id: minId, user2Id: maxId },
});
return result > 0;
} catch (error) {
throw new DatabaseError('Failed to delete friendship', error);
}
}
}
module.exports = FriendshipsRepository;
@@ -0,0 +1,93 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class GroupsRepository {
constructor({ sequelize }) {
this.sequelize = sequelize;
this.Group = sequelize.models.Group;
this.User = sequelize.models.User;
}
async findAll(options = {}) {
try {
return await this.Group.findAll(options);
} catch (error) {
throw new DatabaseError('Failed to fetch groups', error);
}
}
async findById(id, includeMembers = false) {
try {
const options = {};
if (includeMembers) {
options.include = [
{
model: this.User,
as: 'members',
through: { attributes: [] },
},
];
}
const group = await this.Group.findByPk(id, options);
if (!group) {
throw new NotFoundError('Group not found');
}
return group;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to fetch group with id ${id}`, error);
}
}
async create(groupData) {
try {
return await this.Group.create(groupData);
} catch (error) {
throw new DatabaseError('Failed to create group', error);
}
}
async addMembers(groupId, userIds) {
try {
const group = await this.findById(groupId);
await group.addMembers(userIds);
return this.findById(groupId, true);
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError('Failed to add members to group', error);
}
}
async removeMember(groupId, userId) {
try {
const group = await this.findById(groupId);
await group.removeMember(userId);
return this.findById(groupId, true);
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError('Failed to remove member from group', error);
}
}
async update(id, updateData) {
try {
const group = await this.findById(id);
return await group.update(updateData);
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to update group with id ${id}`, error);
}
}
async delete(id) {
try {
const group = await this.findById(id);
await group.destroy();
return true;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to delete group with id ${id}`, error);
}
}
}
module.exports = GroupsRepository;
@@ -0,0 +1,68 @@
const { NotFoundError, DatabaseError } = require('../../shared/errors');
class UsersRepository {
constructor({ sequelize }) {
this.sequelize = sequelize;
this.User = sequelize.models.User;
}
async findAll(options = {}) {
try {
return await this.User.findAll(options);
} catch (error) {
throw new DatabaseError('Failed to fetch users', error);
}
}
async findById(id, options = {}) {
try {
const user = await this.User.findByPk(id, options);
if (!user) {
throw new NotFoundError('User not found');
}
return user;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to fetch user with id ${id}`, error);
}
}
async findOne(where, options = {}) {
try {
return await this.User.findOne({ where, ...options });
} catch (error) {
throw new DatabaseError('Failed to find user', error);
}
}
async create(userData) {
try {
return await this.User.create(userData);
} catch (error) {
throw new DatabaseError('Failed to create user', error);
}
}
async update(id, updateData) {
try {
const user = await this.findById(id);
return await user.update(updateData);
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to update user with id ${id}`, error);
}
}
async delete(id) {
try {
const user = await this.findById(id);
await user.destroy();
return true;
} catch (error) {
if (error instanceof NotFoundError) throw error;
throw new DatabaseError(`Failed to delete user with id ${id}`, error);
}
}
}
module.exports = UsersRepository;
+31
View File
@@ -0,0 +1,31 @@
const express = require('express');
// Il router è ora una factory function che riceve l'istanza di sequelize
module.exports = (sequelize) => {
const router = express.Router();
// --- Middleware per simulare un utente loggato ---
const { User } = sequelize.models;
const MOCK_USER_NAME = 'Mario Rossi';
let MOCK_USER_ID = null;
router.use(async (req, res, next) => {
if (!MOCK_USER_ID) {
const user = await User.findOne({ where: { name: MOCK_USER_NAME } });
if (user) {
MOCK_USER_ID = user.id;
}
}
req.userId = MOCK_USER_ID;
next();
});
// --- Monta i router da file separati ---
router.use(require('./initial-data.routes')(sequelize));
router.use('/bets', require('./bets.routes')(sequelize));
router.use('/groups', require('./groups.routes')(sequelize));
router.use('/friend-requests', require('./friends.routes')(sequelize));
router.use('/users', require('./users.routes')(sequelize));
return router;
};
+39
View File
@@ -0,0 +1,39 @@
const express = require('express');
const router = express.Router();
// Modello Mongoose per le Scommesse
const Bet = require('../models/bet.model');
// Il router è ora una factory function che riceve l'istanza di sequelize
module.exports = (sequelize) => {
const { populateBetsWithUsers } = require('../utils/populateBets')(sequelize);
// POST /api/bets
router.post('/', async (req, res) => {
try {
const betData = req.body;
// Assicura che gli ID utente dal frontend siano correttamente parsati come interi per lo schema MongoDB
const participants = betData.participants.map(p => ({
...p,
user: parseInt(p.user, 10)
}));
const bet = new Bet({
...betData,
participants,
startDate: new Date(),
status: 'ACTIVE'
});
await bet.save();
// Popola nuovamente la scommessa creata per restituire oggetti utente completi
const populatedMongoBet = await Bet.findById(bet._id);
const finalBet = (await populateBetsWithUsers([populatedMongoBet]))[0];
res.status(201).json(finalBet);
} catch (error) {
res.status(400).json({ message: 'Error creating bet', error: error.message });
}
});
return router;
};
+73
View File
@@ -0,0 +1,73 @@
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
module.exports = (sequelize) => {
const { User, Friendship, FriendRequest } = sequelize.models;
// POST /api/friend-requests
router.post('/', async (req, res) => {
try {
const { identifier } = req.body;
const toUser = await User.findOne({ where: { [Op.or]: [{ email: identifier }, { name: identifier }] } });
if (!toUser) return res.status(404).json({ message: 'Utente non trovato.' });
if (toUser.id === req.userId) return res.status(400).json({ message: 'Non puoi aggiungere te stesso.'});
const user1Id = Math.min(req.userId, toUser.id);
const user2Id = Math.max(req.userId, toUser.id);
const friendshipExists = await Friendship.findOne({ where: { user1Id, user2Id } });
if (friendshipExists) return res.status(400).json({ message: 'Siete già amici.'});
const requestExists = await FriendRequest.findOne({
where: { [Op.or]: [{ fromUserId: req.userId, toUserId: toUser.id }, { fromUserId: toUser.id, toUserId: req.userId }] }
});
if (requestExists) return res.status(400).json({ message: 'Richiesta già inviata.'});
const friendRequest = await FriendRequest.create({ fromUserId: req.userId, toUserId: toUser.id });
const populatedReq = await FriendRequest.findByPk(friendRequest.id, {
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
res.status(201).json(populatedReq);
} catch (error) {
res.status(400).json({ message: 'Error sending friend request', error: error.message });
}
});
// POST /api/friend-requests/:id/accept
router.post('/:id/accept', async (req, res) => {
const t = await sequelize.transaction();
try {
const request = await FriendRequest.findByPk(req.params.id, { transaction: t });
if (!request || request.toUserId !== req.userId) {
await t.rollback();
return res.status(404).json({ message: 'Request not found or not for you.' });
}
const user1Id = Math.min(request.fromUserId, request.toUserId);
const user2Id = Math.max(request.fromUserId, request.toUserId);
await Friendship.create({ user1Id, user2Id }, { transaction: t });
await request.destroy({ transaction: t });
await t.commit();
const newFriend = await User.findByPk(request.fromUserId);
res.status(200).json({ message: 'Friend request accepted', newFriend });
} catch (error) {
await t.rollback();
res.status(400).json({ message: 'Error accepting friend request', error: error.message });
}
});
// DELETE /api/friend-requests/:id
router.delete('/:id', async (req, res) => {
try {
const count = await FriendRequest.destroy({ where: { id: req.params.id } });
if (count === 0) return res.status(404).json({ message: 'Request not found.' });
res.status(200).json({ message: 'Friend request cancelled/declined.' });
} catch (error) {
res.status(400).json({ message: 'Error deleting friend request', error: error.message });
}
});
return router;
};
+54
View File
@@ -0,0 +1,54 @@
const express = require('express');
const router = express.Router();
module.exports = (sequelize) => {
const { User, Group } = sequelize.models;
// POST /api/groups
router.post('/', async (req, res) => {
try {
const { name, members } = req.body;
const group = await Group.create({ name });
await group.setMembers(members);
const populatedGroup = await Group.findByPk(group.id, {
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
res.status(201).json(populatedGroup);
} catch (error) {
res.status(400).json({ message: 'Error creating group', error: error.message });
}
});
// PUT /api/groups/:id/members
router.put('/:id/members', async (req, res) => {
try {
const { userIds } = req.body;
const group = await Group.findByPk(req.params.id);
if (!group) return res.status(404).json({ message: 'Group not found' });
await group.addMembers(userIds);
const populatedGroup = await Group.findByPk(group.id, {
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
res.json(populatedGroup);
} catch (error) {
res.status(400).json({ message: 'Error adding members to group', error: error.message });
}
});
// DELETE /api/groups/:id/members/:userId
router.delete('/:id/members/:userId', async (req, res) => {
try {
const group = await Group.findByPk(req.params.id);
if (!group) return res.status(404).json({ message: 'Group not found' });
await group.removeMember(req.params.userId);
const populatedGroup = await Group.findByPk(group.id, {
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
res.json(populatedGroup);
} catch (error) {
res.status(400).json({ message: 'Error removing member from group', error: error.message });
}
});
return router;
};
+32
View File
@@ -0,0 +1,32 @@
const express = require('express');
module.exports = ({ sequelize, mongoose }) => {
const router = express.Router();
router.get('/health', async (req, res) => {
try {
await sequelize.authenticate();
const pgStatus = 'connected';
const mongoStatus =
mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
postgres: pgStatus,
mongodb: mongoStatus,
},
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error.message,
});
}
});
return router;
};
@@ -0,0 +1,59 @@
const express = require('express');
const router = express.Router();
const { Op } = require('sequelize');
// Modello Mongoose per le Scommesse
const Bet = require('../models/bet.model');
module.exports = (sequelize) => {
const { User, Friendship, FriendRequest } = sequelize.models;
const { populateBetsWithUsers } = require('../utils/populateBets')(sequelize);
// GET /api/initial-data
router.get('/initial-data', async (req, res) => {
try {
// --- Query PostgreSQL ---
const currentUser = await User.findByPk(req.userId);
const users = await User.findAll();
const friendships = await Friendship.findAll({
where: { [Op.or]: [{ user1Id: req.userId }, { user2Id: req.userId }] },
include: [{ model: User, as: 'user1' }, { model: User, as: 'user2' }]
});
const friends = friendships.map(f => (f.user1Id === req.userId ? f.user2 : f.user1));
const receivedFriendRequests = await FriendRequest.findAll({
where: { toUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const sentFriendRequests = await FriendRequest.findAll({
where: { fromUserId: req.userId, status: 'PENDING' },
include: [{ model: User, as: 'fromUser' }, { model: User, as: 'toUser' }]
});
const userGroups = await currentUser.getGroups({
include: [{ model: User, as: 'members', through: { attributes: [] } }]
});
// --- Query MongoDB ---
const mongoBets = await Bet.find({ 'participants.user': req.userId }).sort({ startDate: -1 });
const mongoPublicBets = await Bet.find({ isPublic: true, 'participants.user': { $ne: req.userId } });
// --- Data Stitching ---
const bets = await populateBetsWithUsers(mongoBets);
const publicBets = await populateBetsWithUsers(mongoPublicBets);
res.json({
currentUser, users, friends, receivedFriendRequests, sentFriendRequests,
bets, publicBets, groups: userGroups
});
} catch (error) {
console.error('Error fetching initial data:', error);
res.status(500).json({ message: 'Error fetching initial data', error: error.message });
}
});
return router;
};
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
module.exports = (sequelize) => {
const { User } = sequelize.models;
// PUT /api/users/current
router.put('/current', async (req, res) => {
try {
const { name, email } = req.body;
const user = await User.findByPk(req.userId);
if (!user) return res.status(404).json({ message: 'User not found' });
user.name = name;
user.email = email;
await user.save();
res.json(user);
} catch (error) {
res.status(400).json({ message: 'Error updating user', error: error.message });
}
});
return router;
};
+26
View File
@@ -0,0 +1,26 @@
const express = require('express');
const { validate } = require('../../middleware/validation');
const {
createBetSchema,
updateBetSchema,
} = require('../../shared/validators/schemas/bet.schema');
module.exports = ({ betsController }) => {
const router = express.Router();
const controller = {
getAllBets: (req, res, next) => betsController.getAllBets(req, res, next),
getBetById: (req, res, next) => betsController.getBetById(req, res, next),
createBet: (req, res, next) => betsController.createBet(req, res, next),
updateBet: (req, res, next) => betsController.updateBet(req, res, next),
deleteBet: (req, res, next) => betsController.deleteBet(req, res, next),
};
router.get('/', controller.getAllBets);
router.get('/:id', controller.getBetById);
router.post('/', validate(createBetSchema), controller.createBet);
router.put('/:id', validate(updateBetSchema), controller.updateBet);
router.delete('/:id', controller.deleteBet);
return router;
};
+26
View File
@@ -0,0 +1,26 @@
const express = require('express');
const { validate } = require('../../middleware/validation');
const {
sendFriendRequestSchema,
} = require('../../shared/validators/schemas/friendRequest.schema');
module.exports = ({ friendsController }) => {
const router = express.Router();
const controller = {
getFriends: (req, res, next) => friendsController.getFriends(req, res, next),
getPendingRequests: (req, res, next) =>
friendsController.getPendingRequests(req, res, next),
sendRequest: (req, res, next) => friendsController.sendRequest(req, res, next),
acceptRequest: (req, res, next) => friendsController.acceptRequest(req, res, next),
rejectRequest: (req, res, next) => friendsController.rejectRequest(req, res, next),
};
router.get('/', controller.getFriends);
router.get('/pending', controller.getPendingRequests);
router.post('/', validate(sendFriendRequestSchema), controller.sendRequest);
router.post('/:id/accept', controller.acceptRequest);
router.delete('/:id', controller.rejectRequest);
return router;
};
+31
View File
@@ -0,0 +1,31 @@
const express = require('express');
const { validate } = require('../../middleware/validation');
const {
createGroupSchema,
updateGroupSchema,
addMembersSchema,
} = require('../../shared/validators/schemas/group.schema');
module.exports = ({ groupsController }) => {
const router = express.Router();
const controller = {
getAllGroups: (req, res, next) => groupsController.getAllGroups(req, res, next),
getGroupById: (req, res, next) => groupsController.getGroupById(req, res, next),
createGroup: (req, res, next) => groupsController.createGroup(req, res, next),
updateGroup: (req, res, next) => groupsController.updateGroup(req, res, next),
deleteGroup: (req, res, next) => groupsController.deleteGroup(req, res, next),
addMembers: (req, res, next) => groupsController.addMembers(req, res, next),
removeMember: (req, res, next) => groupsController.removeMember(req, res, next),
};
router.get('/', controller.getAllGroups);
router.get('/:id', controller.getGroupById);
router.post('/', validate(createGroupSchema), controller.createGroup);
router.put('/:id', validate(updateGroupSchema), controller.updateGroup);
router.delete('/:id', controller.deleteGroup);
router.put('/:id/members', validate(addMembersSchema), controller.addMembers);
router.delete('/:id/members/:userId', controller.removeMember);
return router;
};
+21
View File
@@ -0,0 +1,21 @@
const express = require('express');
module.exports = (container) => {
const router = express.Router();
const controllers = {
groupsController: container.resolve('groupsController'),
betsController: container.resolve('betsController'),
usersController: container.resolve('usersController'),
friendsController: container.resolve('friendsController'),
initialDataController: container.resolve('initialDataController'),
};
router.use('/groups', require('./groups.routes')(controllers));
router.use('/bets', require('./bets.routes')(controllers));
router.use('/users', require('./users.routes')(controllers));
router.use('/friend-requests', require('./friends.routes')(controllers));
router.use('/initial-data', require('./initialData.routes')(controllers));
return router;
};
@@ -0,0 +1,13 @@
const express = require('express');
module.exports = ({ initialDataController }) => {
const router = express.Router();
const controller = {
getInitialData: (req, res, next) => initialDataController.getInitialData(req, res, next),
};
router.get('/', controller.getInitialData);
return router;
};
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const { validate } = require('../../middleware/validation');
const { updateUserSchema } = require('../../shared/validators/schemas/user.schema');
module.exports = ({ usersController }) => {
const router = express.Router();
const controller = {
updateCurrentUser: (req, res, next) => usersController.updateCurrentUser(req, res, next),
};
router.put('/current', validate(updateUserSchema), controller.updateCurrentUser);
return router;
};
+71
View File
@@ -0,0 +1,71 @@
const mongoose = require('mongoose');
const Bet = require('./models/bet.model');
const seedDatabase = async (sequelize) => {
const { User, Group, Friendship, FriendRequest } = sequelize.models;
try {
const userCount = await User.count();
if (userCount > 0) {
// console.log('PostgreSQL database is not empty. Skipping seed.');
return;
}
console.log('Seeding databases...');
// PostgreSQL tables are already empty due to `sync({ force: true })` in server.js
// Clear MongoDB data
await Bet.deleteMany({});
// --- SEED POSTGRESQL ---
// Create Users
const usersData = [
{ id: 1, name: 'Mario Rossi', email: 'mario.rossi@example.com', avatarUrl: 'https://picsum.photos/id/1005/200/200', score: 120 },
{ id: 2, name: 'Luigi Verdi', email: 'luigi.verdi@example.com', avatarUrl: 'https://picsum.photos/id/1012/200/200', score: 95 },
{ id: 3, name: 'Giovanna Bianchi', email: 'giovanna.bianchi@example.com', avatarUrl: 'https://picsum.photos/id/1027/200/200', score: 150 },
{ id: 4, name: 'Anna Neri', email: 'anna.neri@example.com', avatarUrl: 'https://picsum.photos/id/1011/200/200', score: 80 },
{ id: 5, name: 'Carlo Gialli', email: 'carlo.gialli@example.com', avatarUrl: 'https://picsum.photos/id/20/200/200', score: 200 },
{ id: 6, name: 'Paolo Marroni', email: 'paolo.marroni@example.com', avatarUrl: 'https://picsum.photos/id/30/200/200', score: 50 },
];
await User.bulkCreate(usersData);
// Create Friendships
const friendshipsData = [
{ user1Id: 1, user2Id: 2 },
{ user1Id: 1, user2Id: 3 },
{ user1Id: 2, user2Id: 3 },
];
await Friendship.bulkCreate(friendshipsData);
// Create Friend Requests
const friendRequestsData = [
{ fromUserId: 4, toUserId: 1, status: 'PENDING' },
{ fromUserId: 1, toUserId: 5, status: 'PENDING' },
];
await FriendRequest.bulkCreate(friendRequestsData);
// Create Groups and add members
const group1 = await Group.create({ id: 1, name: 'Amici Stretti' });
await group1.setMembers([1, 2, 3]);
const group2 = await Group.create({ id: 2, name: 'Colleghi' });
await group2.setMembers([1, 4, 5]);
// --- SEED MONGODB ---
// Create Bets, referencing PostgreSQL IDs
const betsData = [
{ title: 'Chi vince a calcetto?', description: 'Partita di venerdì sera', stake: 'Una pizza', participants: [{ user: 1, role: 'PARTICIPANT' }, { user: 2, role: 'PARTICIPANT' }], status: 'ACTIVE', startDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), endDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), isPublic: false, groupId: group1.id.toString() },
{ title: 'Prossimo film al cinema', description: 'Quale film vedremo?', stake: 'Offre i biglietti', participants: [{ user: 1, role: 'PARTICIPANT' }, { user: 3, role: 'PARTICIPANT' }], status: 'AWAITING_VALIDATION', startDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), isPublic: false, groupId: group1.id.toString() },
{ title: 'Riuscirò a finire il report?', description: 'Entro la deadline di domani!', stake: 'Un caffè', participants: [{ user: 1, role: 'PARTICIPANT' }, { user: 4, role: 'OBSERVER' }], status: 'ACTIVE', startDate: new Date(), endDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000), isPublic: true },
{ title: 'Scommessa passata', description: 'Chi ha mangiato più sushi', stake: '€20', participants: [{ user: 1, role: 'PARTICIPANT' }, { user: 2, role: 'PARTICIPANT' }], status: 'COMPLETED', startDate: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000), endDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), isPublic: false, groupId: group1.id.toString(), winner: 2 },
{ title: 'Scommessa pubblica', description: 'Quanti goal segnerà la nazionale?', stake: 'Gloria eterna', participants: [{ user: 5, role: 'PARTICIPANT' }, { user: 6, role: 'PARTICIPANT' }], status: 'ACTIVE', startDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), endDate: null, isPublic: true },
];
await Bet.insertMany(betsData);
console.log('Database seeded successfully.');
} catch (error) {
console.error('Error seeding database:', error);
}
};
module.exports = { seedDatabase };
+64
View File
@@ -0,0 +1,64 @@
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const config = require('./config');
const setupContainer = require('./di/container');
const errorHandler = require('./middleware/errorHandler');
const mockAuthMiddleware = require('./middleware/auth.mock');
const { seedDatabase } = require('./seed');
const legacyApiRoutes = require('./routes/api');
const app = express();
app.use(cors());
app.use(express.json());
const container = setupContainer();
const sequelize = container.resolve('sequelize');
mongoose
.connect(config.database.mongo.uri, config.database.mongo.options)
.then(() => console.log('Connected to MongoDB'))
.catch((err) => {
console.error('MongoDB connection error:', err);
process.exit(1);
});
app.use('/api', mockAuthMiddleware(sequelize));
app.use('/api/legacy', legacyApiRoutes(sequelize));
const v1Routes = require('./routes/v1');
app.use('/api/v1', v1Routes(container));
app.use('/api', v1Routes(container));
const healthRoutes = require('./routes/health.routes');
app.use('/', healthRoutes({ sequelize, mongoose }));
app.use(errorHandler);
sequelize
.authenticate()
.then(() => {
console.log('Connected to PostgreSQL');
return sequelize.sync({ force: true });
})
.then(() => {
console.log('PostgreSQL models synchronized');
return seedDatabase(sequelize);
})
.then(() => {
app.listen(config.port, () => {
console.log(`Server is running on http://localhost:${config.port}`);
console.log(`Environment: ${config.nodeEnv}`);
console.log(`API v1 endpoints available at /api/v1/`);
console.log(`Legacy endpoints available at /api/legacy/`);
console.log(`Health check available at /health`);
});
})
.catch((err) => {
console.error('PostgreSQL connection or sync error:', err);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
const { ValidationError } = require('../shared/errors');
const config = require('../config');
class BetsService {
constructor({ betsRepository, usersRepository, dataStitchingService }) {
this.betsRepository = betsRepository;
this.usersRepository = usersRepository;
this.dataStitchingService = dataStitchingService;
}
async getAllBets() {
const bets = await this.betsRepository.findAll();
return await this.dataStitchingService.populateBetsWithUsers(bets);
}
async getBetById(betId) {
const bet = await this.betsRepository.findById(betId);
const populated = await this.dataStitchingService.populateBetsWithUsers([bet]);
return populated[0];
}
async getUserBets(userId) {
const bets = await this.betsRepository.findByUser(userId);
return await this.dataStitchingService.populateBetsWithUsers(bets);
}
async getGroupBets(groupId) {
const bets = await this.betsRepository.findByGroup(groupId);
return await this.dataStitchingService.populateBetsWithUsers(bets);
}
async getPublicBetsExcludingUser(userId) {
const bets = await this.betsRepository.findPublicBetsExcludingUser(userId);
return await this.dataStitchingService.populateBetsWithUsers(bets);
}
async createBet(betData) {
const { title, description, stake, participants, isPublic, groupId } = betData;
if (!title || title.trim() === '') {
throw new ValidationError('Bet title is required');
}
if (!stake || stake.trim() === '') {
throw new ValidationError('Bet stake is required');
}
if (!participants || !Array.isArray(participants) || participants.length === 0) {
throw new ValidationError('At least one participant is required');
}
const normalizedParticipants = participants.map((p) => ({
...p,
user: parseInt(p.user, 10),
}));
const userIds = normalizedParticipants.map((p) => p.user);
for (const userId of userIds) {
await this.usersRepository.findById(userId);
}
const bet = await this.betsRepository.create({
title,
description,
stake,
participants: normalizedParticipants,
isPublic: isPublic || false,
groupId: groupId || null,
});
const populated = await this.dataStitchingService.populateBetsWithUsers([bet]);
return populated[0];
}
async updateBet(betId, updateData) {
if (updateData.title && updateData.title.trim() === '') {
throw new ValidationError('Bet title cannot be empty');
}
const bet = await this.betsRepository.update(betId, updateData);
const populated = await this.dataStitchingService.populateBetsWithUsers([bet]);
return populated[0];
}
async deleteBet(betId) {
return await this.betsRepository.delete(betId);
}
async declareWinner(betId, winnerId) {
await this.usersRepository.findById(winnerId);
const bet = await this.betsRepository.update(betId, {
winner: winnerId,
status: config.BET_STATUS.COMPLETED,
endDate: new Date(),
});
const populated = await this.dataStitchingService.populateBetsWithUsers([bet]);
return populated[0];
}
}
module.exports = BetsService;
@@ -0,0 +1,41 @@
class DataStitchingService {
constructor({ usersRepository }) {
this.usersRepository = usersRepository;
}
async populateBetsWithUsers(bets) {
if (!bets || bets.length === 0) return [];
const userIds = new Set();
bets.forEach((bet) => {
bet.participants.forEach((p) => userIds.add(p.user));
if (bet.winner) {
userIds.add(bet.winner);
}
});
if (userIds.size === 0)
return bets.map((b) => (typeof b.toJSON === 'function' ? b.toJSON() : b));
const users = await this.usersRepository.findAll({
where: { id: Array.from(userIds) },
});
const userMap = new Map(
users.map((u) => [u.id, typeof u.toJSON === 'function' ? u.toJSON() : u])
);
return bets.map((bet) => {
const betJson = typeof bet.toJSON === 'function' ? bet.toJSON() : bet;
betJson.participants = betJson.participants.map((p) => ({
...p,
user: userMap.get(p.user) || null,
}));
if (betJson.winner) {
betJson.winner = userMap.get(betJson.winner) || null;
}
return betJson;
});
}
}
module.exports = DataStitchingService;
+102
View File
@@ -0,0 +1,102 @@
const { ValidationError, ConflictError } = require('../shared/errors');
const { Op } = require('sequelize');
class FriendsService {
constructor({
friendshipsRepository,
friendRequestsRepository,
usersRepository,
sequelize,
}) {
this.friendshipsRepository = friendshipsRepository;
this.friendRequestsRepository = friendRequestsRepository;
this.usersRepository = usersRepository;
this.sequelize = sequelize;
}
async getFriendsByUserId(userId) {
return await this.friendshipsRepository.findByUserId(userId);
}
async getPendingRequests(userId) {
return await this.friendRequestsRepository.findPendingByUserId(userId);
}
async sendFriendRequest(fromUserId, identifier) {
let toUser;
if (typeof identifier === 'number' || !isNaN(identifier)) {
toUser = await this.usersRepository.findById(parseInt(identifier, 10));
} else {
toUser = await this.usersRepository.findOne({
[Op.or]: [{ name: identifier }, { email: identifier }],
});
if (!toUser) {
throw new ValidationError(
'User not found with provided name or email'
);
}
}
if (toUser.id === fromUserId) {
throw new ValidationError('Cannot send friend request to yourself');
}
const friendshipExists = await this.friendshipsRepository.exists(
fromUserId,
toUser.id
);
if (friendshipExists) {
throw new ConflictError('You are already friends with this user');
}
const existingRequest = await this.friendRequestsRepository.findExisting(
fromUserId,
toUser.id
);
if (existingRequest) {
throw new ConflictError('Friend request already exists');
}
return await this.friendRequestsRepository.create(fromUserId, toUser.id);
}
async acceptFriendRequest(requestId, userId) {
const request = await this.friendRequestsRepository.findById(requestId);
if (request.toUserId !== userId) {
throw new ValidationError('You can only accept requests sent to you');
}
if (request.status !== 'PENDING') {
throw new ValidationError('This request has already been processed');
}
const t = await this.sequelize.transaction();
try {
await this.friendRequestsRepository.updateStatus(requestId, 'ACCEPTED');
await this.friendshipsRepository.create(request.fromUserId, request.toUserId);
await this.friendRequestsRepository.delete(requestId);
await t.commit();
return { message: 'Friend request accepted successfully' };
} catch (error) {
await t.rollback();
throw error;
}
}
async rejectFriendRequest(requestId, userId) {
const request = await this.friendRequestsRepository.findById(requestId);
if (request.toUserId !== userId && request.fromUserId !== userId) {
throw new ValidationError('You can only manage your own requests');
}
await this.friendRequestsRepository.delete(requestId);
return { message: 'Friend request rejected successfully' };
}
}
module.exports = FriendsService;
+76
View File
@@ -0,0 +1,76 @@
const { ValidationError } = require('../shared/errors');
class GroupsService {
constructor({ groupsRepository, usersRepository }) {
this.groupsRepository = groupsRepository;
this.usersRepository = usersRepository;
}
async getAllGroups() {
return await this.groupsRepository.findAll({
include: [
{
model: this.usersRepository.User,
as: 'members',
through: { attributes: [] },
},
],
});
}
async getGroupById(groupId) {
return await this.groupsRepository.findById(groupId, true);
}
async createGroup(groupData) {
const { name, members } = groupData;
if (!name || name.trim() === '') {
throw new ValidationError('Group name is required');
}
if (!members || !Array.isArray(members) || members.length === 0) {
throw new ValidationError('At least one member is required');
}
for (const userId of members) {
await this.usersRepository.findById(userId);
}
const group = await this.groupsRepository.create({ name });
return await this.groupsRepository.addMembers(group.id, members);
}
async updateGroup(groupId, updateData) {
const { name } = updateData;
if (name && name.trim() === '') {
throw new ValidationError('Group name cannot be empty');
}
return await this.groupsRepository.update(groupId, { name });
}
async deleteGroup(groupId) {
return await this.groupsRepository.delete(groupId);
}
async addMembersToGroup(groupId, userIds) {
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
throw new ValidationError('User IDs are required');
}
for (const userId of userIds) {
await this.usersRepository.findById(userId);
}
return await this.groupsRepository.addMembers(groupId, userIds);
}
async removeMemberFromGroup(groupId, userId) {
await this.usersRepository.findById(userId);
return await this.groupsRepository.removeMember(groupId, userId);
}
}
module.exports = GroupsService;
+36
View File
@@ -0,0 +1,36 @@
const { ValidationError } = require('../shared/errors');
class UsersService {
constructor({ usersRepository }) {
this.usersRepository = usersRepository;
}
async getAllUsers() {
return await this.usersRepository.findAll();
}
async getUserById(id) {
return await this.usersRepository.findById(id);
}
async updateUserProfile(id, updateData) {
const { name, email, avatarUrl } = updateData;
if (name && name.trim() === '') {
throw new ValidationError('Name cannot be empty');
}
if (email && email.trim() === '') {
throw new ValidationError('Email cannot be empty');
}
const dataToUpdate = {};
if (name !== undefined) dataToUpdate.name = name;
if (email !== undefined) dataToUpdate.email = email;
if (avatarUrl !== undefined) dataToUpdate.avatarUrl = avatarUrl;
return await this.usersRepository.update(id, dataToUpdate);
}
}
module.exports = UsersService;
+11
View File
@@ -0,0 +1,11 @@
class AppError extends Error {
constructor(message, statusCode, isOperational = true, errors = null) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.errors = errors;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
+55
View File
@@ -0,0 +1,55 @@
const AppError = require('./AppError');
const config = require('../../config');
class ValidationError extends AppError {
constructor(message, errors = null) {
super(message, config.HTTP_STATUS.BAD_REQUEST, true, errors);
this.name = 'ValidationError';
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, config.HTTP_STATUS.NOT_FOUND, true);
this.name = 'NotFoundError';
}
}
class ConflictError extends AppError {
constructor(message, errors = null) {
super(message, config.HTTP_STATUS.CONFLICT, true, errors);
this.name = 'ConflictError';
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, config.HTTP_STATUS.UNAUTHORIZED, true);
this.name = 'UnauthorizedError';
}
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, config.HTTP_STATUS.FORBIDDEN, true);
this.name = 'ForbiddenError';
}
}
class DatabaseError extends AppError {
constructor(message = 'Database operation failed', originalError = null) {
super(message, config.HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
this.name = 'DatabaseError';
this.originalError = originalError;
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
ConflictError,
UnauthorizedError,
ForbiddenError,
DatabaseError,
};
+75
View File
@@ -0,0 +1,75 @@
const config = require('../config');
class ApiResponse {
constructor(success, statusCode, data = null, message = null, errors = null) {
this.success = success;
this.statusCode = statusCode;
this.data = data;
this.message = message;
this.errors = errors;
this.timestamp = new Date().toISOString();
}
toJSON() {
const response = {
success: this.success,
timestamp: this.timestamp,
};
if (this.message) response.message = this.message;
if (this.data !== null) response.data = this.data;
if (this.errors) response.errors = this.errors;
return response;
}
}
class ResponseHandler {
static success(res, data, message = 'Success', statusCode = config.HTTP_STATUS.OK) {
const response = new ApiResponse(true, statusCode, data, message);
return res.status(statusCode).json(response.toJSON());
}
static created(res, data, message = 'Resource created successfully') {
return this.success(res, data, message, config.HTTP_STATUS.CREATED);
}
static error(
res,
message,
statusCode = config.HTTP_STATUS.INTERNAL_SERVER_ERROR,
errors = null
) {
const response = new ApiResponse(false, statusCode, null, message, errors);
return res.status(statusCode).json(response.toJSON());
}
static badRequest(res, message = 'Bad request', errors = null) {
return this.error(res, message, config.HTTP_STATUS.BAD_REQUEST, errors);
}
static notFound(res, message = 'Resource not found') {
return this.error(res, message, config.HTTP_STATUS.NOT_FOUND);
}
static unauthorized(res, message = 'Unauthorized') {
return this.error(res, message, config.HTTP_STATUS.UNAUTHORIZED);
}
static forbidden(res, message = 'Forbidden') {
return this.error(res, message, config.HTTP_STATUS.FORBIDDEN);
}
static conflict(res, message = 'Resource conflict', errors = null) {
return this.error(res, message, config.HTTP_STATUS.CONFLICT, errors);
}
static internalError(res, message = 'Internal server error', error = null) {
if (error && config.isDevelopment) {
console.error('Internal Error:', error);
}
return this.error(res, message, config.HTTP_STATUS.INTERNAL_SERVER_ERROR);
}
}
module.exports = ResponseHandler;
@@ -0,0 +1,49 @@
const Joi = require('joi');
const config = require('../../../config');
const participantSchema = Joi.object({
user: Joi.number().integer().positive().required(),
role: Joi.string()
.valid(...Object.values(config.PARTICIPANT_ROLES))
.required(),
});
const createBetSchema = Joi.object({
title: Joi.string().trim().min(1).max(200).required().messages({
'string.empty': 'Bet title is required',
'string.max': 'Bet title must not exceed 200 characters',
}),
description: Joi.string().trim().max(1000).optional().allow(''),
stake: Joi.string().trim().min(1).required().messages({
'string.empty': 'Bet stake is required',
}),
participants: Joi.array().items(participantSchema).min(1).required().messages({
'array.min': 'At least one participant is required',
}),
isPublic: Joi.boolean().optional().default(false),
groupId: Joi.string().optional().allow(null),
});
const updateBetSchema = Joi.object({
title: Joi.string().trim().min(1).max(200).optional(),
description: Joi.string().trim().max(1000).optional().allow(''),
stake: Joi.string().trim().min(1).optional(),
status: Joi.string()
.valid(...Object.values(config.BET_STATUS))
.optional(),
endDate: Joi.date().optional(),
winner: Joi.number().integer().positive().optional().allow(null),
});
const declareWinnerSchema = Joi.object({
winnerId: Joi.number().integer().positive().required().messages({
'number.base': 'Winner ID must be a number',
'any.required': 'Winner ID is required',
}),
});
module.exports = {
createBetSchema,
updateBetSchema,
declareWinnerSchema,
};
@@ -0,0 +1,11 @@
const Joi = require('joi');
const sendFriendRequestSchema = Joi.object({
identifier: Joi.alternatives().try(Joi.number().integer().positive(), Joi.string().min(1)).required().messages({
'any.required': 'User identifier (ID, name, or email) is required',
}),
});
module.exports = {
sendFriendRequestSchema,
};
@@ -0,0 +1,29 @@
const Joi = require('joi');
const createGroupSchema = Joi.object({
name: Joi.string().trim().min(1).max(100).required().messages({
'string.empty': 'Group name is required',
'string.min': 'Group name must be at least 1 character',
'string.max': 'Group name must not exceed 100 characters',
}),
members: Joi.array().items(Joi.number().integer().positive()).min(1).required().messages({
'array.min': 'At least one member is required',
'array.base': 'Members must be an array',
}),
});
const updateGroupSchema = Joi.object({
name: Joi.string().trim().min(1).max(100).optional(),
});
const addMembersSchema = Joi.object({
userIds: Joi.array().items(Joi.number().integer().positive()).min(1).required().messages({
'array.min': 'At least one user ID is required',
}),
});
module.exports = {
createGroupSchema,
updateGroupSchema,
addMembersSchema,
};
@@ -0,0 +1,11 @@
const Joi = require('joi');
const updateUserSchema = Joi.object({
name: Joi.string().trim().min(1).max(100).optional(),
email: Joi.string().email().optional(),
avatarUrl: Joi.string().uri().optional().allow('', null),
});
module.exports = {
updateUserSchema,
};
+43
View File
@@ -0,0 +1,43 @@
// Questo helper necessita del modello User da Sequelize
module.exports = (sequelize) => {
const { User } = sequelize.models;
/**
* Funzione helper per arricchire i documenti delle scommesse da MongoDB con i dati utente da PostgreSQL.
* @param {Array} bets - Un array di documenti di scommesse Mongoose.
* @returns {Array} Un array di oggetti scommessa con i dettagli utente popolati.
*/
const populateBetsWithUsers = async (bets) => {
if (!bets || bets.length === 0) return [];
// Raccoglie tutti gli ID utente unici da tutte le scommesse
const userIds = new Set();
bets.forEach(bet => {
bet.participants.forEach(p => userIds.add(p.user));
if (bet.winner) {
userIds.add(bet.winner);
}
});
if (userIds.size === 0) return bets.map(b => b.toJSON());
// Recupera tutti gli utenti necessari da PostgreSQL con una singola query
const users = await User.findAll({ where: { id: Array.from(userIds) } });
const userMap = new Map(users.map(u => [u.id, u.toJSON()]));
// Unisce i dati utente negli oggetti scommessa
return bets.map(bet => {
const betJson = bet.toJSON();
betJson.participants = betJson.participants.map(p => ({
...p,
user: userMap.get(p.user) || null
}));
if (betJson.winner) {
betJson.winner = userMap.get(betJson.winner) || null;
}
return betJson;
});
};
return { populateBetsWithUsers };
};