SOGOMS v1.0.3 - Admin UI, Cron, Config reload
Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status
Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure
Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions
Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP
Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
255
internal/config/schema.go
Normal file
255
internal/config/schema.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Package config - Schema représente la structure de la base de données.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Schema représente le schema d'une application.
|
||||
type Schema struct {
|
||||
App string `yaml:"app"`
|
||||
Version string `yaml:"version"`
|
||||
Tables map[string]*Table `yaml:"tables"`
|
||||
}
|
||||
|
||||
// Table représente une table de la base de données.
|
||||
type Table struct {
|
||||
Columns map[string]*Column `yaml:"columns"`
|
||||
Primary []string `yaml:"primary,omitempty"` // Clé primaire composite
|
||||
CRUD []string `yaml:"crud"`
|
||||
Order string `yaml:"order,omitempty"`
|
||||
}
|
||||
|
||||
// Column représente une colonne d'une table.
|
||||
type Column struct {
|
||||
Type string `yaml:"type"`
|
||||
Length int64 `yaml:"length,omitempty"`
|
||||
Required bool `yaml:"required,omitempty"`
|
||||
Primary bool `yaml:"primary,omitempty"`
|
||||
Auto bool `yaml:"auto,omitempty"`
|
||||
Unique bool `yaml:"unique,omitempty"`
|
||||
Default string `yaml:"default,omitempty"`
|
||||
Foreign string `yaml:"foreign,omitempty"` // table.column
|
||||
Filter string `yaml:"filter,omitempty"` // "owner" pour filtrage auto
|
||||
}
|
||||
|
||||
// HasCRUD vérifie si une opération CRUD est autorisée pour cette table.
|
||||
func (t *Table) HasCRUD(op string) bool {
|
||||
for _, c := range t.CRUD {
|
||||
if c == op {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetOwnerColumn retourne le nom de la colonne avec filter: owner, ou "".
|
||||
func (t *Table) GetOwnerColumn() string {
|
||||
for name, col := range t.Columns {
|
||||
if col.Filter == "owner" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPrimaryKey retourne le nom de la clé primaire (simple).
|
||||
func (t *Table) GetPrimaryKey() string {
|
||||
// D'abord chercher dans Primary (composite)
|
||||
if len(t.Primary) == 1 {
|
||||
return t.Primary[0]
|
||||
}
|
||||
// Sinon chercher une colonne avec primary: true
|
||||
for name, col := range t.Columns {
|
||||
if col.Primary {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "id" // Par défaut
|
||||
}
|
||||
|
||||
// IsCompositePK vérifie si la table a une clé primaire composite.
|
||||
func (t *Table) IsCompositePK() bool {
|
||||
return len(t.Primary) > 1
|
||||
}
|
||||
|
||||
// GetSelectColumns retourne la liste des colonnes pour un SELECT.
|
||||
func (t *Table) GetSelectColumns() []string {
|
||||
cols := make([]string, 0, len(t.Columns))
|
||||
for name := range t.Columns {
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// GetInsertColumns retourne les colonnes pour un INSERT (exclut auto-increment).
|
||||
func (t *Table) GetInsertColumns() []string {
|
||||
cols := make([]string, 0, len(t.Columns))
|
||||
for name, col := range t.Columns {
|
||||
if !col.Auto {
|
||||
cols = append(cols, name)
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// GetUpdateColumns retourne les colonnes pour un UPDATE (exclut PK et auto).
|
||||
func (t *Table) GetUpdateColumns() []string {
|
||||
cols := make([]string, 0, len(t.Columns))
|
||||
pk := t.GetPrimaryKey()
|
||||
for name, col := range t.Columns {
|
||||
if name != pk && !col.Auto && col.Filter != "owner" {
|
||||
cols = append(cols, name)
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// BuildListQuery génère la requête SELECT pour list.
|
||||
func (t *Table) BuildListQuery(tableName string) string {
|
||||
cols := t.GetSelectColumns()
|
||||
query := fmt.Sprintf("SELECT %s FROM %s", strings.Join(cols, ", "), tableName)
|
||||
|
||||
// Ajouter filtre owner si présent
|
||||
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
|
||||
query += fmt.Sprintf(" WHERE %s = ?", ownerCol)
|
||||
}
|
||||
|
||||
// Ajouter ORDER BY si défini
|
||||
if t.Order != "" {
|
||||
query += " ORDER BY " + t.Order
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// BuildShowQuery génère la requête SELECT pour show (un seul enregistrement).
|
||||
func (t *Table) BuildShowQuery(tableName string) string {
|
||||
cols := t.GetSelectColumns()
|
||||
pk := t.GetPrimaryKey()
|
||||
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = ?", strings.Join(cols, ", "), tableName, pk)
|
||||
|
||||
// Ajouter filtre owner si présent
|
||||
if ownerCol := t.GetOwnerColumn(); ownerCol != "" {
|
||||
query += fmt.Sprintf(" AND %s = ?", ownerCol)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// BuildInsertQuery génère la requête INSERT.
|
||||
func (t *Table) BuildInsertQuery(tableName string, data map[string]any) (string, []any) {
|
||||
cols := make([]string, 0)
|
||||
placeholders := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
|
||||
for name, col := range t.Columns {
|
||||
if col.Auto {
|
||||
continue // Skip auto-increment
|
||||
}
|
||||
|
||||
if val, ok := data[name]; ok {
|
||||
cols = append(cols, name)
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, val)
|
||||
} else if col.Required && col.Default == "" {
|
||||
// Champ requis sans valeur et sans default - sera une erreur DB
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
tableName,
|
||||
strings.Join(cols, ", "),
|
||||
strings.Join(placeholders, ", "))
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// BuildUpdateQuery génère la requête UPDATE.
|
||||
func (t *Table) BuildUpdateQuery(tableName string, id any, ownerID any, data map[string]any) (string, []any) {
|
||||
setClauses := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
|
||||
pk := t.GetPrimaryKey()
|
||||
ownerCol := t.GetOwnerColumn()
|
||||
|
||||
for name := range t.Columns {
|
||||
// Skip PK, auto, et owner
|
||||
if name == pk || t.Columns[name].Auto || name == ownerCol {
|
||||
continue
|
||||
}
|
||||
|
||||
if val, ok := data[name]; ok {
|
||||
setClauses = append(setClauses, name+" = ?")
|
||||
args = append(args, val)
|
||||
}
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = ?",
|
||||
tableName,
|
||||
strings.Join(setClauses, ", "),
|
||||
pk)
|
||||
args = append(args, id)
|
||||
|
||||
// Ajouter filtre owner
|
||||
if ownerCol != "" && ownerID != nil {
|
||||
query += fmt.Sprintf(" AND %s = ?", ownerCol)
|
||||
args = append(args, ownerID)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// BuildDeleteQuery génère la requête DELETE.
|
||||
func (t *Table) BuildDeleteQuery(tableName string, id any, ownerID any) (string, []any) {
|
||||
pk := t.GetPrimaryKey()
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", tableName, pk)
|
||||
args := []any{id}
|
||||
|
||||
// Ajouter filtre owner
|
||||
if ownerCol := t.GetOwnerColumn(); ownerCol != "" && ownerID != nil {
|
||||
query += fmt.Sprintf(" AND %s = ?", ownerCol)
|
||||
args = append(args, ownerID)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// ValidateInput valide les données d'entrée selon le schema.
|
||||
// Retourne les données filtrées (seuls les champs connus sont gardés).
|
||||
func (t *Table) ValidateInput(data map[string]any) map[string]any {
|
||||
filtered := make(map[string]any)
|
||||
for name := range t.Columns {
|
||||
if val, ok := data[name]; ok {
|
||||
filtered[name] = val
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// loadSchema charge le schema depuis config/apps/{app}/schema.yaml.
|
||||
func loadSchema(configDir, appID string) *Schema {
|
||||
schemaPath := filepath.Join(configDir, "apps", appID, "schema.yaml")
|
||||
|
||||
data, err := os.ReadFile(schemaPath)
|
||||
if err != nil {
|
||||
return nil // Pas de schema, c'est OK
|
||||
}
|
||||
|
||||
var schema Schema
|
||||
if err := yaml.Unmarshal(data, &schema); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &schema
|
||||
}
|
||||
Reference in New Issue
Block a user