diff --git a/DOCTECH.md b/DOCTECH.md new file mode 100644 index 0000000..61b5be6 --- /dev/null +++ b/DOCTECH.md @@ -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: | +

Bienvenue {{.Name}} !

+

Bienvenue sur notre plateforme.

+``` + +--- + +## 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) diff --git a/README.md b/README.md index d9d561d..b536044 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,49 @@ **Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant. -## Architecture +## Vision + +SOGOMS est un framework backend léger en Go qui transforme un simple fichier de schéma YAML en API REST complète. Conçu pour héberger plusieurs applications SaaS sur une même infrastructure avec isolation totale des données. ``` -Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB - ↓ - Unix Socket +schema.yaml → SOGOMS → API REST + Auth + CRUD + Push ``` -| Binaire | Rôle | Port/Socket | -|---------|------|-------------| -| `sogoctl` | Superviseur PID 1, health checks, restart auto | - | -| `sogoway` | Gateway HTTP, auth JWT, routing par hostname | TCP :8080 | -| `sogoms-db` | Accès MariaDB, pool par application | Unix socket | +## Caractéristiques + +- **Léger** : binaires Go compilés (~10MB), pas de dépendances runtime +- **Modulaire** : 1 fonctionnalité = 1 microservice +- **Configurable** : SQL et routing en YAML, sans recompilation +- **Sécurisé** : JWT, isolation par user_id, bcrypt +- **Auto-supervisé** : health checks, restart automatique +- **Temps réel** : push MQTT vers les applications (roadmap) +- **Schema-driven** : génération d'API depuis la structure DB (roadmap) + +## Services actuels + +| Service | Rôle | Statut | +|---------|------|--------| +| `sogoctl` | Superviseur, health checks | Stable | +| `sogoway` | Gateway HTTP, auth JWT, CRUD | Stable | +| `sogoms-db` | Accès MariaDB | Stable | +| `sogoms-logs` | Logging centralisé | Stable | +| `sogoms-smtp` | Envoi emails, templates | Stable | + +## Roadmap + +| Phase | Service | Description | +|-------|---------|-------------| +| 11 | sogoms-crypt | Chiffrement données sensibles | +| 12 | sogoms-imap/mailproc | Lecture et traitement emails | +| 13 | sogoms-cron | Tâches planifiées | +| 14 | sogoms-push | Push temps réel (MQTT) | +| 15 | sogoms-schema | API auto-générée depuis schema | + +## Applications + +| Application | Description | URL | +|-------------|-------------|-----| +| **Prokov** | Gestion de projets et tâches | prokov.unikoffice.com | ## Déploiement @@ -22,76 +52,17 @@ Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB ./deploy.sh ``` -Déploie sur le container `gw3` (Alpine) via IN3. +## Documentation -## Lancement - -Sur gw3 : - -```bash -/opt/sogoms/bin/sogoctl -``` - -## Configuration - -Chaque application cliente a son fichier dans `config/routes/` : - -```yaml -# config/routes/prokov.yaml -app: prokov -hosts: - - prokov.unikoffice.com -database: - host: 13.23.33.4 - user: prokov_user - password_file: /secrets/prokov_db_pass - name: prokov -auth: - jwt_secret_file: /secrets/prokov_jwt_secret - jwt_expiry: 24h -``` - -## Structure - -``` -cmd/ - sogoctl/main.go # Superviseur - sogoway/main.go # Gateway HTTP - sogoms/db/main.go # Microservice DB -internal/ - protocol/ # IPC Unix socket (JSON length-prefixed) - config/ # Chargement YAML, registry par host - auth/ # JWT (HS256), bcrypt passwords -config/ - sogoctl.yaml # Services à superviser - routes/*.yaml # Config par application - scenarios/ # Scénarios YAML (V2) -``` - -## API Endpoints - -```bash -# Health check -curl http://localhost:8080/health - -# Login -curl -X POST http://localhost:8080/api/auth/login \ - -H "Host: prokov.unikoffice.com" \ - -H "Content-Type: application/json" \ - -d '{"email":"user@example.com","password":"secret"}' - -# User info (avec token) -curl http://localhost:8080/api/auth/me \ - -H "Host: prokov.unikoffice.com" \ - -H "Authorization: Bearer " -``` +- [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 diff --git a/TODO.md b/TODO.md index e9ab227..d1d7dac 100755 --- a/TODO.md +++ b/TODO.md @@ -75,18 +75,179 @@ curl https://prokov.unikoffice.com/api/auth/me \ ## Phase 7 : Microservice Logs -- [ ] `cmd/sogoms/logs/main.go` : point d'entrée -- [ ] Écoute sur Unix socket `/run/sogoms-logs.1.sock` -- [ ] Actions `log_error`, `log_event` : écriture dans fichiers -- [ ] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log` -- [ ] Rotation automatique : suppression des fichiers > N jours (défaut 15) -- [ ] Paramètre `retention_days` dans config -- [ ] Intégration avec sogoway et sogoms-db +- [x] `cmd/sogoms/logs/main.go` : point d'entrée +- [x] Écoute sur Unix socket `/run/sogoms-logs.1.sock` +- [x] Actions `log_error`, `log_event` : écriture dans fichiers +- [x] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log` +- [x] Rotation automatique : suppression des fichiers > 30 jours +- [x] Paramètre `retention_days` dans config (`config/routes/prokov.yaml`) +- [x] Intégration avec sogoway et sogoms-db + +## Phase 8 : Système de Queries YAML + +- [x] Structure `config/queries/{app}/*.yaml` +- [x] Requêtes SQL externalisées (pas de recompilation) +- [x] `internal/config/config.go` : QueryConfig, GetQuery(), Build() +- [x] Placeholders `:user_id`, `:id`, etc. +- [x] Filtres par rôle (default, admin) +- [x] Login enrichi : charge projects, tasks, tags, statuses + +## Phase 9 : CRUD Générique + +- [x] Routing `/api/{resource}` dans sogoway +- [x] GET list/show avec filtres YAML +- [x] POST create avec fields YAML +- [x] PUT update avec fields + filtres YAML +- [x] DELETE avec filtres YAML +- [x] Config YAML pour projects, tasks, tags, statuses +- [x] Sécurité : filtre user_id automatique + +## Phase 10 : Améliorations Deploy + +- [x] `deploy.sh` : build sogoms-logs +- [x] `deploy.sh` : backup archives dans `/home/pierre/samba/back/sogoms/` +- [x] `deploy.sh` : redémarrage auto sogoctl +- [x] `deploy.sh` : kill propre des processus zombies +- [x] Documentation `DOCTECH.md` +- [x] Version 1.0.1 + +## Phase 11 : Microservice Crypt + +- [ ] `cmd/sogoms/crypt/main.go` : point d'entrée +- [ ] Écoute sur Unix socket `/run/sogoms-crypt.1.sock` +- [ ] Action `encrypt` : chiffre une donnée (AES-256-GCM) +- [ ] Action `decrypt` : déchiffre une donnée +- [ ] Action `hash` : hash irréversible (SHA-256) +- [ ] Clé de chiffrement par application (`/secrets/{app}_crypt_key`) +- [ ] Intégration avec sogoway pour champs sensibles +- [ ] Config YAML : liste des champs à chiffrer par table +- [ ] Application Prokov : chiffrement `users.email` + +## Phase 12 : Microservices Email + +### 12a. sogoms-smtp (Envoi) + +- [x] `cmd/sogoms/smtp/main.go` : point d'entrée +- [x] Écoute sur Unix socket `/run/sogoms-smtp.1.sock` +- [x] Action `send` : envoi email simple (to, subject, body, html) +- [x] Action `send_template` : envoi avec template YAML +- [x] Action `send_bulk` : envoi en masse (liste de destinataires) +- [x] Config SMTP par application (`config/routes/{app}.yaml`) +- [x] Support TLS/STARTTLS +- [x] Templates YAML (`config/emails/{app}/*.yaml`) +- [ ] Queue d'envoi avec retry en cas d'échec + +### 12b. sogoms-imap (Lecture) + +- [ ] `cmd/sogoms/imap/main.go` : point d'entrée +- [ ] Écoute sur Unix socket `/run/sogoms-imap.1.sock` +- [ ] Action `list` : liste les emails (folder, limit, offset) +- [ ] Action `fetch` : récupère un email complet (uid) +- [ ] Action `delete` : supprime un email +- [ ] Action `mark_read` : marque comme lu +- [ ] Action `move` : déplace vers un autre dossier +- [ ] Config IMAP par application (`config/routes/{app}.yaml`) +- [ ] Support IMAP IDLE pour notifications temps réel + +### 12c. sogoms-mailproc (Traitement) + +- [ ] `cmd/sogoms/mailproc/main.go` : point d'entrée +- [ ] Écoute sur Unix socket `/run/sogoms-mailproc.1.sock` +- [ ] Action `parse` : parse un email (headers, body, attachments) +- [ ] Action `apply_rules` : applique des règles configurées +- [ ] Config YAML : règles par application (`config/mailrules/{app}.yaml`) +- [ ] Webhooks : notification vers URL externe +- [ ] Intégration Prokov : email entrant → création de tâche + +## Phase 13 : Microservice Cron + +- [ ] `cmd/sogoms/cron/main.go` : point d'entrée +- [ ] Écoute sur Unix socket `/run/sogoms-cron.1.sock` +- [ ] Config YAML par application (`config/cron/{app}.yaml`) +- [ ] Parser cron schedule (format standard `* * * * *`) +- [ ] Action `list` : liste les jobs configurés +- [ ] Action `trigger` : déclenche un job manuellement +- [ ] Action `status` : statut des dernières exécutions +- [ ] Type `service` : appel service interne (sogoms-smtp, sogoms-db, etc.) +- [ ] Type `http` : appel HTTP (GET/POST) vers endpoint interne ou externe +- [ ] Type `query_email` : requête DB + envoi email avec résultat +- [ ] Logging des exécutions dans sogoms-logs +- [ ] Application Prokov : email quotidien `tasks_today` + +## Phase 14 : Push Temps Réel (MQTT) + +### 14a. Infrastructure Mosquitto + +- [ ] Installation Mosquitto sur gw3 (Alpine: `apk add mosquitto`) +- [ ] Config `/etc/mosquitto/mosquitto.conf` +- [ ] Auth par user/password ou plugin JWT +- [ ] Port 1883 (MQTT) + 9001 (WebSocket) +- [ ] TLS optionnel pour production + +### 14b. sogoms-push + +- [ ] `cmd/sogoms/push/main.go` : point d'entrée +- [ ] Écoute sur Unix socket `/run/sogoms-push.1.sock` +- [ ] Connexion au broker MQTT +- [ ] Config YAML par application (`config/push/{app}.yaml`) +- [ ] Action `publish` : publie un message sur un topic +- [ ] Action `notify_user` : publie vers `{app}/user/{user_id}/{channel}` +- [ ] Action `broadcast` : publie vers tous les users d'une app +- [ ] Topics : notifications, tasks, projects, comments +- [ ] Intégration sogoway : publish auto sur événements CRUD + +### 14c. Intégration Flutter + +- [ ] Package `mqtt_client` dans Prokov Flutter +- [ ] Service MqttService : connexion, reconnexion auto +- [ ] Subscription aux topics user +- [ ] Mise à jour state en temps réel +- [ ] Notifications in-app + +## Phase 15 : Schema-Driven API (Socle SOGOMS) + +Cette phase transforme SOGOMS en générateur d'API automatique. + +### 15a. Définition du Schema + +- [ ] Format `config/schema/{app}.yaml` : tables, fields, relations +- [ ] Types supportés : int, string, text, bool, date, datetime, json +- [ ] Contraintes : primary, auto, unique, required, default +- [ ] Relations : foreign key avec `foreign: table.field` +- [ ] Sécurité : `filter: owner` pour filtrage auto par user_id +- [ ] Auth : `auth: login`, `auth: password` pour détection auto +- [ ] CRUD : liste des opérations autorisées par table +- [ ] Filtres custom : définition de filtres nommés + +### 15b. sogoms-schema (Générateur) + +- [ ] `cmd/sogoms/schema/main.go` : outil CLI +- [ ] Commande `generate {app}` : génère queries YAML depuis schema +- [ ] Commande `validate {app}` : valide le schema +- [ ] Commande `diff {app}` : compare schema vs DB réelle +- [ ] Commande `migrate {app}` : génère SQL de migration +- [ ] Commande `init {app}` : crée schema depuis DB existante (reverse) + +### 15c. Runtime Dynamique (sogoway) + +- [ ] Chargement schema au démarrage +- [ ] Routes CRUD auto-générées depuis schema +- [ ] Validation des inputs selon types/contraintes +- [ ] Filtrage user_id automatique (filter: owner) +- [ ] Gestion relations (include, nested) +- [ ] Pas de fichiers queries YAML requis (optionnels pour override) + +### 15d. Dictionnaire de Données + +- [ ] Endpoint `/api/_schema` : expose le schema (pour admin/debug) +- [ ] Endpoint `/api/_schema/{table}` : détail d'une table +- [ ] Documentation auto-générée +- [ ] Utilisable par Flutter pour génération de formulaires ## Hors scope V1 - sogorch (orchestrateur scénarios) -- sogoms-pdf, sogoms-email, sogoms-storage -- Multi-tenant +- sogoms-pdf, sogoms-storage +- Multi-tenant avancé (workspaces, partage) - Rate limiting -- Exécution dynamique des scénarios YAML +- Rôles utilisateurs (admin, manager, user) diff --git a/VERSION b/VERSION index 3eefcb9..7dea76e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/clients/prokov.sql b/clients/prokov.sql new file mode 100644 index 0000000..d57c417 --- /dev/null +++ b/clients/prokov.sql @@ -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 diff --git a/clients/prokov/api/config/Database.php b/clients/prokov/api/config/Database.php deleted file mode 100644 index d7f5c3d..0000000 --- a/clients/prokov/api/config/Database.php +++ /dev/null @@ -1,39 +0,0 @@ - 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() {} -} diff --git a/clients/prokov/api/config/config.php b/clients/prokov/api/config/config.php deleted file mode 100644 index f3e9873..0000000 --- a/clients/prokov/api/config/config.php +++ /dev/null @@ -1,27 +0,0 @@ -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'], - ]); - } - } -} diff --git a/clients/prokov/api/controllers/ProjectController.php b/clients/prokov/api/controllers/ProjectController.php deleted file mode 100644 index 503c989..0000000 --- a/clients/prokov/api/controllers/ProjectController.php +++ /dev/null @@ -1,359 +0,0 @@ -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(), - ]); - } - } - } -} diff --git a/clients/prokov/api/controllers/StatusController.php b/clients/prokov/api/controllers/StatusController.php deleted file mode 100644 index 1715d2c..0000000 --- a/clients/prokov/api/controllers/StatusController.php +++ /dev/null @@ -1,231 +0,0 @@ -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); - } - } -} diff --git a/clients/prokov/api/controllers/TagController.php b/clients/prokov/api/controllers/TagController.php deleted file mode 100644 index d231ca0..0000000 --- a/clients/prokov/api/controllers/TagController.php +++ /dev/null @@ -1,235 +0,0 @@ -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(); - } -} diff --git a/clients/prokov/api/controllers/TaskController.php b/clients/prokov/api/controllers/TaskController.php deleted file mode 100644 index 7a29cee..0000000 --- a/clients/prokov/api/controllers/TaskController.php +++ /dev/null @@ -1,399 +0,0 @@ -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(), - ]); - } - } - } -} diff --git a/clients/prokov/api/core/Controller.php b/clients/prokov/api/core/Controller.php deleted file mode 100644 index 38c8b1c..0000000 --- a/clients/prokov/api/core/Controller.php +++ /dev/null @@ -1,108 +0,0 @@ -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; - } -} diff --git a/clients/prokov/api/core/Request.php b/clients/prokov/api/core/Request.php deleted file mode 100644 index 064bbd9..0000000 --- a/clients/prokov/api/core/Request.php +++ /dev/null @@ -1,112 +0,0 @@ -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; - } -} diff --git a/clients/prokov/api/core/Response.php b/clients/prokov/api/core/Response.php deleted file mode 100644 index c3f9b16..0000000 --- a/clients/prokov/api/core/Response.php +++ /dev/null @@ -1,49 +0,0 @@ - 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); - } -} diff --git a/clients/prokov/api/core/Router.php b/clients/prokov/api/core/Router.php deleted file mode 100644 index 5f20862..0000000 --- a/clients/prokov/api/core/Router.php +++ /dev/null @@ -1,145 +0,0 @@ -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(); - } -} diff --git a/clients/prokov/api/core/Session.php b/clients/prokov/api/core/Session.php deleted file mode 100644 index 014809e..0000000 --- a/clients/prokov/api/core/Session.php +++ /dev/null @@ -1,162 +0,0 @@ -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; - } -} diff --git a/clients/prokov/api/deploy-api.sh b/clients/prokov/api/deploy-api.sh deleted file mode 100755 index 13f8f12..0000000 --- a/clients/prokov/api/deploy-api.sh +++ /dev/null @@ -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 diff --git a/clients/prokov/api/models/User.php b/clients/prokov/api/models/User.php deleted file mode 100644 index c40377f..0000000 --- a/clients/prokov/api/models/User.php +++ /dev/null @@ -1,74 +0,0 @@ -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; - } -} diff --git a/clients/prokov/api/public/index.php b/clients/prokov/api/public/index.php deleted file mode 100644 index f1168da..0000000 --- a/clients/prokov/api/public/index.php +++ /dev/null @@ -1,47 +0,0 @@ -dispatch(); diff --git a/cmd/sogoms/db/main.go b/cmd/sogoms/db/main.go index d6e610f..c2a09ab 100755 --- a/cmd/sogoms/db/main.go +++ b/cmd/sogoms/db/main.go @@ -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()) } diff --git a/cmd/sogoms/logs/main.go b/cmd/sogoms/logs/main.go new file mode 100644 index 0000000..a17f725 --- /dev/null +++ b/cmd/sogoms/logs/main.go @@ -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}) +} diff --git a/cmd/sogoms/smtp/main.go b/cmd/sogoms/smtp/main.go new file mode 100644 index 0000000..338474b --- /dev/null +++ b/cmd/sogoms/smtp/main.go @@ -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 +} diff --git a/config/emails/prokov/password_reset.yaml b/config/emails/prokov/password_reset.yaml new file mode 100644 index 0000000..cd68d35 --- /dev/null +++ b/config/emails/prokov/password_reset.yaml @@ -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: | + + + + + + +
+

