SOGOMS v1.0.1 - Microservices logs, smtp et roadmap
Nouveaux services:
- sogoms-logs : logging centralisé avec rotation
- sogoms-smtp : envoi emails avec templates YAML
Nouvelles fonctionnalités:
- Queries YAML externalisées (config/queries/{app}/)
- CRUD générique paramétrable
- Filtres par rôle (default, admin)
- Templates email (config/emails/{app}/)
Documentation:
- DOCTECH.md : documentation technique complète
- README.md : vision et roadmap
- TODO.md : phases 11-15 planifiées
Roadmap:
- Phase 11: sogoms-crypt (chiffrement)
- Phase 12: sogoms-imap/mailproc (emails)
- Phase 13: sogoms-cron (tâches planifiées)
- Phase 14: sogoms-push (MQTT temps réel)
- Phase 15: sogoms-schema (API auto-générée)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
561
DOCTECH.md
Normal file
561
DOCTECH.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# SOGOMS - Documentation Technique
|
||||
|
||||
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
|
||||
|
||||
Version: 1.0.1
|
||||
Date: 16 décembre 2025
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Architecture](#architecture)
|
||||
2. [Services](#services)
|
||||
3. [Communication IPC](#communication-ipc)
|
||||
4. [Configuration](#configuration)
|
||||
5. [API REST](#api-rest)
|
||||
6. [Système de Queries](#système-de-queries)
|
||||
7. [Authentification](#authentification)
|
||||
8. [Déploiement](#déploiement)
|
||||
9. [Structure du projet](#structure-du-projet)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client → Nginx(:443) → Sogoway(:8080) → Sogoms-* → MariaDB
|
||||
↓
|
||||
Unix Sockets
|
||||
/run/sogoms-*.sock
|
||||
```
|
||||
|
||||
### Flux de données
|
||||
|
||||
1. **Client** envoie requête HTTPS vers Nginx
|
||||
2. **Nginx** route `/api/*` vers sogoway:8080
|
||||
3. **Sogoway** identifie l'app par hostname, valide JWT, route vers le bon service
|
||||
4. **Sogoms-db** exécute les requêtes SQL
|
||||
5. **Sogoms-logs** enregistre événements et erreurs
|
||||
|
||||
### Principes
|
||||
|
||||
- **Modularité** : 1 feature = 1 binaire Go
|
||||
- **Configuration YAML** : requêtes SQL externalisées, pas de recompilation
|
||||
- **Multi-tenant** : isolation par `user_id`, filtrage configurable par rôle
|
||||
- **Supervision** : sogoctl gère le cycle de vie des services
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
| Binaire | Rôle | Port/Socket |
|
||||
|---------|------|-------------|
|
||||
| `sogoctl` | Superviseur PID 1, health checks, restart auto | - |
|
||||
| `sogoway` | Gateway HTTP, auth JWT, routing CRUD | TCP :8080 |
|
||||
| `sogoms-db` | Accès MariaDB, pool par application | `/run/sogoms-db.1.sock` |
|
||||
| `sogoms-logs` | Logging centralisé, rotation auto | `/run/sogoms-logs.1.sock` |
|
||||
| `sogoms-smtp` | Envoi d'emails, templates YAML | `/run/sogoms-smtp.1.sock` |
|
||||
|
||||
### sogoctl (Superviseur)
|
||||
|
||||
- Démarre les services dans l'ordre des dépendances
|
||||
- Health checks périodiques (socket ou HTTP)
|
||||
- Redémarrage automatique en cas de crash
|
||||
- Arrêt gracieux sur SIGTERM/SIGINT
|
||||
|
||||
```yaml
|
||||
# config/sogoctl.yaml
|
||||
supervisor:
|
||||
health_interval: 10s
|
||||
restart_delay: 2s
|
||||
max_restarts: 5
|
||||
|
||||
services:
|
||||
sogoms-logs:
|
||||
binary: /opt/sogoms/bin/sogoms-logs
|
||||
args: ["-config", "/config", "-socket", "/run/sogoms-logs.1.sock"]
|
||||
health_socket: /run/sogoms-logs.1.sock
|
||||
|
||||
sogoms-db:
|
||||
binary: /opt/sogoms/bin/sogoms-db
|
||||
args: ["-config", "/config", "-socket", "/run/sogoms-db.1.sock"]
|
||||
health_socket: /run/sogoms-db.1.sock
|
||||
depends_on: [sogoms-logs]
|
||||
|
||||
sogoway:
|
||||
binary: /opt/sogoms/bin/sogoway
|
||||
args: ["-config", "/config", "-port", "8080"]
|
||||
health_url: http://localhost:8080/health
|
||||
depends_on: [sogoms-db, sogoms-logs]
|
||||
```
|
||||
|
||||
### sogoway (Gateway HTTP)
|
||||
|
||||
- Routing par hostname → charge la config de l'app
|
||||
- Authentification JWT (HS256)
|
||||
- CRUD générique paramétré par YAML
|
||||
- Logging des événements (login, register)
|
||||
|
||||
### sogoms-db (Base de données)
|
||||
|
||||
Actions disponibles :
|
||||
- `query` : SELECT multi-résultats
|
||||
- `query_one` : SELECT un résultat
|
||||
- `insert` : INSERT, retourne `insert_id`
|
||||
- `update` : UPDATE, retourne `affected_rows`
|
||||
- `delete` : DELETE, retourne `affected_rows`
|
||||
- `health` : ping DB
|
||||
|
||||
### sogoms-logs (Logging)
|
||||
|
||||
Actions disponibles :
|
||||
- `log_event` : événement applicatif (login, register, etc.)
|
||||
- `log_error` : erreur avec niveau (error, warn, info)
|
||||
- `health` : statut OK
|
||||
|
||||
Fichiers générés : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log`
|
||||
|
||||
Format : JSON, une ligne par entrée
|
||||
|
||||
Rotation : suppression automatique des fichiers > 30 jours (configurable)
|
||||
|
||||
### sogoms-smtp (Emails)
|
||||
|
||||
Actions disponibles :
|
||||
- `send` : envoi email simple (to, subject, body, body_html)
|
||||
- `send_template` : envoi avec template YAML
|
||||
- `send_bulk` : envoi en masse (tableau de destinataires)
|
||||
- `health` : statut OK
|
||||
|
||||
Configuration SMTP dans `config/routes/{app}.yaml` :
|
||||
```yaml
|
||||
smtp:
|
||||
host: mail.example.com
|
||||
port: 587
|
||||
user: noreply@example.com
|
||||
password_file: /secrets/{app}_smtp_pass
|
||||
from: noreply@example.com
|
||||
from_name: Mon App
|
||||
tls: false # false = STARTTLS (587), true = TLS (465)
|
||||
```
|
||||
|
||||
Templates dans `config/emails/{app}/*.yaml` :
|
||||
```yaml
|
||||
subject: "Bienvenue {{.Name}} !"
|
||||
body: |
|
||||
Bonjour {{.Name}},
|
||||
Bienvenue sur notre plateforme.
|
||||
body_html: |
|
||||
<h1>Bienvenue {{.Name}} !</h1>
|
||||
<p>Bienvenue sur notre plateforme.</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Communication IPC
|
||||
|
||||
Protocole Unix socket avec messages JSON length-prefixed :
|
||||
|
||||
```
|
||||
[4 bytes: longueur BigEndian] [payload JSON]
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req_20251216123456.000000",
|
||||
"action": "query",
|
||||
"params": {
|
||||
"app_id": "prokov",
|
||||
"query": "SELECT * FROM users WHERE id = ?",
|
||||
"args": [1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (succès)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req_...",
|
||||
"status": "success",
|
||||
"result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Response (erreur)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req_...",
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": "DB_ERROR",
|
||||
"message": "connection refused"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
config/
|
||||
├── sogoctl.yaml # Superviseur
|
||||
├── routes/
|
||||
│ └── prokov.yaml # Config app (DB, auth, routes)
|
||||
└── queries/
|
||||
└── prokov/
|
||||
├── auth.yaml # Requêtes authentification
|
||||
├── projects.yaml # CRUD projects
|
||||
├── tasks.yaml # CRUD tasks
|
||||
├── tags.yaml # CRUD tags
|
||||
└── statuses.yaml # CRUD statuses
|
||||
```
|
||||
|
||||
### Config application (routes/prokov.yaml)
|
||||
|
||||
```yaml
|
||||
app: prokov
|
||||
version: "1.0"
|
||||
base_path: /api
|
||||
|
||||
hosts:
|
||||
- prokov.unikoffice.com
|
||||
- prokov.sogoms.com
|
||||
|
||||
database:
|
||||
host: 13.23.33.4
|
||||
port: 3306
|
||||
user: prokov_user
|
||||
password_file: /secrets/prokov_db_pass
|
||||
name: prokov
|
||||
|
||||
auth:
|
||||
jwt_secret_file: /secrets/prokov_jwt_secret
|
||||
jwt_expiry: 24h
|
||||
|
||||
logs:
|
||||
retention_days: 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API REST
|
||||
|
||||
### Authentification
|
||||
|
||||
| Méthode | Endpoint | Auth | Description |
|
||||
|---------|----------|------|-------------|
|
||||
| POST | `/api/auth/register` | Non | Inscription |
|
||||
| POST | `/api/auth/login` | Non | Connexion |
|
||||
| POST | `/api/auth/logout` | Oui | Déconnexion |
|
||||
| GET | `/api/auth/me` | Oui | User courant |
|
||||
|
||||
### Login - Réponse enrichie
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Connexion réussie",
|
||||
"data": {
|
||||
"token": "eyJ...",
|
||||
"user": { "id": 1, "email": "...", "name": "..." },
|
||||
"projects": [...],
|
||||
"tasks": [...],
|
||||
"tags": [...],
|
||||
"statuses": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CRUD Générique
|
||||
|
||||
| Méthode | Endpoint | Action |
|
||||
|---------|----------|--------|
|
||||
| GET | `/api/{resource}` | Liste |
|
||||
| GET | `/api/{resource}/{id}` | Détail |
|
||||
| POST | `/api/{resource}` | Création |
|
||||
| PUT | `/api/{resource}/{id}` | Modification |
|
||||
| DELETE | `/api/{resource}/{id}` | Suppression |
|
||||
|
||||
Resources disponibles : `projects`, `tasks`, `tags`, `statuses`
|
||||
|
||||
### Réponses standardisées
|
||||
|
||||
**Succès :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Created",
|
||||
"data": { "insert_id": 123 }
|
||||
}
|
||||
```
|
||||
|
||||
**Erreur :**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Token expired"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Système de Queries
|
||||
|
||||
Les requêtes SQL sont externalisées dans des fichiers YAML pour éviter la recompilation.
|
||||
|
||||
### Structure Read (list/show)
|
||||
|
||||
```yaml
|
||||
list:
|
||||
query: >
|
||||
SELECT id, name, description FROM projects
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "position ASC"
|
||||
|
||||
show:
|
||||
query: >
|
||||
SELECT id, name, description FROM projects WHERE id = :id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
```
|
||||
|
||||
### Structure CUD (create/update/delete)
|
||||
|
||||
```yaml
|
||||
create:
|
||||
table: projects
|
||||
fields:
|
||||
- user_id
|
||||
- name
|
||||
- description
|
||||
- position
|
||||
|
||||
update:
|
||||
table: projects
|
||||
fields:
|
||||
- name
|
||||
- description
|
||||
- position
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
delete:
|
||||
table: projects
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
```
|
||||
|
||||
### Placeholders
|
||||
|
||||
- `:user_id` → ID utilisateur depuis JWT
|
||||
- `:id` → ID ressource depuis URL
|
||||
- `:project_id` → ID projet (pour filtres)
|
||||
|
||||
### Filtres par rôle
|
||||
|
||||
Le filtre appliqué dépend du rôle de l'utilisateur :
|
||||
- `default` : utilisateur standard, filtré par user_id
|
||||
- `admin` : pas de filtre, voit tout
|
||||
|
||||
---
|
||||
|
||||
## Authentification
|
||||
|
||||
### JWT (JSON Web Token)
|
||||
|
||||
- Algorithme : HS256
|
||||
- Expiration : configurable (défaut 24h)
|
||||
- Secret : stocké dans fichier (`/secrets/{app}_jwt_secret`)
|
||||
|
||||
### Payload JWT
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": 1,
|
||||
"email": "user@example.com",
|
||||
"name": "User Name",
|
||||
"app": "prokov",
|
||||
"exp": 1765959278,
|
||||
"iat": 1765872878
|
||||
}
|
||||
```
|
||||
|
||||
### Header HTTP
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Passwords hashés avec bcrypt
|
||||
- JWT stateless (pas de stockage serveur)
|
||||
- Secrets dans fichiers séparés (pas dans YAML)
|
||||
- Filtrage user_id automatique sur toutes les requêtes CRUD
|
||||
|
||||
---
|
||||
|
||||
## Déploiement
|
||||
|
||||
### Script deploy.sh
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Étapes :
|
||||
1. Build des binaires (linux/amd64)
|
||||
2. Création des archives tar.gz
|
||||
3. Copie vers jump server (IN3)
|
||||
4. Déploiement dans container Incus (gw3)
|
||||
5. Backup local des archives
|
||||
6. Redémarrage automatique de sogoctl
|
||||
|
||||
### Cible
|
||||
|
||||
- **Jump host** : IN3 (195.154.80.116)
|
||||
- **Container** : gw3 (Alpine, 13.23.33.5)
|
||||
- **Binaires** : `/opt/sogoms/bin/`
|
||||
- **Config** : `/config/`
|
||||
- **Secrets** : `/secrets/`
|
||||
- **Logs** : `/var/log/sogoms/`
|
||||
|
||||
### Commandes manuelles sur gw3
|
||||
|
||||
```bash
|
||||
# Voir les logs
|
||||
tail -f /var/log/sogoms/sogoctl.log
|
||||
|
||||
# Lister les processus
|
||||
pgrep -la sogo
|
||||
|
||||
# Redémarrer
|
||||
pkill -9 sogo && /opt/sogoms/bin/sogoctl &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
sogoms/
|
||||
├── cmd/
|
||||
│ ├── sogoctl/main.go # Superviseur
|
||||
│ ├── sogoway/main.go # Gateway HTTP
|
||||
│ └── sogoms/
|
||||
│ ├── db/main.go # Microservice DB
|
||||
│ ├── logs/main.go # Microservice Logs
|
||||
│ └── smtp/main.go # Microservice SMTP
|
||||
├── internal/
|
||||
│ ├── protocol/
|
||||
│ │ ├── message.go # Structs Request/Response
|
||||
│ │ ├── server.go # Serveur Unix socket
|
||||
│ │ └── client.go # Client + Pool connexions
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Registry, Queries, CUD
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.go # Génération/validation JWT
|
||||
│ │ └── password.go # Hash bcrypt
|
||||
│ └── version/
|
||||
│ └── version.go # Version, BuildTime
|
||||
├── config/
|
||||
│ ├── sogoctl.yaml
|
||||
│ ├── routes/
|
||||
│ │ └── prokov.yaml # Config app (DB, auth, SMTP)
|
||||
│ ├── queries/
|
||||
│ │ └── prokov/
|
||||
│ │ ├── auth.yaml
|
||||
│ │ ├── projects.yaml
|
||||
│ │ ├── tasks.yaml
|
||||
│ │ ├── tags.yaml
|
||||
│ │ └── statuses.yaml
|
||||
│ └── emails/
|
||||
│ └── prokov/
|
||||
│ ├── welcome.yaml
|
||||
│ ├── password_reset.yaml
|
||||
│ ├── task_assigned.yaml
|
||||
│ └── tasks_today.yaml
|
||||
├── clients/
|
||||
│ └── prokov.sql # Schéma DB
|
||||
├── bin/ # Binaires compilés
|
||||
├── deploy.sh # Script déploiement
|
||||
├── VERSION # Numéro de version
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── README.md
|
||||
├── CLAUDE.md # Instructions Claude Code
|
||||
├── DOCTECH.md # Documentation technique
|
||||
└── TODO.md # Roadmap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dépendances Go
|
||||
|
||||
```
|
||||
go 1.24.0
|
||||
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Services planifiés
|
||||
|
||||
| Phase | Service | Description |
|
||||
|-------|---------|-------------|
|
||||
| 11 | `sogoms-crypt` | Chiffrement/déchiffrement données sensibles (AES-256-GCM) |
|
||||
| 12b | `sogoms-imap` | Lecture boîtes email (IMAP) |
|
||||
| 12c | `sogoms-mailproc` | Traitement emails (règles, webhooks) |
|
||||
| 13 | `sogoms-cron` | Tâches planifiées (format cron standard) |
|
||||
| 14 | `sogoms-push` | Push temps réel via MQTT (Mosquitto) |
|
||||
| 15 | `sogoms-schema` | Génération d'API depuis schéma YAML |
|
||||
|
||||
### Vision Phase 15 : Schema-Driven API
|
||||
|
||||
```yaml
|
||||
# config/schema/monapp.yaml - 1 fichier = 1 API complète
|
||||
tables:
|
||||
users:
|
||||
fields:
|
||||
id: { type: int, primary: true, auto: true }
|
||||
email: { type: string, unique: true, auth: login }
|
||||
password: { type: string, auth: password, hidden: true }
|
||||
|
||||
projects:
|
||||
fields:
|
||||
id: { type: int, primary: true, auto: true }
|
||||
user_id: { type: int, foreign: users.id, filter: owner }
|
||||
name: { type: string, required: true }
|
||||
crud: [list, show, create, update, delete]
|
||||
```
|
||||
|
||||
Génère automatiquement : routes, queries, validation, filtres user_id, auth.
|
||||
|
||||
### Hors scope V1
|
||||
|
||||
- sogorch (orchestrateur scénarios)
|
||||
- sogoms-pdf, sogoms-storage
|
||||
- Multi-tenant avancé (workspaces, partage)
|
||||
- Rate limiting
|
||||
- Rôles utilisateurs (admin, manager, user)
|
||||
117
README.md
117
README.md
@@ -2,19 +2,49 @@
|
||||
|
||||
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
|
||||
|
||||
## Architecture
|
||||
## Vision
|
||||
|
||||
SOGOMS est un framework backend léger en Go qui transforme un simple fichier de schéma YAML en API REST complète. Conçu pour héberger plusieurs applications SaaS sur une même infrastructure avec isolation totale des données.
|
||||
|
||||
```
|
||||
Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB
|
||||
↓
|
||||
Unix Socket
|
||||
schema.yaml → SOGOMS → API REST + Auth + CRUD + Push
|
||||
```
|
||||
|
||||
| Binaire | Rôle | Port/Socket |
|
||||
|---------|------|-------------|
|
||||
| `sogoctl` | Superviseur PID 1, health checks, restart auto | - |
|
||||
| `sogoway` | Gateway HTTP, auth JWT, routing par hostname | TCP :8080 |
|
||||
| `sogoms-db` | Accès MariaDB, pool par application | Unix socket |
|
||||
## Caractéristiques
|
||||
|
||||
- **Léger** : binaires Go compilés (~10MB), pas de dépendances runtime
|
||||
- **Modulaire** : 1 fonctionnalité = 1 microservice
|
||||
- **Configurable** : SQL et routing en YAML, sans recompilation
|
||||
- **Sécurisé** : JWT, isolation par user_id, bcrypt
|
||||
- **Auto-supervisé** : health checks, restart automatique
|
||||
- **Temps réel** : push MQTT vers les applications (roadmap)
|
||||
- **Schema-driven** : génération d'API depuis la structure DB (roadmap)
|
||||
|
||||
## Services actuels
|
||||
|
||||
| Service | Rôle | Statut |
|
||||
|---------|------|--------|
|
||||
| `sogoctl` | Superviseur, health checks | Stable |
|
||||
| `sogoway` | Gateway HTTP, auth JWT, CRUD | Stable |
|
||||
| `sogoms-db` | Accès MariaDB | Stable |
|
||||
| `sogoms-logs` | Logging centralisé | Stable |
|
||||
| `sogoms-smtp` | Envoi emails, templates | Stable |
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Phase | Service | Description |
|
||||
|-------|---------|-------------|
|
||||
| 11 | sogoms-crypt | Chiffrement données sensibles |
|
||||
| 12 | sogoms-imap/mailproc | Lecture et traitement emails |
|
||||
| 13 | sogoms-cron | Tâches planifiées |
|
||||
| 14 | sogoms-push | Push temps réel (MQTT) |
|
||||
| 15 | sogoms-schema | API auto-générée depuis schema |
|
||||
|
||||
## Applications
|
||||
|
||||
| Application | Description | URL |
|
||||
|-------------|-------------|-----|
|
||||
| **Prokov** | Gestion de projets et tâches | prokov.unikoffice.com |
|
||||
|
||||
## Déploiement
|
||||
|
||||
@@ -22,76 +52,17 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Déploie sur le container `gw3` (Alpine) via IN3.
|
||||
## Documentation
|
||||
|
||||
## Lancement
|
||||
|
||||
Sur gw3 :
|
||||
|
||||
```bash
|
||||
/opt/sogoms/bin/sogoctl
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Chaque application cliente a son fichier dans `config/routes/` :
|
||||
|
||||
```yaml
|
||||
# config/routes/prokov.yaml
|
||||
app: prokov
|
||||
hosts:
|
||||
- prokov.unikoffice.com
|
||||
database:
|
||||
host: 13.23.33.4
|
||||
user: prokov_user
|
||||
password_file: /secrets/prokov_db_pass
|
||||
name: prokov
|
||||
auth:
|
||||
jwt_secret_file: /secrets/prokov_jwt_secret
|
||||
jwt_expiry: 24h
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
cmd/
|
||||
sogoctl/main.go # Superviseur
|
||||
sogoway/main.go # Gateway HTTP
|
||||
sogoms/db/main.go # Microservice DB
|
||||
internal/
|
||||
protocol/ # IPC Unix socket (JSON length-prefixed)
|
||||
config/ # Chargement YAML, registry par host
|
||||
auth/ # JWT (HS256), bcrypt passwords
|
||||
config/
|
||||
sogoctl.yaml # Services à superviser
|
||||
routes/*.yaml # Config par application
|
||||
scenarios/ # Scénarios YAML (V2)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Login
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H "Host: prokov.unikoffice.com" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"secret"}'
|
||||
|
||||
# User info (avec token)
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H "Host: prokov.unikoffice.com" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
- [Documentation technique](DOCTECH.md) - Architecture, API, configuration
|
||||
- [Roadmap](TODO.md) - Suivi des tâches et évolutions
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Go 1.22+
|
||||
- MariaDB/MySQL
|
||||
- Container Alpine (gw3)
|
||||
- Container Linux (Alpine recommandé)
|
||||
|
||||
## Licence
|
||||
|
||||
Propriétaire
|
||||
Propriétaire - Tous droits réservés
|
||||
|
||||
181
TODO.md
181
TODO.md
@@ -75,18 +75,179 @@ curl https://prokov.unikoffice.com/api/auth/me \
|
||||
|
||||
## Phase 7 : Microservice Logs
|
||||
|
||||
- [ ] `cmd/sogoms/logs/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-logs.1.sock`
|
||||
- [ ] Actions `log_error`, `log_event` : écriture dans fichiers
|
||||
- [ ] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log`
|
||||
- [ ] Rotation automatique : suppression des fichiers > N jours (défaut 15)
|
||||
- [ ] Paramètre `retention_days` dans config
|
||||
- [ ] Intégration avec sogoway et sogoms-db
|
||||
- [x] `cmd/sogoms/logs/main.go` : point d'entrée
|
||||
- [x] Écoute sur Unix socket `/run/sogoms-logs.1.sock`
|
||||
- [x] Actions `log_error`, `log_event` : écriture dans fichiers
|
||||
- [x] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log`
|
||||
- [x] Rotation automatique : suppression des fichiers > 30 jours
|
||||
- [x] Paramètre `retention_days` dans config (`config/routes/prokov.yaml`)
|
||||
- [x] Intégration avec sogoway et sogoms-db
|
||||
|
||||
## Phase 8 : Système de Queries YAML
|
||||
|
||||
- [x] Structure `config/queries/{app}/*.yaml`
|
||||
- [x] Requêtes SQL externalisées (pas de recompilation)
|
||||
- [x] `internal/config/config.go` : QueryConfig, GetQuery(), Build()
|
||||
- [x] Placeholders `:user_id`, `:id`, etc.
|
||||
- [x] Filtres par rôle (default, admin)
|
||||
- [x] Login enrichi : charge projects, tasks, tags, statuses
|
||||
|
||||
## Phase 9 : CRUD Générique
|
||||
|
||||
- [x] Routing `/api/{resource}` dans sogoway
|
||||
- [x] GET list/show avec filtres YAML
|
||||
- [x] POST create avec fields YAML
|
||||
- [x] PUT update avec fields + filtres YAML
|
||||
- [x] DELETE avec filtres YAML
|
||||
- [x] Config YAML pour projects, tasks, tags, statuses
|
||||
- [x] Sécurité : filtre user_id automatique
|
||||
|
||||
## Phase 10 : Améliorations Deploy
|
||||
|
||||
- [x] `deploy.sh` : build sogoms-logs
|
||||
- [x] `deploy.sh` : backup archives dans `/home/pierre/samba/back/sogoms/`
|
||||
- [x] `deploy.sh` : redémarrage auto sogoctl
|
||||
- [x] `deploy.sh` : kill propre des processus zombies
|
||||
- [x] Documentation `DOCTECH.md`
|
||||
- [x] Version 1.0.1
|
||||
|
||||
## Phase 11 : Microservice Crypt
|
||||
|
||||
- [ ] `cmd/sogoms/crypt/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-crypt.1.sock`
|
||||
- [ ] Action `encrypt` : chiffre une donnée (AES-256-GCM)
|
||||
- [ ] Action `decrypt` : déchiffre une donnée
|
||||
- [ ] Action `hash` : hash irréversible (SHA-256)
|
||||
- [ ] Clé de chiffrement par application (`/secrets/{app}_crypt_key`)
|
||||
- [ ] Intégration avec sogoway pour champs sensibles
|
||||
- [ ] Config YAML : liste des champs à chiffrer par table
|
||||
- [ ] Application Prokov : chiffrement `users.email`
|
||||
|
||||
## Phase 12 : Microservices Email
|
||||
|
||||
### 12a. sogoms-smtp (Envoi)
|
||||
|
||||
- [x] `cmd/sogoms/smtp/main.go` : point d'entrée
|
||||
- [x] Écoute sur Unix socket `/run/sogoms-smtp.1.sock`
|
||||
- [x] Action `send` : envoi email simple (to, subject, body, html)
|
||||
- [x] Action `send_template` : envoi avec template YAML
|
||||
- [x] Action `send_bulk` : envoi en masse (liste de destinataires)
|
||||
- [x] Config SMTP par application (`config/routes/{app}.yaml`)
|
||||
- [x] Support TLS/STARTTLS
|
||||
- [x] Templates YAML (`config/emails/{app}/*.yaml`)
|
||||
- [ ] Queue d'envoi avec retry en cas d'échec
|
||||
|
||||
### 12b. sogoms-imap (Lecture)
|
||||
|
||||
- [ ] `cmd/sogoms/imap/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-imap.1.sock`
|
||||
- [ ] Action `list` : liste les emails (folder, limit, offset)
|
||||
- [ ] Action `fetch` : récupère un email complet (uid)
|
||||
- [ ] Action `delete` : supprime un email
|
||||
- [ ] Action `mark_read` : marque comme lu
|
||||
- [ ] Action `move` : déplace vers un autre dossier
|
||||
- [ ] Config IMAP par application (`config/routes/{app}.yaml`)
|
||||
- [ ] Support IMAP IDLE pour notifications temps réel
|
||||
|
||||
### 12c. sogoms-mailproc (Traitement)
|
||||
|
||||
- [ ] `cmd/sogoms/mailproc/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-mailproc.1.sock`
|
||||
- [ ] Action `parse` : parse un email (headers, body, attachments)
|
||||
- [ ] Action `apply_rules` : applique des règles configurées
|
||||
- [ ] Config YAML : règles par application (`config/mailrules/{app}.yaml`)
|
||||
- [ ] Webhooks : notification vers URL externe
|
||||
- [ ] Intégration Prokov : email entrant → création de tâche
|
||||
|
||||
## Phase 13 : Microservice Cron
|
||||
|
||||
- [ ] `cmd/sogoms/cron/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock`
|
||||
- [ ] Config YAML par application (`config/cron/{app}.yaml`)
|
||||
- [ ] Parser cron schedule (format standard `* * * * *`)
|
||||
- [ ] Action `list` : liste les jobs configurés
|
||||
- [ ] Action `trigger` : déclenche un job manuellement
|
||||
- [ ] Action `status` : statut des dernières exécutions
|
||||
- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.)
|
||||
- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe
|
||||
- [ ] Type `query_email` : requête DB + envoi email avec résultat
|
||||
- [ ] Logging des exécutions dans sogoms-logs
|
||||
- [ ] Application Prokov : email quotidien `tasks_today`
|
||||
|
||||
## Phase 14 : Push Temps Réel (MQTT)
|
||||
|
||||
### 14a. Infrastructure Mosquitto
|
||||
|
||||
- [ ] Installation Mosquitto sur gw3 (Alpine: `apk add mosquitto`)
|
||||
- [ ] Config `/etc/mosquitto/mosquitto.conf`
|
||||
- [ ] Auth par user/password ou plugin JWT
|
||||
- [ ] Port 1883 (MQTT) + 9001 (WebSocket)
|
||||
- [ ] TLS optionnel pour production
|
||||
|
||||
### 14b. sogoms-push
|
||||
|
||||
- [ ] `cmd/sogoms/push/main.go` : point d'entrée
|
||||
- [ ] Écoute sur Unix socket `/run/sogoms-push.1.sock`
|
||||
- [ ] Connexion au broker MQTT
|
||||
- [ ] Config YAML par application (`config/push/{app}.yaml`)
|
||||
- [ ] Action `publish` : publie un message sur un topic
|
||||
- [ ] Action `notify_user` : publie vers `{app}/user/{user_id}/{channel}`
|
||||
- [ ] Action `broadcast` : publie vers tous les users d'une app
|
||||
- [ ] Topics : notifications, tasks, projects, comments
|
||||
- [ ] Intégration sogoway : publish auto sur événements CRUD
|
||||
|
||||
### 14c. Intégration Flutter
|
||||
|
||||
- [ ] Package `mqtt_client` dans Prokov Flutter
|
||||
- [ ] Service MqttService : connexion, reconnexion auto
|
||||
- [ ] Subscription aux topics user
|
||||
- [ ] Mise à jour state en temps réel
|
||||
- [ ] Notifications in-app
|
||||
|
||||
## Phase 15 : Schema-Driven API (Socle SOGOMS)
|
||||
|
||||
Cette phase transforme SOGOMS en générateur d'API automatique.
|
||||
|
||||
### 15a. Définition du Schema
|
||||
|
||||
- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations
|
||||
- [ ] Types supportés : int, string, text, bool, date, datetime, json
|
||||
- [ ] Contraintes : primary, auto, unique, required, default
|
||||
- [ ] Relations : foreign key avec `foreign: table.field`
|
||||
- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id
|
||||
- [ ] Auth : `auth: login`, `auth: password` pour détection auto
|
||||
- [ ] CRUD : liste des opérations autorisées par table
|
||||
- [ ] Filtres custom : définition de filtres nommés
|
||||
|
||||
### 15b. sogoms-schema (Générateur)
|
||||
|
||||
- [ ] `cmd/sogoms/schema/main.go` : outil CLI
|
||||
- [ ] Commande `generate {app}` : génère queries YAML depuis schema
|
||||
- [ ] Commande `validate {app}` : valide le schema
|
||||
- [ ] Commande `diff {app}` : compare schema vs DB réelle
|
||||
- [ ] Commande `migrate {app}` : génère SQL de migration
|
||||
- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse)
|
||||
|
||||
### 15c. Runtime Dynamique (sogoway)
|
||||
|
||||
- [ ] Chargement schema au démarrage
|
||||
- [ ] Routes CRUD auto-générées depuis schema
|
||||
- [ ] Validation des inputs selon types/contraintes
|
||||
- [ ] Filtrage user_id automatique (filter: owner)
|
||||
- [ ] Gestion relations (include, nested)
|
||||
- [ ] Pas de fichiers queries YAML requis (optionnels pour override)
|
||||
|
||||
### 15d. Dictionnaire de Données
|
||||
|
||||
- [ ] Endpoint `/api/_schema` : expose le schema (pour admin/debug)
|
||||
- [ ] Endpoint `/api/_schema/{table}` : détail d'une table
|
||||
- [ ] Documentation auto-générée
|
||||
- [ ] Utilisable par Flutter pour génération de formulaires
|
||||
|
||||
## Hors scope V1
|
||||
|
||||
- sogorch (orchestrateur scénarios)
|
||||
- sogoms-pdf, sogoms-email, sogoms-storage
|
||||
- Multi-tenant
|
||||
- sogoms-pdf, sogoms-storage
|
||||
- Multi-tenant avancé (workspaces, partage)
|
||||
- Rate limiting
|
||||
- Exécution dynamique des scénarios YAML
|
||||
- Rôles utilisateurs (admin, manager, user)
|
||||
|
||||
202
clients/prokov.sql
Normal file
202
clients/prokov.sql
Normal file
@@ -0,0 +1,202 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19-11.8.3-MariaDB, for debian-linux-gnu (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: prokov
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 11.4.8-MariaDB-log
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `project_tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `project_tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `project_tags` (
|
||||
`project_id` int(10) unsigned NOT NULL,
|
||||
`tag_id` int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (`project_id`,`tag_id`),
|
||||
KEY `tag_id` (`tag_id`),
|
||||
CONSTRAINT `project_tags_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `project_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `projects`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `projects`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `projects` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`parent_id` int(10) unsigned DEFAULT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `parent_id` (`parent_id`),
|
||||
KEY `idx_projects_parent` (`user_id`,`parent_id`),
|
||||
CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `statuses`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `statuses`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `statuses` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`project_id` int(10) unsigned DEFAULT NULL,
|
||||
`code` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#6B7280',
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_statuses_user_project` (`user_id`,`project_id`),
|
||||
CONSTRAINT `statuses_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tags` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#3B82F6',
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
|
||||
CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `task_tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `task_tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `task_tags` (
|
||||
`task_id` int(10) unsigned NOT NULL,
|
||||
`tag_id` int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (`task_id`,`tag_id`),
|
||||
KEY `tag_id` (`tag_id`),
|
||||
CONSTRAINT `task_tags_ibfk_1` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `task_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tasks` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`project_id` int(10) unsigned NOT NULL,
|
||||
`status_id` int(10) unsigned NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`priority` tinyint(3) unsigned DEFAULT 5,
|
||||
`date_start` date DEFAULT NULL,
|
||||
`date_end` date DEFAULT NULL,
|
||||
`time_estimated` int(10) unsigned DEFAULT 0,
|
||||
`time_spent` int(10) unsigned DEFAULT 0,
|
||||
`billing` decimal(10,2) DEFAULT 0.00,
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `status_id` (`status_id`),
|
||||
KEY `idx_tasks_project_status` (`project_id`,`status_id`),
|
||||
KEY `idx_tasks_user_status` (`user_id`,`status_id`),
|
||||
KEY `idx_tasks_dates` (`date_start`,`date_end`),
|
||||
CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `users` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`role_id` int(10) unsigned NOT NULL DEFAULT 1,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email` (`email`),
|
||||
KEY `role_id` (`role_id`),
|
||||
CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `users_roles` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users_roles`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users_roles`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `users_roles` (
|
||||
`id` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'prokov'
|
||||
--
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
|
||||
|
||||
-- Dump completed on 2025-12-16 10:58:46
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Connexion à la base de données (Singleton)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
try {
|
||||
self::$instance = new PDO(
|
||||
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
|
||||
DB_USER,
|
||||
DB_PASS,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
Response::error('Database connection failed', 500);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
// Empêcher le clonage et la désérialisation
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
public function __wakeup() {}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Configuration de l'application
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Environnement : 'dev' ou 'prod'
|
||||
define('APP_ENV', 'dev');
|
||||
|
||||
// Base de données
|
||||
define('DB_HOST', '13.23.33.4'); // container incus maria3
|
||||
define('DB_NAME', 'prokov');
|
||||
define('DB_USER', 'prokov_user');
|
||||
define('DB_PASS', 'CHANGE_ME_PASSWORD');
|
||||
|
||||
// Session
|
||||
define('SESSION_LIFETIME', 86400 * 7); // 7 jours
|
||||
|
||||
// Debug
|
||||
if (APP_ENV === 'dev') {
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
} else {
|
||||
error_reporting(0);
|
||||
ini_set('display_errors', '0');
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur d'authentification
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /auth/register
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'email' => 'required|email|max:255',
|
||||
'password' => 'required|min:6|max:255',
|
||||
'name' => 'required|min:2|max:100',
|
||||
]);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Vérifier si l'email existe déjà
|
||||
$stmt = $db->prepare('SELECT id FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $data['email']]);
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
Response::error('Cet email est déjà utilisé', 409);
|
||||
}
|
||||
|
||||
// Créer l'utilisateur
|
||||
$hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO users (email, password, name)
|
||||
VALUES (:email, :password, :name)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'email' => $data['email'],
|
||||
'password' => $hashedPassword,
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
|
||||
$userId = (int) $db->lastInsertId();
|
||||
|
||||
// Créer les statuts par défaut pour ce nouvel utilisateur
|
||||
$this->createDefaultStatuses($userId);
|
||||
|
||||
// Créer une session
|
||||
$sessionId = Session::create($userId);
|
||||
|
||||
Response::success([
|
||||
'session_id' => $sessionId,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'email' => $data['email'],
|
||||
'name' => $data['name'],
|
||||
],
|
||||
], 'Inscription réussie', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/login
|
||||
*/
|
||||
public function login(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('SELECT id, email, name, password FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $data['email']]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($data['password'], $user['password'])) {
|
||||
Response::error('Email ou mot de passe incorrect', 401);
|
||||
}
|
||||
|
||||
// Créer une session
|
||||
$sessionId = Session::create($user['id']);
|
||||
|
||||
Response::success([
|
||||
'session_id' => $sessionId,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'name' => $user['name'],
|
||||
],
|
||||
], 'Connexion réussie');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
$sessionId = $this->request->getSessionId();
|
||||
|
||||
if ($sessionId) {
|
||||
Session::destroy($sessionId);
|
||||
}
|
||||
|
||||
Response::success(null, 'Déconnexion réussie');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
*/
|
||||
public function me(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
Response::success([
|
||||
'user' => $this->user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer les statuts par défaut pour un nouvel utilisateur
|
||||
*/
|
||||
private function createDefaultStatuses(int $userId): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$defaultStatuses = [
|
||||
['code' => 10, 'name' => 'Backlog', 'color' => '#6B7280', 'position' => 10],
|
||||
['code' => 20, 'name' => 'À faire', 'color' => '#3B82F6', 'position' => 20],
|
||||
['code' => 30, 'name' => 'En cours', 'color' => '#F59E0B', 'position' => 30],
|
||||
['code' => 40, 'name' => 'À tester', 'color' => '#8B5CF6', 'position' => 40],
|
||||
['code' => 50, 'name' => 'Livré', 'color' => '#10B981', 'position' => 50],
|
||||
['code' => 60, 'name' => 'Terminé', 'color' => '#059669', 'position' => 60],
|
||||
['code' => 70, 'name' => 'Archivé', 'color' => '#9CA3AF', 'position' => 70],
|
||||
];
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO statuses (user_id, project_id, code, name, color, position)
|
||||
VALUES (:user_id, NULL, :code, :name, :color, :position)
|
||||
');
|
||||
|
||||
foreach ($defaultStatuses as $status) {
|
||||
$stmt->execute([
|
||||
'user_id' => $userId,
|
||||
'code' => $status['code'],
|
||||
'name' => $status['name'],
|
||||
'color' => $status['color'],
|
||||
'position' => $status['position'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur des projets
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /projects
|
||||
* Liste tous les projets de l'utilisateur (arborescence)
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Récupérer tous les projets de l'utilisateur
|
||||
$stmt = $db->prepare('
|
||||
SELECT p.*,
|
||||
GROUP_CONCAT(t.id) as tag_ids,
|
||||
GROUP_CONCAT(t.name) as tag_names
|
||||
FROM projects p
|
||||
LEFT JOIN project_tags pt ON p.id = pt.project_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
WHERE p.user_id = :user_id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.parent_id ASC, p.position ASC, p.name ASC
|
||||
');
|
||||
|
||||
$stmt->execute(['user_id' => $this->getUserId()]);
|
||||
$projects = $stmt->fetchAll();
|
||||
|
||||
// Construire l'arborescence
|
||||
$tree = $this->buildTree($projects);
|
||||
|
||||
Response::success($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /projects/{id}
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$project = $this->findOrFail($id);
|
||||
|
||||
// Récupérer les tags
|
||||
$project['tags'] = $this->getProjectTags($id);
|
||||
|
||||
// Récupérer les sous-projets
|
||||
$project['children'] = $this->getChildren($id);
|
||||
|
||||
Response::success($project);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /projects
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->validate([
|
||||
'name' => 'required|min:1|max:100',
|
||||
'description' => 'max:65535',
|
||||
'parent_id' => 'int',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
// Vérifier que le parent appartient à l'utilisateur
|
||||
if (!empty($data['parent_id'])) {
|
||||
$this->findOrFail((int) $data['parent_id']);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO projects (user_id, parent_id, name, description, position)
|
||||
VALUES (:user_id, :parent_id, :name, :description, :position)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'user_id' => $this->getUserId(),
|
||||
'parent_id' => $data['parent_id'] ?: null,
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'position' => $data['position'] ?? 0,
|
||||
]);
|
||||
|
||||
$projectId = (int) $db->lastInsertId();
|
||||
|
||||
// Gérer les tags si fournis
|
||||
$tags = $this->request->get('tags');
|
||||
if (is_array($tags)) {
|
||||
$this->syncTags($projectId, $tags);
|
||||
}
|
||||
|
||||
$project = $this->findOrFail($projectId);
|
||||
$project['tags'] = $this->getProjectTags($projectId);
|
||||
|
||||
Response::success($project, 'Projet créé', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /projects/{id}
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$data = $this->validate([
|
||||
'name' => 'min:1|max:100',
|
||||
'description' => 'max:65535',
|
||||
'parent_id' => 'int',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
// Vérifier que le parent n'est pas le projet lui-même ou un de ses enfants
|
||||
if (!empty($data['parent_id'])) {
|
||||
$parentId = (int) $data['parent_id'];
|
||||
if ($parentId === $id) {
|
||||
Response::error('Un projet ne peut pas être son propre parent', 422);
|
||||
}
|
||||
$this->findOrFail($parentId);
|
||||
|
||||
// Vérifier que le parent n'est pas un enfant du projet
|
||||
if ($this->isDescendant($parentId, $id)) {
|
||||
Response::error('Le parent ne peut pas être un sous-projet', 422);
|
||||
}
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$fields[] = 'name = :name';
|
||||
$params['name'] = $data['name'];
|
||||
}
|
||||
|
||||
if (array_key_exists('description', $data)) {
|
||||
$fields[] = 'description = :description';
|
||||
$params['description'] = $data['description'];
|
||||
}
|
||||
|
||||
if (array_key_exists('parent_id', $data)) {
|
||||
$fields[] = 'parent_id = :parent_id';
|
||||
$params['parent_id'] = $data['parent_id'] ?: null;
|
||||
}
|
||||
|
||||
if (isset($data['position'])) {
|
||||
$fields[] = 'position = :position';
|
||||
$params['position'] = $data['position'];
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
$sql = 'UPDATE projects SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
// Gérer les tags si fournis
|
||||
$tags = $this->request->get('tags');
|
||||
if (is_array($tags)) {
|
||||
$this->syncTags($id, $tags);
|
||||
}
|
||||
|
||||
$project = $this->findOrFail($id);
|
||||
$project['tags'] = $this->getProjectTags($id);
|
||||
|
||||
Response::success($project, 'Projet mis à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /projects/{id}
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Les sous-projets et tâches seront supprimés en cascade (FK)
|
||||
$stmt = $db->prepare('DELETE FROM projects WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
Response::success(null, 'Projet supprimé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouver un projet ou retourner 404
|
||||
*/
|
||||
private function findOrFail(int $id): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT * FROM projects
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project) {
|
||||
Response::notFound('Projet non trouvé');
|
||||
}
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construire l'arborescence des projets
|
||||
*/
|
||||
private function buildTree(array $projects, ?int $parentId = null): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if ($project['parent_id'] == $parentId) {
|
||||
// Parser les tags
|
||||
$project['tags'] = [];
|
||||
if (!empty($project['tag_ids'])) {
|
||||
$ids = explode(',', $project['tag_ids']);
|
||||
$names = explode(',', $project['tag_names']);
|
||||
foreach ($ids as $i => $tagId) {
|
||||
$project['tags'][] = [
|
||||
'id' => (int) $tagId,
|
||||
'name' => $names[$i] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
unset($project['tag_ids'], $project['tag_names']);
|
||||
|
||||
// Récursion pour les enfants
|
||||
$project['children'] = $this->buildTree($projects, (int) $project['id']);
|
||||
|
||||
$tree[] = $project;
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les tags d'un projet
|
||||
*/
|
||||
private function getProjectTags(int $projectId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM tags t
|
||||
JOIN project_tags pt ON t.id = pt.tag_id
|
||||
WHERE pt.project_id = :project_id
|
||||
');
|
||||
|
||||
$stmt->execute(['project_id' => $projectId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les sous-projets directs
|
||||
*/
|
||||
private function getChildren(int $projectId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT * FROM projects
|
||||
WHERE parent_id = :parent_id AND user_id = :user_id
|
||||
ORDER BY position ASC, name ASC
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'parent_id' => $projectId,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un projet est un descendant d'un autre
|
||||
*/
|
||||
private function isDescendant(int $projectId, int $ancestorId): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT parent_id FROM projects
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $projectId,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
$project = $stmt->fetch();
|
||||
|
||||
if (!$project || $project['parent_id'] === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $project['parent_id'] === $ancestorId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isDescendant((int) $project['parent_id'], $ancestorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchroniser les tags d'un projet
|
||||
*/
|
||||
private function syncTags(int $projectId, array $tagIds): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Supprimer les associations existantes
|
||||
$stmt = $db->prepare('DELETE FROM project_tags WHERE project_id = :project_id');
|
||||
$stmt->execute(['project_id' => $projectId]);
|
||||
|
||||
// Ajouter les nouvelles associations
|
||||
if (!empty($tagIds)) {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO project_tags (project_id, tag_id)
|
||||
SELECT :project_id, id FROM tags
|
||||
WHERE id = :tag_id AND user_id = :user_id
|
||||
');
|
||||
|
||||
foreach ($tagIds as $tagId) {
|
||||
$stmt->execute([
|
||||
'project_id' => $projectId,
|
||||
'tag_id' => (int) $tagId,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur des statuts
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class StatusController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /statuses
|
||||
* ?project_id=X - statuts d'un projet spécifique
|
||||
* ?global=1 - uniquement les statuts globaux
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$where = ['user_id = :user_id'];
|
||||
$params = ['user_id' => $this->getUserId()];
|
||||
|
||||
$projectId = $this->request->get('project_id');
|
||||
$globalOnly = $this->request->get('global');
|
||||
|
||||
if ($projectId !== null) {
|
||||
// Statuts du projet + statuts globaux
|
||||
$where = ['user_id = :user_id AND (project_id = :project_id OR project_id IS NULL)'];
|
||||
$params['project_id'] = (int) $projectId;
|
||||
} elseif ($globalOnly !== null) {
|
||||
$where[] = 'project_id IS NULL';
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count
|
||||
FROM statuses s
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY s.position ASC, s.code ASC
|
||||
';
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$statuses = $stmt->fetchAll();
|
||||
|
||||
Response::success($statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /statuses/{id}
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$status = $this->findOrFail($id);
|
||||
|
||||
// Nombre de tâches avec ce statut
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$status['task_count'] = (int) $stmt->fetch()['count'];
|
||||
|
||||
Response::success($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /statuses
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->validate([
|
||||
'code' => 'required|int',
|
||||
'name' => 'required|min:1|max:50',
|
||||
'color' => 'max:7',
|
||||
'project_id' => 'int',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
// Si project_id fourni, vérifier qu'il appartient à l'utilisateur
|
||||
if (!empty($data['project_id'])) {
|
||||
$this->verifyProject((int) $data['project_id']);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO statuses (user_id, project_id, code, name, color, position)
|
||||
VALUES (:user_id, :project_id, :code, :name, :color, :position)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'user_id' => $this->getUserId(),
|
||||
'project_id' => $data['project_id'] ?: null,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'] ?? '#6B7280',
|
||||
'position' => $data['position'] ?? $data['code'],
|
||||
]);
|
||||
|
||||
$statusId = (int) $db->lastInsertId();
|
||||
$status = $this->findOrFail($statusId);
|
||||
|
||||
Response::success($status, 'Statut créé', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /statuses/{id}
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$data = $this->validate([
|
||||
'code' => 'int',
|
||||
'name' => 'min:1|max:50',
|
||||
'color' => 'max:7',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
if (isset($data['code'])) {
|
||||
$fields[] = 'code = :code';
|
||||
$params['code'] = $data['code'];
|
||||
}
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$fields[] = 'name = :name';
|
||||
$params['name'] = $data['name'];
|
||||
}
|
||||
|
||||
if (isset($data['color'])) {
|
||||
$fields[] = 'color = :color';
|
||||
$params['color'] = $data['color'];
|
||||
}
|
||||
|
||||
if (isset($data['position'])) {
|
||||
$fields[] = 'position = :position';
|
||||
$params['position'] = $data['position'];
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
$sql = 'UPDATE statuses SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
$status = $this->findOrFail($id);
|
||||
|
||||
Response::success($status, 'Statut mis à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /statuses/{id}
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$status = $this->findOrFail($id);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Vérifier qu'aucune tâche n'utilise ce statut
|
||||
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$count = (int) $stmt->fetch()['count'];
|
||||
|
||||
if ($count > 0) {
|
||||
Response::error("Impossible de supprimer : {$count} tâche(s) utilisent ce statut", 409);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM statuses WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
Response::success(null, 'Statut supprimé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouver un statut ou retourner 404
|
||||
*/
|
||||
private function findOrFail(int $id): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT * FROM statuses
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
$status = $stmt->fetch();
|
||||
|
||||
if (!$status) {
|
||||
Response::notFound('Statut non trouvé');
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier qu'un projet appartient à l'utilisateur
|
||||
*/
|
||||
private function verifyProject(int $projectId): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
|
||||
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
|
||||
|
||||
if (!$stmt->fetch()) {
|
||||
Response::error('Projet invalide', 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur des tags
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /tags
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT t.*,
|
||||
(SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count,
|
||||
(SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count
|
||||
FROM tags t
|
||||
WHERE t.user_id = :user_id
|
||||
ORDER BY t.name ASC
|
||||
');
|
||||
|
||||
$stmt->execute(['user_id' => $this->getUserId()]);
|
||||
$tags = $stmt->fetchAll();
|
||||
|
||||
Response::success($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tags/{id}
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$tag = $this->findOrFail($id);
|
||||
|
||||
// Récupérer les projets associés
|
||||
$tag['projects'] = $this->getTagProjects($id);
|
||||
|
||||
// Récupérer les tâches associées
|
||||
$tag['tasks'] = $this->getTagTasks($id);
|
||||
|
||||
Response::success($tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tags
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->validate([
|
||||
'name' => 'required|min:1|max:50',
|
||||
'color' => 'max:7',
|
||||
]);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Vérifier unicité du nom pour cet utilisateur
|
||||
$stmt = $db->prepare('SELECT id FROM tags WHERE user_id = :user_id AND name = :name');
|
||||
$stmt->execute(['user_id' => $this->getUserId(), 'name' => $data['name']]);
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
Response::error('Ce tag existe déjà', 409);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO tags (user_id, name, color)
|
||||
VALUES (:user_id, :name, :color)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'user_id' => $this->getUserId(),
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'] ?? '#3B82F6',
|
||||
]);
|
||||
|
||||
$tagId = (int) $db->lastInsertId();
|
||||
$tag = $this->findOrFail($tagId);
|
||||
|
||||
Response::success($tag, 'Tag créé', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /tags/{id}
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$data = $this->validate([
|
||||
'name' => 'min:1|max:50',
|
||||
'color' => 'max:7',
|
||||
]);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Vérifier unicité du nom si modifié
|
||||
if (!empty($data['name'])) {
|
||||
$stmt = $db->prepare('
|
||||
SELECT id FROM tags
|
||||
WHERE user_id = :user_id AND name = :name AND id != :id
|
||||
');
|
||||
$stmt->execute([
|
||||
'user_id' => $this->getUserId(),
|
||||
'name' => $data['name'],
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
Response::error('Ce tag existe déjà', 409);
|
||||
}
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$fields[] = 'name = :name';
|
||||
$params['name'] = $data['name'];
|
||||
}
|
||||
|
||||
if (isset($data['color'])) {
|
||||
$fields[] = 'color = :color';
|
||||
$params['color'] = $data['color'];
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
$sql = 'UPDATE tags SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
$tag = $this->findOrFail($id);
|
||||
|
||||
Response::success($tag, 'Tag mis à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /tags/{id}
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Les associations seront supprimées en cascade (FK)
|
||||
$stmt = $db->prepare('DELETE FROM tags WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
Response::success(null, 'Tag supprimé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouver un tag ou retourner 404
|
||||
*/
|
||||
private function findOrFail(int $id): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT * FROM tags
|
||||
WHERE id = :id AND user_id = :user_id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
$tag = $stmt->fetch();
|
||||
|
||||
if (!$tag) {
|
||||
Response::notFound('Tag non trouvé');
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les projets associés à un tag
|
||||
*/
|
||||
private function getTagProjects(int $tagId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT p.id, p.name
|
||||
FROM projects p
|
||||
JOIN project_tags pt ON p.id = pt.project_id
|
||||
WHERE pt.tag_id = :tag_id
|
||||
ORDER BY p.name ASC
|
||||
');
|
||||
|
||||
$stmt->execute(['tag_id' => $tagId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les tâches associées à un tag
|
||||
*/
|
||||
private function getTagTasks(int $tagId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT t.id, t.title, t.status_id, s.name as status_name
|
||||
FROM tasks t
|
||||
JOIN task_tags tt ON t.id = tt.task_id
|
||||
LEFT JOIN statuses s ON t.status_id = s.id
|
||||
WHERE tt.tag_id = :tag_id
|
||||
ORDER BY t.created_at DESC
|
||||
');
|
||||
|
||||
$stmt->execute(['tag_id' => $tagId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur des tâches
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class TaskController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /tasks
|
||||
* Liste les tâches avec filtres optionnels
|
||||
* ?project_id=X - filtrer par projet
|
||||
* ?status_id=X - filtrer par statut
|
||||
* ?tag_id=X - filtrer par tag
|
||||
* ?date_start=YYYY-MM-DD - tâches commençant après
|
||||
* ?date_end=YYYY-MM-DD - tâches finissant avant
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$where = ['t.user_id = :user_id'];
|
||||
$params = ['user_id' => $this->getUserId()];
|
||||
|
||||
// Filtre par projet
|
||||
$projectId = $this->request->get('project_id');
|
||||
if ($projectId !== null) {
|
||||
$where[] = 't.project_id = :project_id';
|
||||
$params['project_id'] = (int) $projectId;
|
||||
}
|
||||
|
||||
// Filtre par statut
|
||||
$statusId = $this->request->get('status_id');
|
||||
if ($statusId !== null) {
|
||||
$where[] = 't.status_id = :status_id';
|
||||
$params['status_id'] = (int) $statusId;
|
||||
}
|
||||
|
||||
// Filtre par date de début
|
||||
$dateStart = $this->request->get('date_start');
|
||||
if ($dateStart !== null) {
|
||||
$where[] = 't.date_start >= :date_start';
|
||||
$params['date_start'] = $dateStart;
|
||||
}
|
||||
|
||||
// Filtre par date de fin
|
||||
$dateEnd = $this->request->get('date_end');
|
||||
if ($dateEnd !== null) {
|
||||
$where[] = 't.date_end <= :date_end';
|
||||
$params['date_end'] = $dateEnd;
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT t.*,
|
||||
p.name as project_name,
|
||||
s.name as status_name,
|
||||
s.color as status_color,
|
||||
GROUP_CONCAT(tg.id) as tag_ids,
|
||||
GROUP_CONCAT(tg.name) as tag_names,
|
||||
GROUP_CONCAT(tg.color) as tag_colors
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN statuses s ON t.status_id = s.id
|
||||
LEFT JOIN task_tags tt ON t.id = tt.task_id
|
||||
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
GROUP BY t.id
|
||||
ORDER BY t.position ASC, t.priority DESC, t.created_at DESC
|
||||
';
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$tasks = $stmt->fetchAll();
|
||||
|
||||
// Filtre par tag (après GROUP BY)
|
||||
$tagId = $this->request->get('tag_id');
|
||||
|
||||
// Parser les tags
|
||||
foreach ($tasks as &$task) {
|
||||
$task['tags'] = $this->parseTags($task);
|
||||
unset($task['tag_ids'], $task['tag_names'], $task['tag_colors']);
|
||||
}
|
||||
|
||||
// Appliquer filtre tag si nécessaire
|
||||
if ($tagId !== null) {
|
||||
$tagId = (int) $tagId;
|
||||
$tasks = array_filter($tasks, function ($task) use ($tagId) {
|
||||
foreach ($task['tags'] as $tag) {
|
||||
if ($tag['id'] === $tagId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
$tasks = array_values($tasks);
|
||||
}
|
||||
|
||||
Response::success($tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tasks/{id}
|
||||
*/
|
||||
public function show(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$task = $this->findOrFail($id);
|
||||
|
||||
$task['tags'] = $this->getTaskTags($id);
|
||||
|
||||
Response::success($task);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tasks
|
||||
*/
|
||||
public function store(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->validate([
|
||||
'project_id' => 'required|int',
|
||||
'status_id' => 'required|int',
|
||||
'title' => 'required|min:1|max:255',
|
||||
'description' => 'max:65535',
|
||||
'priority' => 'int',
|
||||
'date_start' => 'max:10',
|
||||
'date_end' => 'max:10',
|
||||
'time_estimated' => 'int',
|
||||
'time_spent' => 'int',
|
||||
'billing' => 'numeric',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
// Vérifier que le projet appartient à l'utilisateur
|
||||
$this->verifyProject((int) $data['project_id']);
|
||||
|
||||
// Vérifier que le statut appartient à l'utilisateur
|
||||
$this->verifyStatus((int) $data['status_id']);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO tasks (user_id, project_id, status_id, title, description, priority,
|
||||
date_start, date_end, time_estimated, time_spent, billing, position)
|
||||
VALUES (:user_id, :project_id, :status_id, :title, :description, :priority,
|
||||
:date_start, :date_end, :time_estimated, :time_spent, :billing, :position)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'user_id' => $this->getUserId(),
|
||||
'project_id' => $data['project_id'],
|
||||
'status_id' => $data['status_id'],
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'priority' => $data['priority'] ?? 5,
|
||||
'date_start' => $data['date_start'] ?: null,
|
||||
'date_end' => $data['date_end'] ?: null,
|
||||
'time_estimated' => $data['time_estimated'] ?? 0,
|
||||
'time_spent' => $data['time_spent'] ?? 0,
|
||||
'billing' => $data['billing'] ?? 0,
|
||||
'position' => $data['position'] ?? 0,
|
||||
]);
|
||||
|
||||
$taskId = (int) $db->lastInsertId();
|
||||
|
||||
// Gérer les tags si fournis
|
||||
$tags = $this->request->get('tags');
|
||||
if (is_array($tags)) {
|
||||
$this->syncTags($taskId, $tags);
|
||||
}
|
||||
|
||||
$task = $this->findOrFail($taskId);
|
||||
$task['tags'] = $this->getTaskTags($taskId);
|
||||
|
||||
Response::success($task, 'Tâche créée', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /tasks/{id}
|
||||
*/
|
||||
public function update(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$data = $this->validate([
|
||||
'project_id' => 'int',
|
||||
'status_id' => 'int',
|
||||
'title' => 'min:1|max:255',
|
||||
'description' => 'max:65535',
|
||||
'priority' => 'int',
|
||||
'date_start' => 'max:10',
|
||||
'date_end' => 'max:10',
|
||||
'time_estimated' => 'int',
|
||||
'time_spent' => 'int',
|
||||
'billing' => 'numeric',
|
||||
'position' => 'int',
|
||||
]);
|
||||
|
||||
if (!empty($data['project_id'])) {
|
||||
$this->verifyProject((int) $data['project_id']);
|
||||
}
|
||||
|
||||
if (!empty($data['status_id'])) {
|
||||
$this->verifyStatus((int) $data['status_id']);
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
$allowedFields = [
|
||||
'project_id', 'status_id', 'title', 'description', 'priority',
|
||||
'date_start', 'date_end', 'time_estimated', 'time_spent', 'billing', 'position'
|
||||
];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$fields[] = "{$field} = :{$field}";
|
||||
$value = $data[$field];
|
||||
// Convertir les chaînes vides en null pour les dates
|
||||
if (in_array($field, ['date_start', 'date_end']) && $value === '') {
|
||||
$value = null;
|
||||
}
|
||||
$params[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
$sql = 'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
// Gérer les tags si fournis
|
||||
$tags = $this->request->get('tags');
|
||||
if (is_array($tags)) {
|
||||
$this->syncTags($id, $tags);
|
||||
}
|
||||
|
||||
$task = $this->findOrFail($id);
|
||||
$task['tags'] = $this->getTaskTags($id);
|
||||
|
||||
Response::success($task, 'Tâche mise à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /tasks/{id}
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->requireAuth();
|
||||
|
||||
$id = (int) $this->request->getParam('id');
|
||||
$this->findOrFail($id);
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM tasks WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
Response::success(null, 'Tâche supprimée');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouver une tâche ou retourner 404
|
||||
*/
|
||||
private function findOrFail(int $id): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN statuses s ON t.status_id = s.id
|
||||
WHERE t.id = :id AND t.user_id = :user_id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $id,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
|
||||
$task = $stmt->fetch();
|
||||
|
||||
if (!$task) {
|
||||
Response::notFound('Tâche non trouvée');
|
||||
}
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier qu'un projet appartient à l'utilisateur
|
||||
*/
|
||||
private function verifyProject(int $projectId): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
|
||||
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
|
||||
|
||||
if (!$stmt->fetch()) {
|
||||
Response::error('Projet invalide', 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier qu'un statut appartient à l'utilisateur
|
||||
*/
|
||||
private function verifyStatus(int $statusId): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('SELECT id FROM statuses WHERE id = :id AND user_id = :user_id');
|
||||
$stmt->execute(['id' => $statusId, 'user_id' => $this->getUserId()]);
|
||||
|
||||
if (!$stmt->fetch()) {
|
||||
Response::error('Statut invalide', 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser les tags depuis le GROUP_CONCAT
|
||||
*/
|
||||
private function parseTags(array $task): array
|
||||
{
|
||||
$tags = [];
|
||||
if (!empty($task['tag_ids'])) {
|
||||
$ids = explode(',', $task['tag_ids']);
|
||||
$names = explode(',', $task['tag_names']);
|
||||
$colors = explode(',', $task['tag_colors']);
|
||||
foreach ($ids as $i => $tagId) {
|
||||
$tags[] = [
|
||||
'id' => (int) $tagId,
|
||||
'name' => $names[$i] ?? '',
|
||||
'color' => $colors[$i] ?? '#3B82F6',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les tags d'une tâche
|
||||
*/
|
||||
private function getTaskTags(int $taskId): array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM tags t
|
||||
JOIN task_tags tt ON t.id = tt.tag_id
|
||||
WHERE tt.task_id = :task_id
|
||||
');
|
||||
|
||||
$stmt->execute(['task_id' => $taskId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchroniser les tags d'une tâche
|
||||
*/
|
||||
private function syncTags(int $taskId, array $tagIds): void
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM task_tags WHERE task_id = :task_id');
|
||||
$stmt->execute(['task_id' => $taskId]);
|
||||
|
||||
if (!empty($tagIds)) {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO task_tags (task_id, tag_id)
|
||||
SELECT :task_id, id FROM tags
|
||||
WHERE id = :tag_id AND user_id = :user_id
|
||||
');
|
||||
|
||||
foreach ($tagIds as $tagId) {
|
||||
$stmt->execute([
|
||||
'task_id' => $taskId,
|
||||
'tag_id' => (int) $tagId,
|
||||
'user_id' => $this->getUserId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Contrôleur de base
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected Request $request;
|
||||
protected ?array $user = null;
|
||||
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requiert une authentification valide
|
||||
*/
|
||||
protected function requireAuth(): void
|
||||
{
|
||||
$sessionId = $this->request->getSessionId();
|
||||
|
||||
if (empty($sessionId)) {
|
||||
Response::unauthorized('Session ID required');
|
||||
}
|
||||
|
||||
$user = Session::validate($sessionId);
|
||||
|
||||
if ($user === null) {
|
||||
Response::unauthorized('Invalid or expired session');
|
||||
}
|
||||
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ID de l'utilisateur authentifié
|
||||
*/
|
||||
protected function getUserId(): int
|
||||
{
|
||||
return $this->user['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les champs requis dans le body
|
||||
*/
|
||||
protected function validate(array $rules): array
|
||||
{
|
||||
$body = $this->request->getBody();
|
||||
$errors = [];
|
||||
$data = [];
|
||||
|
||||
foreach ($rules as $field => $rule) {
|
||||
$value = $body[$field] ?? null;
|
||||
$ruleList = explode('|', $rule);
|
||||
|
||||
foreach ($ruleList as $r) {
|
||||
if ($r === 'required' && ($value === null || $value === '')) {
|
||||
$errors[$field] = "Le champ {$field} est requis";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($r === 'email' && $value !== null && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$field] = "Le champ {$field} doit être un email valide";
|
||||
break;
|
||||
}
|
||||
|
||||
if (str_starts_with($r, 'min:')) {
|
||||
$min = (int) substr($r, 4);
|
||||
if ($value !== null && strlen($value) < $min) {
|
||||
$errors[$field] = "Le champ {$field} doit contenir au moins {$min} caractères";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($r, 'max:')) {
|
||||
$max = (int) substr($r, 4);
|
||||
if ($value !== null && strlen($value) > $max) {
|
||||
$errors[$field] = "Le champ {$field} doit contenir au maximum {$max} caractères";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($r === 'int' && $value !== null && !is_numeric($value)) {
|
||||
$errors[$field] = "Le champ {$field} doit être un nombre entier";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($r === 'numeric' && $value !== null && !is_numeric($value)) {
|
||||
$errors[$field] = "Le champ {$field} doit être un nombre";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($errors[$field])) {
|
||||
$data[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
Response::error('Validation failed', 422, $errors);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestion de la requête entrante
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Request
|
||||
{
|
||||
private string $method;
|
||||
private string $uri;
|
||||
private array $params = [];
|
||||
private array $body = [];
|
||||
private array $headers = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = $_SERVER['REQUEST_METHOD'];
|
||||
$this->uri = $this->parseUri();
|
||||
$this->headers = $this->parseHeaders();
|
||||
$this->body = $this->parseBody();
|
||||
}
|
||||
|
||||
private function parseUri(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
|
||||
// Retirer le query string
|
||||
if (($pos = strpos($uri, '?')) !== false) {
|
||||
$uri = substr($uri, 0, $pos);
|
||||
}
|
||||
|
||||
// Retirer le préfixe /api si présent
|
||||
$uri = preg_replace('#^/api#', '', $uri);
|
||||
|
||||
return '/' . trim($uri, '/');
|
||||
}
|
||||
|
||||
private function parseHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$name = str_replace('_', '-', substr($key, 5));
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function parseBody(): array
|
||||
{
|
||||
if (in_array($this->method, ['POST', 'PUT', 'PATCH'])) {
|
||||
$input = file_get_contents('php://input');
|
||||
if (!empty($input)) {
|
||||
$decoded = json_decode($input, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
// Fallback sur $_POST ou array vide
|
||||
return $_POST ?: [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getUri(): string
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
public function getHeader(string $name): ?string
|
||||
{
|
||||
$name = strtoupper(str_replace('-', '_', $name));
|
||||
return $this->headers[$name] ?? null;
|
||||
}
|
||||
|
||||
public function getSessionId(): ?string
|
||||
{
|
||||
return $this->getHeader('X-SESSION-ID');
|
||||
}
|
||||
|
||||
public function getBody(): array
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->body[$key] ?? $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function setParams(array $params): void
|
||||
{
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function getParam(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->params[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getParams(): array
|
||||
{
|
||||
return $this->params;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestion des réponses JSON
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function json(mixed $data, int $code = 200): void
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'OK', int $code = 200): void
|
||||
{
|
||||
self::json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], $code);
|
||||
}
|
||||
|
||||
public static function error(string $message, int $code = 400, mixed $errors = null): void
|
||||
{
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
self::json($response, $code);
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Resource not found'): void
|
||||
{
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
public static function unauthorized(string $message = 'Unauthorized'): void
|
||||
{
|
||||
self::error($message, 401);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Routeur simple pour API REST
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private Request $request;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->request = new Request();
|
||||
$this->registerRoutes();
|
||||
}
|
||||
|
||||
private function registerRoutes(): void
|
||||
{
|
||||
// Auth (routes publiques)
|
||||
$this->post('/auth/register', 'AuthController@register');
|
||||
$this->post('/auth/login', 'AuthController@login');
|
||||
$this->post('/auth/logout', 'AuthController@logout');
|
||||
$this->get('/auth/me', 'AuthController@me');
|
||||
|
||||
// Projects
|
||||
$this->get('/projects', 'ProjectController@index');
|
||||
$this->get('/projects/{id}', 'ProjectController@show');
|
||||
$this->post('/projects', 'ProjectController@store');
|
||||
$this->put('/projects/{id}', 'ProjectController@update');
|
||||
$this->delete('/projects/{id}', 'ProjectController@destroy');
|
||||
|
||||
// Tasks
|
||||
$this->get('/tasks', 'TaskController@index');
|
||||
$this->get('/tasks/{id}', 'TaskController@show');
|
||||
$this->post('/tasks', 'TaskController@store');
|
||||
$this->put('/tasks/{id}', 'TaskController@update');
|
||||
$this->delete('/tasks/{id}', 'TaskController@destroy');
|
||||
|
||||
// Tags
|
||||
$this->get('/tags', 'TagController@index');
|
||||
$this->get('/tags/{id}', 'TagController@show');
|
||||
$this->post('/tags', 'TagController@store');
|
||||
$this->put('/tags/{id}', 'TagController@update');
|
||||
$this->delete('/tags/{id}', 'TagController@destroy');
|
||||
|
||||
// Statuses
|
||||
$this->get('/statuses', 'StatusController@index');
|
||||
$this->get('/statuses/{id}', 'StatusController@show');
|
||||
$this->post('/statuses', 'StatusController@store');
|
||||
$this->put('/statuses/{id}', 'StatusController@update');
|
||||
$this->delete('/statuses/{id}', 'StatusController@destroy');
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, string $handler): void
|
||||
{
|
||||
$this->routes[] = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'handler' => $handler,
|
||||
];
|
||||
}
|
||||
|
||||
public function get(string $path, string $handler): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler);
|
||||
}
|
||||
|
||||
public function post(string $path, string $handler): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler);
|
||||
}
|
||||
|
||||
public function put(string $path, string $handler): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler);
|
||||
}
|
||||
|
||||
public function delete(string $path, string $handler): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler);
|
||||
}
|
||||
|
||||
public function dispatch(): void
|
||||
{
|
||||
$method = $this->request->getMethod();
|
||||
$uri = $this->request->getUri();
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$params = $this->matchRoute($route['path'], $uri);
|
||||
if ($params !== false) {
|
||||
$this->request->setParams($params);
|
||||
$this->callHandler($route['handler']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Response::notFound('Route not found');
|
||||
}
|
||||
|
||||
private function matchRoute(string $routePath, string $uri): array|false
|
||||
{
|
||||
// Convertir /projects/{id} en regex /projects/([^/]+)
|
||||
$pattern = preg_replace('#\{(\w+)\}#', '([^/]+)', $routePath);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
if (preg_match($pattern, $uri, $matches)) {
|
||||
array_shift($matches); // Retirer le match complet
|
||||
|
||||
// Extraire les noms des paramètres
|
||||
preg_match_all('#\{(\w+)\}#', $routePath, $paramNames);
|
||||
$params = [];
|
||||
|
||||
foreach ($paramNames[1] as $index => $name) {
|
||||
$params[$name] = $matches[$index] ?? null;
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function callHandler(string $handler): void
|
||||
{
|
||||
[$controllerName, $methodName] = explode('@', $handler);
|
||||
|
||||
if (!class_exists($controllerName)) {
|
||||
Response::error("Controller {$controllerName} not found", 500);
|
||||
}
|
||||
|
||||
$controller = new $controllerName($this->request);
|
||||
|
||||
if (!method_exists($controller, $methodName)) {
|
||||
Response::error("Method {$methodName} not found", 500);
|
||||
}
|
||||
|
||||
$controller->$methodName();
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Gestion des sessions en base de données
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Session
|
||||
{
|
||||
private static ?array $currentSession = null;
|
||||
private static ?array $currentUser = null;
|
||||
|
||||
/**
|
||||
* Récupérer l'IP réelle du client (derrière proxy)
|
||||
*/
|
||||
public static function getClientIp(): ?string
|
||||
{
|
||||
// Headers transmis par le proxy nginx
|
||||
$headers = [
|
||||
'HTTP_X_REAL_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_CLIENT_IP',
|
||||
'REMOTE_ADDR',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// X-Forwarded-For peut contenir plusieurs IPs (client, proxy1, proxy2...)
|
||||
$ips = explode(',', $_SERVER[$header]);
|
||||
$ip = trim($ips[0]);
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer une nouvelle session pour un utilisateur
|
||||
*/
|
||||
public static function create(int $userId, ?string $ipAddress = null, ?string $userAgent = null): string
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$sessionId = bin2hex(random_bytes(64)); // 128 caractères
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
|
||||
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||
VALUES (:id, :user_id, :ip_address, :user_agent, :expires_at)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $sessionId,
|
||||
'user_id' => $userId,
|
||||
'ip_address' => $ipAddress ?? self::getClientIp(),
|
||||
'user_agent' => $userAgent ?? $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return $sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valider une session et retourner l'utilisateur
|
||||
*/
|
||||
public static function validate(string $sessionId): ?array
|
||||
{
|
||||
if (self::$currentSession !== null && self::$currentSession['id'] === $sessionId) {
|
||||
return self::$currentUser;
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT s.*, u.id as user_id, u.email, u.name
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = :id AND s.expires_at > NOW()
|
||||
');
|
||||
|
||||
$stmt->execute(['id' => $sessionId]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
if (!$result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$currentSession = [
|
||||
'id' => $result['id'],
|
||||
'user_id' => $result['user_id'],
|
||||
'expires_at' => $result['expires_at'],
|
||||
];
|
||||
|
||||
self::$currentUser = [
|
||||
'id' => $result['user_id'],
|
||||
'email' => $result['email'],
|
||||
'name' => $result['name'],
|
||||
];
|
||||
|
||||
return self::$currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détruire une session
|
||||
*/
|
||||
public static function destroy(string $sessionId): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM sessions WHERE id = :id');
|
||||
$stmt->execute(['id' => $sessionId]);
|
||||
|
||||
self::$currentSession = null;
|
||||
self::$currentUser = null;
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoyer les sessions expirées
|
||||
*/
|
||||
public static function cleanup(): int
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
|
||||
$stmt->execute();
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prolonger une session
|
||||
*/
|
||||
public static function extend(string $sessionId): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
|
||||
|
||||
$stmt = $db->prepare('
|
||||
UPDATE sessions SET expires_at = :expires_at WHERE id = :id
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
'id' => $sessionId,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'utilisateur courant (depuis le cache)
|
||||
*/
|
||||
public static function getCurrentUser(): ?array
|
||||
{
|
||||
return self::$currentUser;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de déploiement pour PROKOV API
|
||||
# Version: 1.0 (12 décembre 2025)
|
||||
# Auteur: Pierre (avec l'aide de Claude)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ENV=DEV
|
||||
JUMP_USER="root"
|
||||
JUMP_HOST="195.154.80.116"
|
||||
JUMP_PORT="22"
|
||||
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
|
||||
INCUS_PROJECT=default
|
||||
INCUS_CONTAINER=dva-front
|
||||
|
||||
# Paramètres du container Incus
|
||||
CONTAINER_USER=root
|
||||
CONTAINER_IP="13.23.33.42"
|
||||
|
||||
# Paramètres de déploiement
|
||||
FINAL_PATH="/var/www/prokov/api"
|
||||
FINAL_OWNER="nginx"
|
||||
FINAL_GROUP="nginx"
|
||||
FINAL_OWNER_LOGS="nobody"
|
||||
|
||||
# Couleurs pour les messages
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Fonction pour afficher les messages d'étape
|
||||
echo_step() {
|
||||
echo -e "${GREEN}==>${NC} $1"
|
||||
}
|
||||
|
||||
# Fonction pour afficher les informations
|
||||
echo_info() {
|
||||
echo -e "${BLUE}Info:${NC} $1"
|
||||
}
|
||||
|
||||
# Fonction pour afficher les avertissements
|
||||
echo_warning() {
|
||||
echo -e "${YELLOW}Warning:${NC} $1"
|
||||
}
|
||||
|
||||
# Fonction pour afficher les erreurs
|
||||
echo_error() {
|
||||
echo -e "${RED}Error:${NC} $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Vérification de l'environnement
|
||||
echo_step "Verifying environment..."
|
||||
echo_info "Deploying PROKOV API to $ENV environment"
|
||||
echo_info "Container: $INCUS_CONTAINER (IP: $CONTAINER_IP)"
|
||||
echo_info "Target path: $FINAL_PATH"
|
||||
|
||||
# Vérification des fichiers requis
|
||||
if [ ! -f "public/index.php" ]; then
|
||||
echo_error "public/index.php missing - are you in the api directory?"
|
||||
fi
|
||||
|
||||
if [ ! -d "core" ] || [ ! -d "controllers" ]; then
|
||||
echo_error "API structure incomplete (core/ or controllers/ missing)"
|
||||
fi
|
||||
|
||||
# Étape 0: Définir le nom de l'archive
|
||||
ARCHIVE_NAME="prokov-api-${ENV}-$(date +%s).tar.gz"
|
||||
ARCHIVE_PATH="/tmp/${ARCHIVE_NAME}"
|
||||
echo_info "Archive name will be: $ARCHIVE_NAME"
|
||||
|
||||
# Étape 1: Créer une archive du projet
|
||||
echo_step "Creating project archive..."
|
||||
tar --exclude='.git' \
|
||||
--exclude='.gitignore' \
|
||||
--exclude='.vscode' \
|
||||
--exclude='logs' \
|
||||
--exclude='*.template' \
|
||||
--exclude='*.sh' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.DS_Store' \
|
||||
--exclude='README.md' \
|
||||
--exclude="*.tar.gz" \
|
||||
--no-xattrs \
|
||||
-czf "${ARCHIVE_PATH}" . || echo_error "Failed to create archive"
|
||||
|
||||
# Vérifier la taille de l'archive
|
||||
ARCHIVE_SIZE=$(du -h "${ARCHIVE_PATH}" | cut -f1)
|
||||
|
||||
SSH_JUMP_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
|
||||
|
||||
# Étape 2: Copier l'archive vers le serveur de saut (IN3)
|
||||
echo_step "Copying archive to jump server (IN3)..."
|
||||
echo_info "Archive size: $ARCHIVE_SIZE"
|
||||
scp -i "${JUMP_KEY}" -P "${JUMP_PORT}" "${ARCHIVE_PATH}" "${JUMP_USER}@${JUMP_HOST}:/tmp/${ARCHIVE_NAME}" || echo_error "Failed to copy archive to jump server"
|
||||
|
||||
# Étape 3: Exécuter les commandes sur IN3 pour déployer dans le container Incus dva-front
|
||||
echo_step "Deploying to Incus container ($INCUS_CONTAINER)..."
|
||||
$SSH_JUMP_CMD "
|
||||
set -euo pipefail
|
||||
|
||||
echo '✅ Passage au projet Incus...'
|
||||
incus project switch ${INCUS_PROJECT} || exit 1
|
||||
|
||||
echo '📦 Poussée de l archive dans le conteneur...'
|
||||
incus file push /tmp/${ARCHIVE_NAME} ${INCUS_CONTAINER}/tmp/${ARCHIVE_NAME} || exit 1
|
||||
|
||||
echo '📁 Préparation du dossier final...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH} || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- rm -rf ${FINAL_PATH}/* || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${FINAL_PATH}/ || exit 1
|
||||
|
||||
echo '🔧 Réglage des permissions...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH}/logs || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${FINAL_PATH} || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type d -exec chmod 755 {} \; || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type f -exec chmod 644 {} \; || exit 1
|
||||
|
||||
# Permissions spéciales pour le dossier logs
|
||||
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${FINAL_PATH}/logs || exit 1
|
||||
incus exec ${INCUS_CONTAINER} -- chmod -R 775 ${FINAL_PATH}/logs || exit 1
|
||||
|
||||
echo '🧹 Nettoyage...'
|
||||
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} || exit 1
|
||||
rm -f /tmp/${ARCHIVE_NAME} || exit 1
|
||||
"
|
||||
|
||||
# Nettoyage local
|
||||
rm -f "${ARCHIVE_PATH}"
|
||||
|
||||
# Résumé final
|
||||
echo_step "Deployment completed successfully."
|
||||
echo ""
|
||||
echo_info "PROKOV API deployed to $ENV environment"
|
||||
echo_info " Host: IN3 ($JUMP_HOST)"
|
||||
echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)"
|
||||
echo_info " Path: $FINAL_PATH"
|
||||
echo_info " Deployment time: $(date)"
|
||||
echo ""
|
||||
echo_info "API should be accessible at: https://prokov.unikoffice.com/api/"
|
||||
|
||||
# Journaliser le déploiement
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - PROKOV API deployed to ${ENV} (${INCUS_CONTAINER}:${FINAL_PATH})" >> ~/.prokov_deploy_history
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Modèle User
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class User
|
||||
{
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, email, name, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :id
|
||||
');
|
||||
|
||||
$stmt->execute(['id' => $id]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$stmt = $db->prepare('
|
||||
SELECT id, email, name, password, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = :email
|
||||
');
|
||||
|
||||
$stmt->execute(['email' => $email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$db = Database::getInstance();
|
||||
|
||||
$fields = [];
|
||||
$params = ['id' => $id];
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$fields[] = 'name = :name';
|
||||
$params['name'] = $data['name'];
|
||||
}
|
||||
|
||||
if (isset($data['email'])) {
|
||||
$fields[] = 'email = :email';
|
||||
$params['email'] = $data['email'];
|
||||
}
|
||||
|
||||
if (isset($data['password'])) {
|
||||
$fields[] = 'password = :password';
|
||||
$params['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
if (empty($fields)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* PROKOV API - Point d'entrée
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Chemin racine de l'API (dossier parent)
|
||||
define('API_ROOT', dirname(__DIR__));
|
||||
|
||||
// Headers CORS et JSON
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Session-Id');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
|
||||
// Preflight OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Autoload simple
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$paths = [
|
||||
'config/',
|
||||
'core/',
|
||||
'controllers/',
|
||||
'models/',
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$file = API_ROOT . '/' . $path . $class . '.php';
|
||||
if (file_exists($file)) {
|
||||
require_once $file;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Chargement config
|
||||
require_once API_ROOT . '/config/config.php';
|
||||
|
||||
// Initialisation
|
||||
$router = new Router();
|
||||
$router->dispatch();
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
@@ -24,8 +25,29 @@ import (
|
||||
var (
|
||||
socketPath = flag.String("socket", "/run/sogoms-db.1.sock", "Unix socket path")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "sogoms-logs socket path")
|
||||
)
|
||||
|
||||
var logsPool *protocol.Pool
|
||||
|
||||
// logError envoie une erreur à sogoms-logs (fire and forget).
|
||||
func logError(appID, level, message string, ctx map[string]any) {
|
||||
if logsPool == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
req := protocol.NewRequest("log_error", map[string]any{
|
||||
"app_id": appID,
|
||||
"level": level,
|
||||
"message": message,
|
||||
"context": ctx,
|
||||
})
|
||||
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
logsPool.Call(reqCtx, req)
|
||||
}()
|
||||
}
|
||||
|
||||
// DBPool gère les connexions DB par application.
|
||||
type DBPool struct {
|
||||
registry *config.Registry
|
||||
@@ -108,6 +130,10 @@ func main() {
|
||||
dbPool := NewDBPool(registry)
|
||||
defer dbPool.Close()
|
||||
|
||||
// Pool de connexions vers sogoms-logs
|
||||
logsPool = protocol.NewPool(*logsSocket, 3)
|
||||
defer logsPool.Close()
|
||||
|
||||
// Handler des requêtes
|
||||
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
|
||||
return handleRequest(ctx, req, dbPool)
|
||||
@@ -144,15 +170,15 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
|
||||
|
||||
switch req.Action {
|
||||
case "query":
|
||||
return handleQuery(req, db)
|
||||
return handleQuery(req, db, appID)
|
||||
case "query_one":
|
||||
return handleQueryOne(req, db)
|
||||
return handleQueryOne(req, db, appID)
|
||||
case "insert":
|
||||
return handleInsert(req, db)
|
||||
return handleInsert(req, db, appID)
|
||||
case "update":
|
||||
return handleUpdate(req, db)
|
||||
return handleUpdate(req, db, appID)
|
||||
case "delete":
|
||||
return handleDelete(req, db)
|
||||
return handleDelete(req, db, appID)
|
||||
case "health":
|
||||
return handleHealth(req, db)
|
||||
default:
|
||||
@@ -161,7 +187,7 @@ func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *
|
||||
}
|
||||
|
||||
// handleQuery exécute un SELECT et retourne plusieurs lignes.
|
||||
func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
func handleQuery(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
query, args, err := extractQueryParams(req.Params)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
|
||||
@@ -169,12 +195,14 @@ func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
logError(appID, "error", "query_failed", map[string]any{"query": query, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results, err := scanRows(rows)
|
||||
if err != nil {
|
||||
logError(appID, "error", "scan_failed", map[string]any{"query": query, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
|
||||
}
|
||||
|
||||
@@ -182,7 +210,7 @@ func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
}
|
||||
|
||||
// handleQueryOne exécute un SELECT et retourne une seule ligne.
|
||||
func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
func handleQueryOne(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
query, args, err := extractQueryParams(req.Params)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
|
||||
@@ -190,12 +218,14 @@ func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
logError(appID, "error", "query_failed", map[string]any{"query": query, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results, err := scanRows(rows)
|
||||
if err != nil {
|
||||
logError(appID, "error", "scan_failed", map[string]any{"query": query, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
|
||||
}
|
||||
|
||||
@@ -207,7 +237,7 @@ func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
}
|
||||
|
||||
// handleInsert exécute un INSERT et retourne l'ID inséré.
|
||||
func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
func handleInsert(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
table, ok := req.Params["table"].(string)
|
||||
if !ok {
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||
@@ -236,6 +266,7 @@ func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
|
||||
result, err := db.Exec(query, values...)
|
||||
if err != nil {
|
||||
logError(appID, "error", "insert_failed", map[string]any{"table": table, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "INSERT_ERROR", err.Error())
|
||||
}
|
||||
|
||||
@@ -246,7 +277,7 @@ func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
}
|
||||
|
||||
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
|
||||
func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
func handleUpdate(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
table, ok := req.Params["table"].(string)
|
||||
if !ok {
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||
@@ -285,6 +316,7 @@ func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
|
||||
result, err := db.Exec(query, values...)
|
||||
if err != nil {
|
||||
logError(appID, "error", "update_failed", map[string]any{"table": table, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "UPDATE_ERROR", err.Error())
|
||||
}
|
||||
|
||||
@@ -295,7 +327,7 @@ func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
}
|
||||
|
||||
// handleDelete exécute un DELETE et retourne le nombre de lignes affectées.
|
||||
func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
func handleDelete(req *protocol.Request, db *sql.DB, appID string) *protocol.Response {
|
||||
table, ok := req.Params["table"].(string)
|
||||
if !ok {
|
||||
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||
@@ -321,6 +353,7 @@ func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||
|
||||
result, err := db.Exec(query, values...)
|
||||
if err != nil {
|
||||
logError(appID, "error", "delete_failed", map[string]any{"table": table, "error": err.Error()})
|
||||
return protocol.Failure(req.ID, "DELETE_ERROR", err.Error())
|
||||
}
|
||||
|
||||
|
||||
285
cmd/sogoms/logs/main.go
Normal file
285
cmd/sogoms/logs/main.go
Normal file
@@ -0,0 +1,285 @@
|
||||
// sogoms-logs : Microservice de logging centralisé.
|
||||
// Écrit les logs applicatifs dans des fichiers avec rotation automatique.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
socketPath = flag.String("socket", "/run/sogoms-logs.1.sock", "Unix socket path")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
logDir = flag.String("logdir", "/var/log/sogoms", "Log files directory")
|
||||
retentionDays = flag.Int("retention", 30, "Default retention days")
|
||||
)
|
||||
|
||||
// LogEntry représente une entrée de log au format JSON.
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
App string `json:"app"`
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// LogPool gère les fichiers de log par application.
|
||||
type LogPool struct {
|
||||
registry *config.Registry
|
||||
logDir string
|
||||
retentionDays int
|
||||
files map[string]*os.File // clé: "{app}-{date}-{type}"
|
||||
mu sync.Mutex
|
||||
stopRotation chan struct{}
|
||||
}
|
||||
|
||||
func NewLogPool(registry *config.Registry, logDir string, retentionDays int) *LogPool {
|
||||
return &LogPool{
|
||||
registry: registry,
|
||||
logDir: logDir,
|
||||
retentionDays: retentionDays,
|
||||
files: make(map[string]*os.File),
|
||||
stopRotation: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start démarre la goroutine de rotation des logs.
|
||||
func (p *LogPool) Start() {
|
||||
// Créer le répertoire de logs si nécessaire
|
||||
if err := os.MkdirAll(p.logDir, 0755); err != nil {
|
||||
log.Printf("[logs] warning: cannot create log dir: %v", err)
|
||||
}
|
||||
|
||||
// Rotation initiale
|
||||
p.rotate()
|
||||
|
||||
// Goroutine de rotation quotidienne
|
||||
go func() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
p.rotate()
|
||||
case <-p.stopRotation:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// rotate supprime les fichiers de log plus vieux que retentionDays.
|
||||
func (p *LogPool) rotate() {
|
||||
cutoff := time.Now().AddDate(0, 0, -p.retentionDays)
|
||||
entries, err := os.ReadDir(p.logDir)
|
||||
if err != nil {
|
||||
log.Printf("[logs] rotation error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
path := filepath.Join(p.logDir, entry.Name())
|
||||
if err := os.Remove(path); err == nil {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Printf("[logs] rotation: deleted %d old files", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// Write écrit une entrée de log dans le fichier approprié.
|
||||
func (p *LogPool) Write(appID, logType string, entry *LogEntry) error {
|
||||
date := time.Now().Format("20060102")
|
||||
key := fmt.Sprintf("%s-%s-%s", appID, date, logType)
|
||||
filename := filepath.Join(p.logDir, key+".log")
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Obtenir ou créer le fichier
|
||||
f, ok := p.files[key]
|
||||
if !ok {
|
||||
var err error
|
||||
f, err = os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
p.files[key] = f
|
||||
}
|
||||
|
||||
// Écrire l'entrée JSON
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal entry: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fmt.Errorf("write entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close ferme tous les fichiers ouverts.
|
||||
func (p *LogPool) Close() {
|
||||
close(p.stopRotation)
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for key, f := range p.files {
|
||||
f.Close()
|
||||
delete(p.files, key)
|
||||
}
|
||||
log.Printf("[logs] closed all log files")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||
|
||||
// Charger les configurations
|
||||
registry := config.NewRegistry(*configDir)
|
||||
if err := registry.Load(); err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
log.Printf("[logs] loaded apps: %v", registry.Apps())
|
||||
|
||||
// Pool de fichiers logs
|
||||
logPool := NewLogPool(registry, *logDir, *retentionDays)
|
||||
logPool.Start()
|
||||
defer logPool.Close()
|
||||
|
||||
// Handler des requêtes
|
||||
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
|
||||
return handleRequest(ctx, req, logPool)
|
||||
}
|
||||
|
||||
// Démarrer le serveur
|
||||
server := protocol.NewServer(*socketPath, handler)
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("start server: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[logs] sogoms-logs started on %s", *socketPath)
|
||||
|
||||
// Attendre signal d'arrêt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Printf("[logs] shutting down...")
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
func handleRequest(ctx context.Context, req *protocol.Request, logPool *LogPool) *protocol.Response {
|
||||
switch req.Action {
|
||||
case "health":
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||
case "log_error":
|
||||
return handleLogError(req, logPool)
|
||||
case "log_event":
|
||||
return handleLogEvent(req, logPool)
|
||||
default:
|
||||
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogError écrit un log d'erreur.
|
||||
// Params: app_id, level (error|warn|info), message, context (optional)
|
||||
func handleLogError(req *protocol.Request, logPool *LogPool) *protocol.Response {
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
}
|
||||
|
||||
level, _ := req.Params["level"].(string)
|
||||
if level == "" {
|
||||
level = "error"
|
||||
}
|
||||
|
||||
message, ok := req.Params["message"].(string)
|
||||
if !ok || message == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_MESSAGE", "message is required")
|
||||
}
|
||||
|
||||
var context map[string]any
|
||||
if ctx, ok := req.Params["context"].(map[string]any); ok {
|
||||
context = ctx
|
||||
}
|
||||
|
||||
entry := &LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
App: appID,
|
||||
Type: "error",
|
||||
Level: level,
|
||||
Message: message,
|
||||
Context: context,
|
||||
}
|
||||
|
||||
if err := logPool.Write(appID, "error", entry); err != nil {
|
||||
return protocol.Failure(req.ID, "WRITE_ERROR", err.Error())
|
||||
}
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{"logged": true})
|
||||
}
|
||||
|
||||
// handleLogEvent écrit un log d'événement.
|
||||
// Params: app_id, event_type, data
|
||||
func handleLogEvent(req *protocol.Request, logPool *LogPool) *protocol.Response {
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
}
|
||||
|
||||
eventType, ok := req.Params["event_type"].(string)
|
||||
if !ok || eventType == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_EVENT_TYPE", "event_type is required")
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if d, ok := req.Params["data"].(map[string]any); ok {
|
||||
data = d
|
||||
}
|
||||
|
||||
entry := &LogEntry{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
App: appID,
|
||||
Type: "event",
|
||||
EventType: eventType,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if err := logPool.Write(appID, "event", entry); err != nil {
|
||||
return protocol.Failure(req.ID, "WRITE_ERROR", err.Error())
|
||||
}
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{"logged": true})
|
||||
}
|
||||
706
cmd/sogoms/smtp/main.go
Normal file
706
cmd/sogoms/smtp/main.go
Normal file
@@ -0,0 +1,706 @@
|
||||
// sogoms-smtp : Microservice d'envoi d'emails.
|
||||
// Envoie des emails via SMTP avec support des templates YAML.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"sogoms.com/internal/config"
|
||||
"sogoms.com/internal/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
socketPath = flag.String("socket", "/run/sogoms-smtp.1.sock", "Unix socket path")
|
||||
configDir = flag.String("config", "/config", "Configuration directory")
|
||||
logsSocket = flag.String("logs-socket", "/run/sogoms-logs.1.sock", "Logs service socket")
|
||||
)
|
||||
|
||||
// SMTPConfig représente la configuration SMTP d'une application.
|
||||
type SMTPConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
PasswordFile string `yaml:"password_file"`
|
||||
From string `yaml:"from"`
|
||||
FromName string `yaml:"from_name"`
|
||||
TLS bool `yaml:"tls"`
|
||||
password string
|
||||
}
|
||||
|
||||
// EmailTemplate représente un template d'email.
|
||||
type EmailTemplate struct {
|
||||
Subject string `yaml:"subject"`
|
||||
Body string `yaml:"body"`
|
||||
BodyHTML string `yaml:"body_html"`
|
||||
}
|
||||
|
||||
// SMTPPool gère les connexions SMTP par application.
|
||||
type SMTPPool struct {
|
||||
registry *config.Registry
|
||||
configDir string
|
||||
configs map[string]*SMTPConfig // appID -> config SMTP
|
||||
templates map[string]map[string]*EmailTemplate // appID -> templateName -> template
|
||||
logsPool *protocol.Pool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewSMTPPool(registry *config.Registry, configDir string, logsPool *protocol.Pool) *SMTPPool {
|
||||
return &SMTPPool{
|
||||
registry: registry,
|
||||
configDir: configDir,
|
||||
configs: make(map[string]*SMTPConfig),
|
||||
templates: make(map[string]map[string]*EmailTemplate),
|
||||
logsPool: logsPool,
|
||||
}
|
||||
}
|
||||
|
||||
// Load charge les configurations SMTP et templates pour toutes les apps.
|
||||
func (p *SMTPPool) Load() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for _, appID := range p.registry.Apps() {
|
||||
// Charger config SMTP depuis config/routes/{app}.yaml (section smtp:)
|
||||
smtpCfg, err := p.loadSMTPConfig(appID)
|
||||
if err != nil {
|
||||
log.Printf("[smtp] warning: no SMTP config for %s: %v", appID, err)
|
||||
continue
|
||||
}
|
||||
p.configs[appID] = smtpCfg
|
||||
|
||||
// Charger templates depuis config/emails/{app}/
|
||||
templates, err := p.loadTemplates(appID)
|
||||
if err != nil {
|
||||
log.Printf("[smtp] warning: no templates for %s: %v", appID, err)
|
||||
}
|
||||
p.templates[appID] = templates
|
||||
|
||||
log.Printf("[smtp] loaded config for %s (%d templates)", appID, len(templates))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSMTPConfig charge la config SMTP depuis un fichier YAML d'application.
|
||||
func (p *SMTPPool) loadSMTPConfig(appID string) (*SMTPConfig, error) {
|
||||
path := filepath.Join(p.configDir, "routes", appID+".yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
SMTP *SMTPConfig `yaml:"smtp"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw.SMTP == nil {
|
||||
return nil, fmt.Errorf("no smtp section")
|
||||
}
|
||||
|
||||
cfg := raw.SMTP
|
||||
|
||||
// Port par défaut
|
||||
if cfg.Port == 0 {
|
||||
if cfg.TLS {
|
||||
cfg.Port = 465
|
||||
} else {
|
||||
cfg.Port = 587
|
||||
}
|
||||
}
|
||||
|
||||
// Charger le mot de passe depuis le fichier
|
||||
if cfg.PasswordFile != "" {
|
||||
passData, err := os.ReadFile(cfg.PasswordFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
cfg.password = strings.TrimSpace(string(passData))
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// loadTemplates charge les templates email pour une application.
|
||||
func (p *SMTPPool) loadTemplates(appID string) (map[string]*EmailTemplate, error) {
|
||||
templatesDir := filepath.Join(p.configDir, "emails", appID)
|
||||
entries, err := os.ReadDir(templatesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templates := make(map[string]*EmailTemplate)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(entry.Name(), ".yaml")
|
||||
path := filepath.Join(templatesDir, entry.Name())
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var tpl EmailTemplate
|
||||
if err := yaml.Unmarshal(data, &tpl); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
templates[name] = &tpl
|
||||
}
|
||||
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// GetConfig retourne la configuration SMTP pour une application.
|
||||
func (p *SMTPPool) GetConfig(appID string) (*SMTPConfig, bool) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
cfg, ok := p.configs[appID]
|
||||
return cfg, ok
|
||||
}
|
||||
|
||||
// GetTemplate retourne un template pour une application.
|
||||
func (p *SMTPPool) GetTemplate(appID, name string) (*EmailTemplate, bool) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if tpls, ok := p.templates[appID]; ok {
|
||||
tpl, ok := tpls[name]
|
||||
return tpl, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Send envoie un email.
|
||||
func (p *SMTPPool) Send(appID string, to []string, subject, body, bodyHTML string) error {
|
||||
cfg, ok := p.GetConfig(appID)
|
||||
if !ok {
|
||||
return fmt.Errorf("no SMTP config for app %s", appID)
|
||||
}
|
||||
|
||||
// Construire le message MIME
|
||||
msg := p.buildMessage(cfg, to, subject, body, bodyHTML)
|
||||
|
||||
// Envoyer
|
||||
return p.sendMail(cfg, to, msg)
|
||||
}
|
||||
|
||||
// buildMessage construit un message email MIME.
|
||||
func (p *SMTPPool) buildMessage(cfg *SMTPConfig, to []string, subject, body, bodyHTML string) []byte {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Headers
|
||||
from := cfg.From
|
||||
if cfg.FromName != "" {
|
||||
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From)
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("From: %s\r\n", from))
|
||||
buf.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", ")))
|
||||
buf.WriteString(fmt.Sprintf("Subject: =?UTF-8?B?%s?=\r\n", base64.StdEncoding.EncodeToString([]byte(subject))))
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
if bodyHTML != "" {
|
||||
// Multipart
|
||||
boundary := fmt.Sprintf("boundary_%d", time.Now().UnixNano())
|
||||
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
// Plain text part
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(body)))
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(bodyHTML)))
|
||||
buf.WriteString("\r\n")
|
||||
|
||||
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
} else {
|
||||
// Plain text only
|
||||
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString(base64.StdEncoding.EncodeToString([]byte(body)))
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// sendMail envoie un email via SMTP.
|
||||
func (p *SMTPPool) sendMail(cfg *SMTPConfig, to []string, msg []byte) error {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
if cfg.TLS {
|
||||
// TLS direct (port 465)
|
||||
return p.sendMailTLS(cfg, addr, to, msg)
|
||||
}
|
||||
|
||||
// STARTTLS (port 587)
|
||||
return p.sendMailSTARTTLS(cfg, addr, to, msg)
|
||||
}
|
||||
|
||||
// sendMailTLS envoie via TLS direct (port 465).
|
||||
func (p *SMTPPool) sendMailTLS(cfg *SMTPConfig, addr string, to []string, msg []byte) error {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: cfg.Host,
|
||||
}
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TLS dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return p.sendMailClient(client, cfg, to, msg)
|
||||
}
|
||||
|
||||
// sendMailSTARTTLS envoie via STARTTLS (port 587).
|
||||
func (p *SMTPPool) sendMailSTARTTLS(cfg *SMTPConfig, addr string, to []string, msg []byte) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// STARTTLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: cfg.Host,
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("STARTTLS: %w", err)
|
||||
}
|
||||
|
||||
return p.sendMailClient(client, cfg, to, msg)
|
||||
}
|
||||
|
||||
// sendMailClient envoie un email via un client SMTP établi.
|
||||
func (p *SMTPPool) sendMailClient(client *smtp.Client, cfg *SMTPConfig, to []string, msg []byte) error {
|
||||
// Auth
|
||||
if cfg.User != "" && cfg.password != "" {
|
||||
auth := smtp.PlainAuth("", cfg.User, cfg.password, cfg.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// From
|
||||
if err := client.Mail(cfg.From); err != nil {
|
||||
return fmt.Errorf("MAIL FROM: %w", err)
|
||||
}
|
||||
|
||||
// To
|
||||
for _, addr := range to {
|
||||
if err := client.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("RCPT TO %s: %w", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// Log envoie un log au service logs.
|
||||
func (p *SMTPPool) Log(appID, level, message string, data map[string]any) {
|
||||
if p.logsPool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := protocol.NewRequest("log_event", map[string]any{
|
||||
"app_id": appID,
|
||||
"event_type": "email_" + level,
|
||||
"data": data,
|
||||
})
|
||||
p.logsPool.Call(ctx, req)
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||
|
||||
// Charger les configurations
|
||||
registry := config.NewRegistry(*configDir)
|
||||
if err := registry.Load(); err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
log.Printf("[smtp] loaded apps: %v", registry.Apps())
|
||||
|
||||
// Pool de logs (optionnel)
|
||||
var logsPool *protocol.Pool
|
||||
if *logsSocket != "" {
|
||||
logsPool = protocol.NewPool(*logsSocket, 2)
|
||||
}
|
||||
|
||||
// Pool SMTP
|
||||
smtpPool := NewSMTPPool(registry, *configDir, logsPool)
|
||||
if err := smtpPool.Load(); err != nil {
|
||||
log.Fatalf("load SMTP config: %v", err)
|
||||
}
|
||||
|
||||
// Handler des requêtes
|
||||
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
|
||||
return handleRequest(ctx, req, smtpPool)
|
||||
}
|
||||
|
||||
// Démarrer le serveur
|
||||
server := protocol.NewServer(*socketPath, handler)
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatalf("start server: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[smtp] sogoms-smtp started on %s", *socketPath)
|
||||
|
||||
// Attendre signal d'arrêt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Printf("[smtp] shutting down...")
|
||||
server.Stop()
|
||||
}
|
||||
|
||||
func handleRequest(ctx context.Context, req *protocol.Request, pool *SMTPPool) *protocol.Response {
|
||||
switch req.Action {
|
||||
case "health":
|
||||
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||
case "send":
|
||||
return handleSend(req, pool)
|
||||
case "send_template":
|
||||
return handleSendTemplate(req, pool)
|
||||
case "send_bulk":
|
||||
return handleSendBulk(req, pool)
|
||||
default:
|
||||
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSend envoie un email simple.
|
||||
// Params: app_id, to (string ou []string), subject, body, body_html (optionnel)
|
||||
func handleSend(req *protocol.Request, pool *SMTPPool) *protocol.Response {
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
}
|
||||
|
||||
// To peut être string ou []string
|
||||
var to []string
|
||||
switch v := req.Params["to"].(type) {
|
||||
case string:
|
||||
to = []string{v}
|
||||
case []any:
|
||||
for _, addr := range v {
|
||||
if s, ok := addr.(string); ok {
|
||||
to = append(to, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return protocol.Failure(req.ID, "MISSING_TO", "to is required (string or array)")
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
|
||||
}
|
||||
|
||||
subject, ok := req.Params["subject"].(string)
|
||||
if !ok || subject == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_SUBJECT", "subject is required")
|
||||
}
|
||||
|
||||
body, _ := req.Params["body"].(string)
|
||||
bodyHTML, _ := req.Params["body_html"].(string)
|
||||
|
||||
if body == "" && bodyHTML == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_BODY", "body or body_html is required")
|
||||
}
|
||||
|
||||
// Envoyer
|
||||
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
|
||||
pool.Log(appID, "error", "send failed", map[string]any{
|
||||
"to": to,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return protocol.Failure(req.ID, "SEND_ERROR", err.Error())
|
||||
}
|
||||
|
||||
pool.Log(appID, "sent", "email sent", map[string]any{
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
})
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{"sent": true, "recipients": len(to)})
|
||||
}
|
||||
|
||||
// handleSendTemplate envoie un email à partir d'un template.
|
||||
// Params: app_id, to, template, data (map pour le template)
|
||||
func handleSendTemplate(req *protocol.Request, pool *SMTPPool) *protocol.Response {
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
}
|
||||
|
||||
// To
|
||||
var to []string
|
||||
switch v := req.Params["to"].(type) {
|
||||
case string:
|
||||
to = []string{v}
|
||||
case []any:
|
||||
for _, addr := range v {
|
||||
if s, ok := addr.(string); ok {
|
||||
to = append(to, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
return protocol.Failure(req.ID, "MISSING_TO", "to is required")
|
||||
}
|
||||
|
||||
templateName, ok := req.Params["template"].(string)
|
||||
if !ok || templateName == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_TEMPLATE", "template is required")
|
||||
}
|
||||
|
||||
// Charger le template
|
||||
tpl, ok := pool.GetTemplate(appID, templateName)
|
||||
if !ok {
|
||||
return protocol.Failure(req.ID, "TEMPLATE_NOT_FOUND", "template not found: "+templateName)
|
||||
}
|
||||
|
||||
// Data pour le template
|
||||
data := make(map[string]any)
|
||||
if d, ok := req.Params["data"].(map[string]any); ok {
|
||||
data = d
|
||||
}
|
||||
|
||||
// Rendre le template
|
||||
subject, err := renderTemplate(tpl.Subject, data)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "subject: "+err.Error())
|
||||
}
|
||||
|
||||
body, err := renderTemplate(tpl.Body, data)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "body: "+err.Error())
|
||||
}
|
||||
|
||||
var bodyHTML string
|
||||
if tpl.BodyHTML != "" {
|
||||
bodyHTML, err = renderTemplate(tpl.BodyHTML, data)
|
||||
if err != nil {
|
||||
return protocol.Failure(req.ID, "TEMPLATE_ERROR", "body_html: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer
|
||||
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
|
||||
pool.Log(appID, "error", "send_template failed", map[string]any{
|
||||
"to": to,
|
||||
"template": templateName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return protocol.Failure(req.ID, "SEND_ERROR", err.Error())
|
||||
}
|
||||
|
||||
pool.Log(appID, "sent", "template email sent", map[string]any{
|
||||
"to": to,
|
||||
"template": templateName,
|
||||
})
|
||||
|
||||
return protocol.Success(req.ID, map[string]any{"sent": true, "recipients": len(to), "template": templateName})
|
||||
}
|
||||
|
||||
// handleSendBulk envoie des emails en masse.
|
||||
// Params: app_id, recipients ([]map avec to, subject, body ou template+data)
|
||||
func handleSendBulk(req *protocol.Request, pool *SMTPPool) *protocol.Response {
|
||||
appID, ok := req.Params["app_id"].(string)
|
||||
if !ok || appID == "" {
|
||||
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||
}
|
||||
|
||||
recipients, ok := req.Params["recipients"].([]any)
|
||||
if !ok || len(recipients) == 0 {
|
||||
return protocol.Failure(req.ID, "MISSING_RECIPIENTS", "recipients array is required")
|
||||
}
|
||||
|
||||
sent := 0
|
||||
failed := 0
|
||||
var errors []string
|
||||
|
||||
for i, r := range recipients {
|
||||
rcpt, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: invalid recipient format", i))
|
||||
continue
|
||||
}
|
||||
|
||||
// To
|
||||
var to []string
|
||||
switch v := rcpt["to"].(type) {
|
||||
case string:
|
||||
to = []string{v}
|
||||
case []any:
|
||||
for _, addr := range v {
|
||||
if s, ok := addr.(string); ok {
|
||||
to = append(to, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(to) == 0 {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: missing to", i))
|
||||
continue
|
||||
}
|
||||
|
||||
var subject, body, bodyHTML string
|
||||
|
||||
// Template ou direct
|
||||
if templateName, ok := rcpt["template"].(string); ok && templateName != "" {
|
||||
tpl, ok := pool.GetTemplate(appID, templateName)
|
||||
if !ok {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: template not found: %s", i, templateName))
|
||||
continue
|
||||
}
|
||||
|
||||
data := make(map[string]any)
|
||||
if d, ok := rcpt["data"].(map[string]any); ok {
|
||||
data = d
|
||||
}
|
||||
|
||||
var err error
|
||||
subject, err = renderTemplate(tpl.Subject, data)
|
||||
if err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: subject template error", i))
|
||||
continue
|
||||
}
|
||||
|
||||
body, _ = renderTemplate(tpl.Body, data)
|
||||
if tpl.BodyHTML != "" {
|
||||
bodyHTML, _ = renderTemplate(tpl.BodyHTML, data)
|
||||
}
|
||||
} else {
|
||||
subject, _ = rcpt["subject"].(string)
|
||||
body, _ = rcpt["body"].(string)
|
||||
bodyHTML, _ = rcpt["body_html"].(string)
|
||||
}
|
||||
|
||||
if subject == "" {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: missing subject", i))
|
||||
continue
|
||||
}
|
||||
|
||||
if body == "" && bodyHTML == "" {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: missing body", i))
|
||||
continue
|
||||
}
|
||||
|
||||
// Envoyer
|
||||
if err := pool.Send(appID, to, subject, body, bodyHTML); err != nil {
|
||||
failed++
|
||||
errors = append(errors, fmt.Sprintf("#%d: %s", i, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
sent++
|
||||
}
|
||||
|
||||
pool.Log(appID, "bulk", "bulk send completed", map[string]any{
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
})
|
||||
|
||||
result := map[string]any{
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"total": len(recipients),
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
result["errors"] = errors
|
||||
}
|
||||
|
||||
return protocol.Success(req.ID, result)
|
||||
}
|
||||
|
||||
// renderTemplate rend un template Go text/template avec les données.
|
||||
func renderTemplate(text string, data map[string]any) (string, error) {
|
||||
if text == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
tpl, err := template.New("email").Parse(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
41
config/emails/prokov/password_reset.yaml
Normal file
41
config/emails/prokov/password_reset.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
# Template: Réinitialisation de mot de passe
|
||||
subject: "Réinitialisation de votre mot de passe Prokov"
|
||||
|
||||
body: |
|
||||
Bonjour {{.Name}},
|
||||
|
||||
Vous avez demandé la réinitialisation de votre mot de passe.
|
||||
|
||||
Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :
|
||||
{{.ResetURL}}
|
||||
|
||||
Ce lien expire dans {{.ExpiresIn}}.
|
||||
|
||||
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
|
||||
|
||||
Cordialement,
|
||||
L'équipe Prokov
|
||||
|
||||
body_html: |
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Réinitialisation de mot de passe</h1>
|
||||
<p>Bonjour <strong>{{.Name}}</strong>,</p>
|
||||
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{.ResetURL}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Réinitialiser mon mot de passe
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">Ce lien expire dans {{.ExpiresIn}}.</p>
|
||||
<p style="color: #666; font-size: 14px;">Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 14px;">Cordialement,<br>L'équipe Prokov</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
45
config/emails/prokov/task_assigned.yaml
Normal file
45
config/emails/prokov/task_assigned.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Template: Notification d'assignation de tâche
|
||||
subject: "Nouvelle tâche : {{.TaskName}}"
|
||||
|
||||
body: |
|
||||
Bonjour {{.Name}},
|
||||
|
||||
Une nouvelle tâche vous a été assignée :
|
||||
|
||||
Projet : {{.ProjectName}}
|
||||
Tâche : {{.TaskName}}
|
||||
{{if .Description}}Description : {{.Description}}{{end}}
|
||||
{{if .DueDate}}Échéance : {{.DueDate}}{{end}}
|
||||
|
||||
Connectez-vous pour voir les détails.
|
||||
|
||||
Cordialement,
|
||||
L'équipe Prokov
|
||||
|
||||
body_html: |
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Nouvelle tâche assignée</h1>
|
||||
<p>Bonjour <strong>{{.Name}}</strong>,</p>
|
||||
<p>Une nouvelle tâche vous a été assignée :</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0;">
|
||||
<p style="margin: 5px 0;"><strong>Projet :</strong> {{.ProjectName}}</p>
|
||||
<p style="margin: 5px 0;"><strong>Tâche :</strong> {{.TaskName}}</p>
|
||||
{{if .Description}}<p style="margin: 5px 0;"><strong>Description :</strong> {{.Description}}</p>{{end}}
|
||||
{{if .DueDate}}<p style="margin: 5px 0;"><strong>Échéance :</strong> {{.DueDate}}</p>{{end}}
|
||||
</div>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{.TaskURL}}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Voir la tâche
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 14px;">Cordialement,<br>L'équipe Prokov</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
67
config/emails/prokov/tasks_today.yaml
Normal file
67
config/emails/prokov/tasks_today.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
# Template: Récapitulatif des tâches du jour
|
||||
subject: "Vos tâches du jour - {{.Date}}"
|
||||
|
||||
body: |
|
||||
Bonjour {{.Name}},
|
||||
|
||||
Voici vos tâches prévues pour aujourd'hui ({{.Date}}) :
|
||||
|
||||
{{range .Tasks}}
|
||||
- [{{.Status}}] {{.Name}}{{if .Project}} ({{.Project}}){{end}}{{if .DueTime}} - {{.DueTime}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .TaskCount}}Vous avez {{.TaskCount}} tâche(s) à accomplir.{{end}}
|
||||
|
||||
Bonne journée !
|
||||
|
||||
Cordialement,
|
||||
L'équipe Prokov
|
||||
|
||||
body_html: |
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Vos tâches du jour</h1>
|
||||
<p>Bonjour <strong>{{.Name}}</strong>,</p>
|
||||
<p>Voici vos tâches prévues pour aujourd'hui ({{.Date}}) :</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background-color: #f8fafc;">
|
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #e2e8f0;">Tâche</th>
|
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #e2e8f0;">Projet</th>
|
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #e2e8f0;">Statut</th>
|
||||
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #e2e8f0;">Heure</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Tasks}}
|
||||
<tr>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0;">{{.Name}}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0;">{{.Project}}</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0;">
|
||||
<span style="background-color: {{.StatusColor}}; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">{{.Status}}</span>
|
||||
</td>
|
||||
<td style="padding: 12px; border-bottom: 1px solid #e2e8f0;">{{.DueTime}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if .TaskCount}}
|
||||
<p style="background-color: #eff6ff; padding: 15px; border-radius: 6px; text-align: center;">
|
||||
<strong>{{.TaskCount}}</strong> tâche(s) à accomplir aujourd'hui
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p>Bonne journée !</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 14px;">Cordialement,<br>L'équipe Prokov</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
34
config/emails/prokov/welcome.yaml
Normal file
34
config/emails/prokov/welcome.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Template: Email de bienvenue
|
||||
subject: "Bienvenue sur Prokov, {{.Name}} !"
|
||||
|
||||
body: |
|
||||
Bonjour {{.Name}},
|
||||
|
||||
Bienvenue sur Prokov !
|
||||
|
||||
Votre compte a été créé avec succès.
|
||||
Email: {{.Email}}
|
||||
|
||||
Vous pouvez maintenant vous connecter et commencer à gérer vos projets.
|
||||
|
||||
Cordialement,
|
||||
L'équipe Prokov
|
||||
|
||||
body_html: |
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2563eb;">Bienvenue sur Prokov !</h1>
|
||||
<p>Bonjour <strong>{{.Name}}</strong>,</p>
|
||||
<p>Votre compte a été créé avec succès.</p>
|
||||
<p><strong>Email :</strong> {{.Email}}</p>
|
||||
<p>Vous pouvez maintenant vous connecter et commencer à gérer vos projets.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 14px;">Cordialement,<br>L'équipe Prokov</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
config/queries/prokov/auth.yaml
Normal file
25
config/queries/prokov/auth.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# Requêtes d'authentification
|
||||
|
||||
# Données chargées après login réussi
|
||||
login_data:
|
||||
projects: >
|
||||
SELECT id, parent_id, name, description, position, created_at, updated_at
|
||||
FROM projects WHERE user_id = ? ORDER BY position
|
||||
tasks: >
|
||||
SELECT id, project_id, status_id, title, description, priority,
|
||||
date_start, date_end, time_estimated, time_spent, billing, position,
|
||||
created_at, updated_at
|
||||
FROM tasks WHERE user_id = ? ORDER BY position
|
||||
tags: >
|
||||
SELECT id, name, color, created_at
|
||||
FROM tags WHERE user_id = ?
|
||||
statuses: >
|
||||
SELECT id, project_id, code, name, color, position, created_at
|
||||
FROM statuses WHERE user_id = ? ORDER BY code
|
||||
|
||||
# Requêtes unitaires
|
||||
user_by_email: >
|
||||
SELECT id, email, name, password FROM users WHERE email = ?
|
||||
|
||||
user_by_id: >
|
||||
SELECT id, email, name, created_at FROM users WHERE id = ?
|
||||
44
config/queries/prokov/projects.yaml
Normal file
44
config/queries/prokov/projects.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
# Requêtes CRUD projects
|
||||
|
||||
list:
|
||||
query: >
|
||||
SELECT id, parent_id, name, description, position, created_at, updated_at
|
||||
FROM projects
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "position ASC"
|
||||
|
||||
show:
|
||||
query: >
|
||||
SELECT id, parent_id, name, description, position, created_at, updated_at
|
||||
FROM projects WHERE id = :id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
create:
|
||||
table: projects
|
||||
fields:
|
||||
- user_id
|
||||
- parent_id
|
||||
- name
|
||||
- description
|
||||
- position
|
||||
|
||||
update:
|
||||
table: projects
|
||||
fields:
|
||||
- parent_id
|
||||
- name
|
||||
- description
|
||||
- position
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
delete:
|
||||
table: projects
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
55
config/queries/prokov/statuses.yaml
Normal file
55
config/queries/prokov/statuses.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Requêtes CRUD statuses
|
||||
|
||||
list:
|
||||
query: >
|
||||
SELECT id, project_id, code, name, color, position, created_at
|
||||
FROM statuses
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "code ASC"
|
||||
|
||||
list_by_project:
|
||||
query: >
|
||||
SELECT id, project_id, code, name, color, position, created_at
|
||||
FROM statuses WHERE (project_id = :project_id OR project_id IS NULL)
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "code ASC"
|
||||
|
||||
show:
|
||||
query: >
|
||||
SELECT id, project_id, code, name, color, position, created_at
|
||||
FROM statuses WHERE id = :id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
create:
|
||||
table: statuses
|
||||
fields:
|
||||
- user_id
|
||||
- project_id
|
||||
- code
|
||||
- name
|
||||
- color
|
||||
- position
|
||||
|
||||
update:
|
||||
table: statuses
|
||||
fields:
|
||||
- project_id
|
||||
- code
|
||||
- name
|
||||
- color
|
||||
- position
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
delete:
|
||||
table: statuses
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
39
config/queries/prokov/tags.yaml
Normal file
39
config/queries/prokov/tags.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Requêtes CRUD tags
|
||||
|
||||
list:
|
||||
query: >
|
||||
SELECT id, name, color, created_at
|
||||
FROM tags
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
show:
|
||||
query: >
|
||||
SELECT id, name, color, created_at
|
||||
FROM tags WHERE id = :id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
create:
|
||||
table: tags
|
||||
fields:
|
||||
- user_id
|
||||
- name
|
||||
- color
|
||||
|
||||
update:
|
||||
table: tags
|
||||
fields:
|
||||
- name
|
||||
- color
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
delete:
|
||||
table: tags
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
73
config/queries/prokov/tasks.yaml
Normal file
73
config/queries/prokov/tasks.yaml
Normal file
@@ -0,0 +1,73 @@
|
||||
# Requêtes CRUD tasks
|
||||
|
||||
list:
|
||||
query: >
|
||||
SELECT id, project_id, status_id, title, description, priority,
|
||||
date_start, date_end, time_estimated, time_spent, billing, position,
|
||||
created_at, updated_at
|
||||
FROM tasks
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "position ASC"
|
||||
|
||||
list_by_project:
|
||||
query: >
|
||||
SELECT id, project_id, status_id, title, description, priority,
|
||||
date_start, date_end, time_estimated, time_spent, billing, position,
|
||||
created_at, updated_at
|
||||
FROM tasks WHERE project_id = :project_id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
order: "position ASC"
|
||||
|
||||
show:
|
||||
query: >
|
||||
SELECT id, project_id, status_id, title, description, priority,
|
||||
date_start, date_end, time_estimated, time_spent, billing, position,
|
||||
created_at, updated_at
|
||||
FROM tasks WHERE id = :id
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
create:
|
||||
table: tasks
|
||||
fields:
|
||||
- user_id
|
||||
- project_id
|
||||
- status_id
|
||||
- title
|
||||
- description
|
||||
- priority
|
||||
- date_start
|
||||
- date_end
|
||||
- time_estimated
|
||||
- time_spent
|
||||
- billing
|
||||
- position
|
||||
|
||||
update:
|
||||
table: tasks
|
||||
fields:
|
||||
- project_id
|
||||
- status_id
|
||||
- title
|
||||
- description
|
||||
- priority
|
||||
- date_start
|
||||
- date_end
|
||||
- time_estimated
|
||||
- time_spent
|
||||
- billing
|
||||
- position
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
|
||||
delete:
|
||||
table: tasks
|
||||
filters:
|
||||
default: "user_id = :user_id"
|
||||
admin: ""
|
||||
@@ -23,6 +23,20 @@ auth:
|
||||
jwt_secret_file: /secrets/prokov_jwt_secret
|
||||
jwt_expiry: 24h
|
||||
|
||||
# Logs
|
||||
logs:
|
||||
retention_days: 30
|
||||
|
||||
# SMTP
|
||||
smtp:
|
||||
host: barbotte.o2switch.net
|
||||
port: 465
|
||||
user: prokov@unikoffice.com
|
||||
password_file: /secrets/prokov_smtp_pass
|
||||
from: prokov@unikoffice.com
|
||||
from_name: Prokov
|
||||
tls: true # false = STARTTLS (587), true = TLS direct (465)
|
||||
|
||||
# Routes
|
||||
routes:
|
||||
# === AUTH ===
|
||||
|
||||
@@ -13,7 +13,35 @@ services:
|
||||
- "/config"
|
||||
- "-socket"
|
||||
- "/run/sogoms-db.1.sock"
|
||||
- "-logs-socket"
|
||||
- "/run/sogoms-logs.1.sock"
|
||||
health_socket: /run/sogoms-db.1.sock
|
||||
depends_on:
|
||||
- sogoms-logs
|
||||
|
||||
sogoms-logs:
|
||||
binary: /opt/sogoms/bin/sogoms-logs
|
||||
args:
|
||||
- "-config"
|
||||
- "/config"
|
||||
- "-socket"
|
||||
- "/run/sogoms-logs.1.sock"
|
||||
- "-logdir"
|
||||
- "/var/log/sogoms"
|
||||
health_socket: /run/sogoms-logs.1.sock
|
||||
|
||||
sogoms-smtp:
|
||||
binary: /opt/sogoms/bin/sogoms-smtp
|
||||
args:
|
||||
- "-config"
|
||||
- "/config"
|
||||
- "-socket"
|
||||
- "/run/sogoms-smtp.1.sock"
|
||||
- "-logs-socket"
|
||||
- "/run/sogoms-logs.1.sock"
|
||||
health_socket: /run/sogoms-smtp.1.sock
|
||||
depends_on:
|
||||
- sogoms-logs
|
||||
|
||||
sogoway:
|
||||
binary: /opt/sogoms/bin/sogoway
|
||||
@@ -24,6 +52,9 @@ services:
|
||||
- "8080"
|
||||
- "-db-socket"
|
||||
- "/run/sogoms-db.1.sock"
|
||||
- "-logs-socket"
|
||||
- "/run/sogoms-logs.1.sock"
|
||||
health_url: http://localhost:8080/health
|
||||
depends_on:
|
||||
- sogoms-db
|
||||
- sogoms-logs
|
||||
|
||||
50
deploy.sh
50
deploy.sh
@@ -78,10 +78,12 @@ echo_step "Building binaries v${VERSION} (linux/amd64)..."
|
||||
mkdir -p bin
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-logs ./cmd/sogoms/logs || echo_error "Failed to build sogoms-logs"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-smtp ./cmd/sogoms/smtp || echo_error "Failed to build sogoms-smtp"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl"
|
||||
|
||||
echo_info "Built: sogoms-db, sogoway, sogoctl (v${VERSION})"
|
||||
echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoway, sogoctl (v${VERSION})"
|
||||
|
||||
# Étape 2: Créer les archives
|
||||
echo_step "Creating archives..."
|
||||
@@ -117,23 +119,61 @@ $SSH_CMD "
|
||||
echo '📁 Deploying binaries...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
|
||||
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/
|
||||
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
|
||||
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
|
||||
|
||||
echo '📁 Deploying config...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios ${REMOTE_CONFIG}/queries ${REMOTE_CONFIG}/emails
|
||||
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
|
||||
|
||||
echo '📁 Setting up run directory...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p /run
|
||||
echo '📁 Setting up run and log directories...'
|
||||
incus exec ${INCUS_CONTAINER} -- mkdir -p /run /var/log/sogoms
|
||||
|
||||
echo '🧹 Cleanup...'
|
||||
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
|
||||
rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
|
||||
"
|
||||
|
||||
# Étape 5: Backup local des archives
|
||||
BACKUP_DIR="/home/pierre/samba/back/sogoms"
|
||||
echo_step "Backing up archives to ${BACKUP_DIR}..."
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
cp "/tmp/${BIN_ARCHIVE}" "${BACKUP_DIR}/"
|
||||
cp "/tmp/${CONFIG_ARCHIVE}" "${BACKUP_DIR}/"
|
||||
echo_info "Backed up: ${BIN_ARCHIVE}, ${CONFIG_ARCHIVE}"
|
||||
|
||||
# Nettoyage local
|
||||
rm -f "/tmp/${BIN_ARCHIVE}" "/tmp/${CONFIG_ARCHIVE}"
|
||||
|
||||
# Étape 6: Redémarrer sogoctl
|
||||
echo_step "Restarting sogoctl..."
|
||||
$SSH_CMD "
|
||||
echo '🛑 Stopping all sogoms processes...'
|
||||
incus exec ${INCUS_CONTAINER} -- pkill -9 sogoctl || true
|
||||
incus exec ${INCUS_CONTAINER} -- pkill -9 sogoms || true
|
||||
incus exec ${INCUS_CONTAINER} -- pkill -9 sogoway || true
|
||||
sleep 2
|
||||
|
||||
# Vérifier qu'ils sont tous morts
|
||||
if incus exec ${INCUS_CONTAINER} -- pgrep -la sogo > /dev/null 2>&1; then
|
||||
echo '⚠️ Some processes still running, force kill...'
|
||||
incus exec ${INCUS_CONTAINER} -- pkill -9 sogo || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo '🚀 Starting sogoctl...'
|
||||
incus exec ${INCUS_CONTAINER} -- sh -c 'nohup /opt/sogoms/bin/sogoctl > /var/log/sogoms/sogoctl.log 2>&1 &'
|
||||
sleep 3
|
||||
|
||||
# Vérifier le démarrage
|
||||
if incus exec ${INCUS_CONTAINER} -- pgrep -l sogoctl > /dev/null 2>&1; then
|
||||
echo '✅ sogoctl started'
|
||||
incus exec ${INCUS_CONTAINER} -- pgrep -la sogo
|
||||
else
|
||||
echo '❌ sogoctl failed to start'
|
||||
incus exec ${INCUS_CONTAINER} -- tail -20 /var/log/sogoms/sogoctl.log
|
||||
fi
|
||||
"
|
||||
|
||||
# Résumé final
|
||||
echo_step "Deployment completed successfully!"
|
||||
echo ""
|
||||
|
||||
@@ -20,6 +20,227 @@ type AppConfig struct {
|
||||
Database Database `yaml:"database"`
|
||||
Auth Auth `yaml:"auth"`
|
||||
Routes []Route `yaml:"routes"`
|
||||
Queries *Queries // Chargé depuis config/queries/{app}/
|
||||
}
|
||||
|
||||
// Queries stocke les requêtes SQL par domaine.
|
||||
type Queries struct {
|
||||
Auth map[string]any `yaml:",inline"`
|
||||
Projects map[string]any `yaml:",inline"`
|
||||
Tasks map[string]any `yaml:",inline"`
|
||||
Tags map[string]any `yaml:",inline"`
|
||||
Statuses map[string]any `yaml:",inline"`
|
||||
files map[string]map[string]any // domaine -> clé -> requête
|
||||
}
|
||||
|
||||
// Get retourne une requête par domaine et clé.
|
||||
func (q *Queries) Get(domain, key string) string {
|
||||
if q == nil || q.files == nil {
|
||||
return ""
|
||||
}
|
||||
if domainMap, ok := q.files[domain]; ok {
|
||||
if val, ok := domainMap[key]; ok {
|
||||
if s, ok := val.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetMap retourne une map de requêtes (ex: login_data).
|
||||
func (q *Queries) GetMap(domain, key string) map[string]string {
|
||||
if q == nil || q.files == nil {
|
||||
return nil
|
||||
}
|
||||
if domainMap, ok := q.files[domain]; ok {
|
||||
if val, ok := domainMap[key]; ok {
|
||||
if m, ok := val.(map[string]any); ok {
|
||||
result := make(map[string]string)
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
result[k] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryConfig représente une requête paramétrable.
|
||||
type QueryConfig struct {
|
||||
Query string
|
||||
Filters map[string]string
|
||||
Order string
|
||||
}
|
||||
|
||||
// GetQuery retourne une QueryConfig pour un domaine et une clé.
|
||||
func (q *Queries) GetQuery(domain, key string) *QueryConfig {
|
||||
if q == nil || q.files == nil {
|
||||
return nil
|
||||
}
|
||||
domainMap, ok := q.files[domain]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
val, ok := domainMap[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
// Ancienne syntaxe : juste une string
|
||||
if s, ok := val.(string); ok {
|
||||
return &QueryConfig{Query: strings.TrimSpace(s)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
qc := &QueryConfig{
|
||||
Filters: make(map[string]string),
|
||||
}
|
||||
|
||||
if query, ok := m["query"].(string); ok {
|
||||
qc.Query = strings.TrimSpace(query)
|
||||
}
|
||||
if order, ok := m["order"].(string); ok {
|
||||
qc.Order = strings.TrimSpace(order)
|
||||
}
|
||||
if filters, ok := m["filters"].(map[string]any); ok {
|
||||
for k, v := range filters {
|
||||
if s, ok := v.(string); ok {
|
||||
qc.Filters[k] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return qc
|
||||
}
|
||||
|
||||
// CUDConfig représente une config pour Create/Update/Delete.
|
||||
type CUDConfig struct {
|
||||
Table string
|
||||
Fields []string
|
||||
Filters map[string]string
|
||||
}
|
||||
|
||||
// GetCUD retourne une CUDConfig pour un domaine et une clé (create/update/delete).
|
||||
func (q *Queries) GetCUD(domain, key string) *CUDConfig {
|
||||
if q == nil || q.files == nil {
|
||||
return nil
|
||||
}
|
||||
domainMap, ok := q.files[domain]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
val, ok := domainMap[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
cud := &CUDConfig{
|
||||
Filters: make(map[string]string),
|
||||
}
|
||||
|
||||
if table, ok := m["table"].(string); ok {
|
||||
cud.Table = table
|
||||
}
|
||||
if fields, ok := m["fields"].([]any); ok {
|
||||
for _, f := range fields {
|
||||
if s, ok := f.(string); ok {
|
||||
cud.Fields = append(cud.Fields, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if filters, ok := m["filters"].(map[string]any); ok {
|
||||
for k, v := range filters {
|
||||
if s, ok := v.(string); ok {
|
||||
cud.Filters[k] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cud
|
||||
}
|
||||
|
||||
// GetFilter retourne le filtre pour un rôle donné.
|
||||
func (cud *CUDConfig) GetFilter(role string) string {
|
||||
if cud == nil {
|
||||
return ""
|
||||
}
|
||||
if f, ok := cud.Filters[role]; ok {
|
||||
return f
|
||||
}
|
||||
if f, ok := cud.Filters["default"]; ok {
|
||||
return f
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build construit la requête SQL finale avec filtres et ordre.
|
||||
// role: rôle de l'utilisateur (ou "default")
|
||||
// params: map de placeholders à remplacer (:user_id, :id, etc.)
|
||||
func (qc *QueryConfig) Build(role string, params map[string]any) (string, []any) {
|
||||
if qc == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
query := qc.Query
|
||||
|
||||
// Déterminer le filtre à appliquer
|
||||
filter := ""
|
||||
if f, ok := qc.Filters[role]; ok {
|
||||
filter = f
|
||||
} else if f, ok := qc.Filters["default"]; ok {
|
||||
filter = f
|
||||
}
|
||||
|
||||
// Construire la requête
|
||||
hasWhere := strings.Contains(strings.ToUpper(query), " WHERE ")
|
||||
|
||||
if filter != "" {
|
||||
if hasWhere {
|
||||
query += " AND " + filter
|
||||
} else {
|
||||
query += " WHERE " + filter
|
||||
}
|
||||
}
|
||||
|
||||
if qc.Order != "" {
|
||||
query += " ORDER BY " + qc.Order
|
||||
}
|
||||
|
||||
// Remplacer les placeholders :name par ? et collecter les args
|
||||
var args []any
|
||||
for {
|
||||
idx := strings.Index(query, ":")
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
// Trouver la fin du placeholder
|
||||
end := idx + 1
|
||||
for end < len(query) && (query[end] == '_' || (query[end] >= 'a' && query[end] <= 'z') || (query[end] >= 'A' && query[end] <= 'Z') || (query[end] >= '0' && query[end] <= '9')) {
|
||||
end++
|
||||
}
|
||||
placeholder := query[idx+1 : end]
|
||||
if val, ok := params[placeholder]; ok {
|
||||
args = append(args, val)
|
||||
query = query[:idx] + "?" + query[end:]
|
||||
} else {
|
||||
// Placeholder non trouvé, on laisse tel quel (peut être une erreur)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// Auth contient la configuration d'authentification.
|
||||
@@ -151,9 +372,48 @@ func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
|
||||
cfg.Auth.JWTExpiry = "24h"
|
||||
}
|
||||
|
||||
// Charger les requêtes depuis config/queries/{app}/
|
||||
cfg.Queries = r.loadQueries(cfg.App)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// loadQueries charge les fichiers de requêtes pour une application.
|
||||
func (r *Registry) loadQueries(appID string) *Queries {
|
||||
queriesDir := filepath.Join(r.configDir, "queries", appID)
|
||||
entries, err := os.ReadDir(queriesDir)
|
||||
if err != nil {
|
||||
return nil // Pas de répertoire queries, c'est OK
|
||||
}
|
||||
|
||||
q := &Queries{
|
||||
files: make(map[string]map[string]any),
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||
continue
|
||||
}
|
||||
|
||||
domain := strings.TrimSuffix(entry.Name(), ".yaml")
|
||||
path := filepath.Join(queriesDir, entry.Name())
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var content map[string]any
|
||||
if err := yaml.Unmarshal(data, &content); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
q.files[domain] = content
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// GetByApp retourne la configuration d'une application par son ID.
|
||||
func (r *Registry) GetByApp(appID string) (*AppConfig, bool) {
|
||||
r.mu.RLock()
|
||||
|
||||
Reference in New Issue
Block a user