diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go index cdf028e..c894f1b 100644 --- a/control-plane/api/internal/router/router.go +++ b/control-plane/api/internal/router/router.go @@ -77,6 +77,7 @@ func New(d Deps) *gin.Engine { admin.GET("/profile/alerts", d.ProfileH.GetAlerts) admin.POST("/profile/alerts", d.ProfileH.SetAlerts) admin.POST("/profile/alerts/test", d.ProfileH.TestAlerts) + admin.GET("/premium", d.SubH.GetPremiumUser) if d.DemosH != nil { admin.POST("/demos", d.DemosH.Create) admin.GET("/demos", d.DemosH.List) diff --git a/control-plane/api/internal/sub/handler.go b/control-plane/api/internal/sub/handler.go index 1f06bb3..2e133df 100644 --- a/control-plane/api/internal/sub/handler.go +++ b/control-plane/api/internal/sub/handler.go @@ -137,3 +137,24 @@ func (h *Handler) AddCode(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"}) } + +// GetPremiumUser : GET /premium — liste des clients premium (nom, date de +// saisie du code, date de fin d'abonnement) pour l'admin. +func (h *Handler) GetPremiumUser(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + if p.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "vous n'avez pas les droits"}) + return + } + + items, err := h.store.GetUserPremium() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} diff --git a/control-plane/api/internal/sub/models.go b/control-plane/api/internal/sub/models.go index 90bd83c..9738e0f 100644 --- a/control-plane/api/internal/sub/models.go +++ b/control-plane/api/internal/sub/models.go @@ -15,3 +15,14 @@ type CodeBuySub struct { // TableName définit explicitement le nom de la table. func (CodeBuySub) TableName() string { return "code_buy_subs" } + +// PremiumUser : client actuellement en abonnement premium, tel qu'affiché +// à l'admin sur la page des codes. ActivatedAt (date de saisie du code) +// n'est pas stockée telle quelle — le code est supprimé une fois consommé +// (voir Store.AddCodeForSub) — mais l'abonnement dure toujours exactement +// PremiumDuration, donc on la déduit de ExpiredAt (voir GormStore). +type PremiumUser struct { + Username string `json:"username"` + ActivatedAt time.Time `json:"activated_at"` + ExpiredAt time.Time `json:"expired_at"` +} diff --git a/control-plane/api/internal/sub/store.go b/control-plane/api/internal/sub/store.go index 39858ff..6f1b4ae 100644 --- a/control-plane/api/internal/sub/store.go +++ b/control-plane/api/internal/sub/store.go @@ -10,6 +10,11 @@ import ( "gorm.io/gorm" ) +// PremiumDuration : durée d'un abonnement premium débloqué par un code +// (30 jours). Sert à la fois à fixer ExpiredAt lors de l'activation et à +// retrouver la date de saisie du code (ExpiredAt - PremiumDuration). +const PremiumDuration = 30 * 24 * time.Hour + // Store définit les opérations de persistance pour les codes de souscription. type Store interface { // CreateCodeForSub crée un nouveau code pour un utilisateur. @@ -19,6 +24,8 @@ type Store interface { // ListCodes liste tous les codes. ListCodes() ([]CodeBuySub, error) AddCodeForSub(username, code string) (auth.User, error) + // GetUserPremium liste les clients actuellement en abonnement premium. + GetUserPremium() ([]PremiumUser, error) } // GormStore implémente Store avec PostgreSQL via GORM. @@ -78,7 +85,7 @@ func (s *GormStore) AddCodeForSub(username, code string) (auth.User, error) { } u.TypeAbo = "premium" - u.ExpiredAt = time.Now().UTC().AddDate(0, 0, 30) + u.ExpiredAt = time.Now().UTC().Add(PremiumDuration) if err := s.db.Model(&u).Updates(map[string]interface{}{ "type_abo": u.TypeAbo, "expired_at": u.ExpiredAt, @@ -106,6 +113,25 @@ func (s *GormStore) ListCodes() ([]CodeBuySub, error) { return codes, nil } +// GetUserPremium liste les clients dont l'abonnement est premium, avec la +// date de saisie du code (déduite : ExpiredAt - PremiumDuration) et la date +// de fin d'abonnement (ExpiredAt). Trié par échéance la plus proche. +func (s *GormStore) GetUserPremium() ([]PremiumUser, error) { + var users []auth.User + if err := s.db.Where("type_abo = ?", "premium").Order("expired_at asc").Find(&users).Error; err != nil { + return nil, err + } + out := make([]PremiumUser, 0, len(users)) + for _, u := range users { + out = append(out, PremiumUser{ + Username: u.Username, + ActivatedAt: u.ExpiredAt.Add(-PremiumDuration), + ExpiredAt: u.ExpiredAt, + }) + } + return out, nil +} + // MemStore implémente Store en mémoire (pour dev/tests). type MemStore struct { mu sync.RWMutex @@ -174,3 +200,10 @@ func (m *MemStore) AddCodeForSub(username, code string) (auth.User, error) { // il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous. return auth.User{Username: username, TypeAbo: "premium"}, nil } + +// GetUserPremium : le MemStore (dev/tests) ne connaît pas la table des +// utilisateurs (voir AddCodeForSub) — il ne peut donc pas lister les clients +// premium. Renvoie une liste vide plutôt qu'une erreur. +func (m *MemStore) GetUserPremium() ([]PremiumUser, error) { + return []PremiumUser{}, nil +} diff --git a/web/src/components/docs/sections/AlertesSection.tsx b/web/src/components/docs/sections/AlertesSection.tsx index 6372047..89b8a5e 100644 --- a/web/src/components/docs/sections/AlertesSection.tsx +++ b/web/src/components/docs/sections/AlertesSection.tsx @@ -11,9 +11,11 @@ interface Alert { active: boolean } +// Les deux seuls types d'alerte que peut envoyer un livreur (voir +// DeliveryAlertsSection) : contrôle de police / guet-apens. const INITIAL: Alert[] = [ - { id: '1', driver: 'lucas_d', message: 'Accès bloqué, portail fermé', time: '03/08/2026, 14:32', active: true }, - { id: '2', driver: 'emma_l', message: 'Client injoignable', time: '02/08/2026, 19:05', active: false }, + { id: '1', driver: 'lucas_d', message: 'Guet-apens', time: '03/08/2026, 14:32', active: true }, + { id: '2', driver: 'emma_l', message: 'Contrôle de police', time: '02/08/2026, 19:05', active: false }, ] // Le bouton "Résoudre" change réellement l'état de l'alerte (badge + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index af126cd..04d15cc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -97,6 +97,12 @@ export interface CodeBuySub { created_at: string } +export interface PremiumUser { + username: string + activated_at: string + expired_at: string +} + export interface Contact { id: string username: string @@ -223,6 +229,7 @@ export const api = { transferDemoToPremium: (id: string) => request('POST', `/demos/${id}/premium`), listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'), + listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'), createCode: (username: string) => request<{ code: string }>('POST', '/codes', { username }), addCode: (code: string) => diff --git a/web/src/pages/Documentation.tsx b/web/src/pages/Documentation.tsx index ea05485..622011c 100644 --- a/web/src/pages/Documentation.tsx +++ b/web/src/pages/Documentation.tsx @@ -262,8 +262,8 @@ export function Documentation() { {/* Sélecteur de rôle + sommaire */} - - + + {ROLES.map((r) => (