Réinitialisation de mot de passe

+

Bonjour {{.Name}},

+

Vous avez demandé la réinitialisation de votre mot de passe.

+

+ + Réinitialiser mon mot de passe + +

+

Ce lien expire dans {{.ExpiresIn}}.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+
+

Cordialement,
L'équipe Prokov

+
+ + diff --git a/config/emails/prokov/task_assigned.yaml b/config/emails/prokov/task_assigned.yaml new file mode 100644 index 0000000..6bdd088 --- /dev/null +++ b/config/emails/prokov/task_assigned.yaml @@ -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: | + + + + + + +
+

Nouvelle tâche assignée

+

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}} +
+

+ + Voir la tâche + +

+
+

Cordialement,
L'équipe Prokov

+
+ + diff --git a/config/emails/prokov/tasks_today.yaml b/config/emails/prokov/tasks_today.yaml new file mode 100644 index 0000000..8e535bf --- /dev/null +++ b/config/emails/prokov/tasks_today.yaml @@ -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: | + + + + + + +
+

Vos tâches du jour

+

Bonjour {{.Name}},

+

Voici vos tâches prévues pour aujourd'hui ({{.Date}}) :

+ + + + + + + + + + + + {{range .Tasks}} + + + + + + + {{end}} + +
TâcheProjetStatutHeure
{{.Name}}{{.Project}} + {{.Status}} + {{.DueTime}}
+ + {{if .TaskCount}} +

