Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
dist/
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -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/)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
Generated
+4780
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user