Initial commit

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