+ {{.TaskCount}} tâche(s) à accomplir aujourd'hui +

+ {{end}} + +

Bonne journée !

+ +
+

Cordialement,
L'équipe Prokov

+
+ + diff --git a/config/emails/prokov/welcome.yaml b/config/emails/prokov/welcome.yaml new file mode 100644 index 0000000..c205cd4 --- /dev/null +++ b/config/emails/prokov/welcome.yaml @@ -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: | + + + + + + +
+

Bienvenue sur Prokov !

+

Bonjour {{.Name}},

+

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

+
+ + diff --git a/config/queries/prokov/auth.yaml b/config/queries/prokov/auth.yaml new file mode 100644 index 0000000..ad28013 --- /dev/null +++ b/config/queries/prokov/auth.yaml @@ -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 = ? diff --git a/config/queries/prokov/projects.yaml b/config/queries/prokov/projects.yaml new file mode 100644 index 0000000..01fc9ae --- /dev/null +++ b/config/queries/prokov/projects.yaml @@ -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: "" diff --git a/config/queries/prokov/statuses.yaml b/config/queries/prokov/statuses.yaml new file mode 100644 index 0000000..aa4c15f --- /dev/null +++ b/config/queries/prokov/statuses.yaml @@ -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: "" diff --git a/config/queries/prokov/tags.yaml b/config/queries/prokov/tags.yaml new file mode 100644 index 0000000..e2d5dd0 --- /dev/null +++ b/config/queries/prokov/tags.yaml @@ -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: "" diff --git a/config/queries/prokov/tasks.yaml b/config/queries/prokov/tasks.yaml new file mode 100644 index 0000000..c69318a --- /dev/null +++ b/config/queries/prokov/tasks.yaml @@ -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: "" diff --git a/config/routes/prokov.yaml b/config/routes/prokov.yaml index b398350..96ff46c 100644 --- a/config/routes/prokov.yaml +++ b/config/routes/prokov.yaml @@ -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 === diff --git a/config/sogoctl.yaml b/config/sogoctl.yaml index a55d033..db330da 100644 --- a/config/sogoctl.yaml +++ b/config/sogoctl.yaml @@ -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 diff --git a/deploy.sh b/deploy.sh index ac747ae..0c68418 100755 --- a/deploy.sh +++ b/deploy.sh @@ -78,10 +78,12 @@ echo_step "Building binaries v${VERSION} (linux/amd64)..." mkdir -p bin CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-logs ./cmd/sogoms/logs || echo_error "Failed to build sogoms-logs" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-smtp ./cmd/sogoms/smtp || echo_error "Failed to build sogoms-smtp" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl" -echo_info "Built: sogoms-db, sogoway, sogoctl (v${VERSION})" +echo_info "Built: sogoms-db, sogoms-logs, sogoms-smtp, sogoway, sogoctl (v${VERSION})" # Étape 2: Créer les archives echo_step "Creating archives..." @@ -117,23 +119,61 @@ $SSH_CMD " echo '📁 Deploying binaries...' incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN} incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/ - incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl + incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoms-logs ${REMOTE_BIN}/sogoms-smtp ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl echo '📁 Deploying config...' - incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios + incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios ${REMOTE_CONFIG}/queries ${REMOTE_CONFIG}/emails incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/ - echo '📁 Setting up run directory...' - incus exec ${INCUS_CONTAINER} -- mkdir -p /run + echo '📁 Setting up run and log directories...' + incus exec ${INCUS_CONTAINER} -- mkdir -p /run /var/log/sogoms echo '🧹 Cleanup...' incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE} rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE} " +# Étape 5: Backup local des archives +BACKUP_DIR="/home/pierre/samba/back/sogoms" +echo_step "Backing up archives to ${BACKUP_DIR}..." +mkdir -p "${BACKUP_DIR}" +cp "/tmp/${BIN_ARCHIVE}" "${BACKUP_DIR}/" +cp "/tmp/${CONFIG_ARCHIVE}" "${BACKUP_DIR}/" +echo_info "Backed up: ${BIN_ARCHIVE}, ${CONFIG_ARCHIVE}" + # Nettoyage local rm -f "/tmp/${BIN_ARCHIVE}" "/tmp/${CONFIG_ARCHIVE}" +# Étape 6: Redémarrer sogoctl +echo_step "Restarting sogoctl..." +$SSH_CMD " + echo '🛑 Stopping all sogoms processes...' + incus exec ${INCUS_CONTAINER} -- pkill -9 sogoctl || true + incus exec ${INCUS_CONTAINER} -- pkill -9 sogoms || true + incus exec ${INCUS_CONTAINER} -- pkill -9 sogoway || true + sleep 2 + + # Vérifier qu'ils sont tous morts + if incus exec ${INCUS_CONTAINER} -- pgrep -la sogo > /dev/null 2>&1; then + echo '⚠️ Some processes still running, force kill...' + incus exec ${INCUS_CONTAINER} -- pkill -9 sogo || true + sleep 1 + fi + + echo '🚀 Starting sogoctl...' + incus exec ${INCUS_CONTAINER} -- sh -c 'nohup /opt/sogoms/bin/sogoctl > /var/log/sogoms/sogoctl.log 2>&1 &' + sleep 3 + + # Vérifier le démarrage + if incus exec ${INCUS_CONTAINER} -- pgrep -l sogoctl > /dev/null 2>&1; then + echo '✅ sogoctl started' + incus exec ${INCUS_CONTAINER} -- pgrep -la sogo + else + echo '❌ sogoctl failed to start' + incus exec ${INCUS_CONTAINER} -- tail -20 /var/log/sogoms/sogoctl.log + fi +" + # Résumé final echo_step "Deployment completed successfully!" echo "" diff --git a/internal/config/config.go b/internal/config/config.go index 43c1fb3..7d84e23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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()