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:
2025-12-16 14:58:46 +01:00
parent 7e27f87d6f
commit a4694a10d1
36 changed files with 2786 additions and 2387 deletions

561
DOCTECH.md Normal file
View 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
View File

@@ -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
View File

@@ -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)

View File

@@ -1 +1 @@
1.0.0
1.0.1

202
clients/prokov.sql Normal file
View 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

View File

@@ -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() {}
}

View File

@@ -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');
}

View File

@@ -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'],
]);
}
}
}

View File

@@ -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(),
]);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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(),
]);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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
View 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
View 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
}

View 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>

View 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>

View 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>

View 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>

View 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 = ?

View 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: ""

View 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: ""

View 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: ""

View 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: ""

View File

@@ -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 ===

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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()