This commit is contained in:
cangui 2025-05-09 10:14:22 +02:00
commit 087641d050
81 changed files with 68077 additions and 0 deletions

17
.env Normal file
View File

@ -0,0 +1,17 @@
# Base de données
DB_HOST=db
DB_PORT=3306
DB_USER=root
DB_PASS=secret
DB_NAME=whatsapp_saas
# Environnement
ENV=dev
# WhatsApp Business API
WHATSAPP_TOKEN=EAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_WEBHOOK_TOKEN=secrettoken
# Port d'écoute de l'app
PORT=3003

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# Étape unique : build + exécution dans une seule image (évite les erreurs GLIBC)
FROM golang:1.24
# Définir le dossier de travail
WORKDIR /app
# Copier les fichiers Go
COPY go.mod go.sum ./
RUN go mod download
# Copier tout le projet
COPY . .
# Compiler le projet (assure-toi que main.go est à ./backend/main.go)
RUN go build -o server ./backend/main.go
# Copier les templates HTMX
COPY frontend/templates /app/frontend/templates
COPY frontend/assets /app/frontend/assets
# Exposer le port (adapté à ton choix)
EXPOSE 3003
# Commande de lancement
CMD ["./server"]

45
Makefile Normal file
View File

@ -0,0 +1,45 @@
# Nom du projet
PROJECT_NAME = whatsapp-saas
# Cible par défaut
.PHONY: help
help:
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo " up Lance l'application (docker-compose up -d)"
@echo " down Stoppe l'application (docker-compose down)"
@echo " rebuild Reconstruit et relance l'app (docker-compose up --build)"
@echo " logs Affiche les logs de l'application"
@echo " clean Supprime containers, volumes, images"
@echo " db Accède au MariaDB en ligne de commande"
# Lancer les containers
.PHONY: up
up:
docker-compose up -d
# Stopper les containers
.PHONY: down
down:
docker-compose down
# Rebuilder tout
.PHONY: rebuild
rebuild:
docker-compose up --build -d
# Logs de l'app
.PHONY: logs
logs:
docker-compose logs -f
# Nettoyer tous les artefacts Docker liés au projet
.PHONY: clean
clean:
docker-compose down -v --rmi all --remove-orphans
# Accéder au shell MariaDB
.PHONY: db
db:
docker exec -it whatsapp_saas_db mariadb -uroot -psecret whatsapp_saas

View File

@ -0,0 +1,60 @@
// seed_users.go
package main
import (
"cangui/whatsapp/backend/models"
"fmt"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"os"
)
func main() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",
os.Getenv("DB_USER"),
os.Getenv("DB_PASS"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"),
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
users := []models.User{
{
Email: "info@pointvirgule.net",
Password: hash("test"),
SSOID: "admin001",
Role: models.ROLE_ADMIN,
WhatsappToken: "EAAYuBH5IDeQBOZBT3lVkpW48657qUFarq4ihCR9aUzlatERQsPxkKuK3bFFHnTbyYpfcPmIdZCko4FJupceC52dunyIuIidTZBZCFTbTaVkQfYZBfJu3rtr6B3ZBtwcVFnm0AVXi8Lj5TmZCPLcEEgxupVtlVdsOvIdMpOWJZCOBC56FzTgA8fRijzFNMlOLuPAEW7Y1f2eMVd5Ku3avy6m5d4JAxrxZCcv1fZBfLXWLZBV",
WhatsappPhoneNumberID: "644541092077960",
},
{
Email: "canguijc@gmail.com",
Password: hash("test"),
SSOID: "client001",
Role: models.ROLE_CLIENT,
WhatsappToken: "EAAYuBH5IDeQBOZBT3lVkpW48657qUFarq4ihCR9aUzlatERQsPxkKuK3bFFHnTbyYpfcPmIdZCko4FJupceC52dunyIuIidTZBZCFTbTaVkQfYZBfJu3rtr6B3ZBtwcVFnm0AVXi8Lj5TmZCPLcEEgxupVtlVdsOvIdMpOWJZCOBC56FzTgA8fRijzFNMlOLuPAEW7Y1f2eMVd5Ku3avy6m5d4JAxrxZCcv1fZBfLXWLZBV",
WhatsappPhoneNumberID: "644541092077960",
},
}
for _, user := range users {
var existing models.User
db.Where("email = ?", user.Email).First(&existing)
if existing.ID == 0 {
db.Create(&user)
fmt.Println("✅ Créé:", user.Email)
} else {
fmt.Println("⏩ Existe déjà:", user.Email)
}
}
}
func hash(password string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(h)
}

54
backend/db/db.go Normal file
View File

@ -0,0 +1,54 @@
package db
import (
"cangui/whatsapp/backend/models"
"fmt"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func InitDB() *gorm.DB {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
pass := os.Getenv("DB_PASS")
name := os.Getenv("DB_NAME")
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local", user, pass, host, port, name)
var db *gorm.DB
var err error
// Retry loop (attente de MariaDB)
for i := 0; i < 10; i++ {
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err == nil {
break
}
fmt.Printf("⏳ Tentative %d: BDD pas encore prête (%s)\n", i+1, err)
time.Sleep(3 * time.Second)
}
if err != nil {
panic("❌ Failed to connect to database: " + err.Error())
}
fmt.Println("✅ Connexion à MariaDB réussie !")
err = db.AutoMigrate(
&models.User{},
&models.Consumption{},
&models.MonthlyConsumption{},
&models.Conversation{},
)
if err != nil {
panic("Migration failed: " + err.Error())
}
return db
}

View File

@ -0,0 +1,149 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"cangui/whatsapp/backend/handlers"
"cangui/whatsapp/backend/models"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func initTestDB() *gorm.DB {
database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
database.AutoMigrate(&models.User{}, &models.Consumption{}, &models.MonthlyConsumption{}, &models.Conversation{})
return database
}
func createTestUser(db *gorm.DB) models.User {
hash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
user := models.User{
Email: "test@example.com",
Password: string(hash),
SSOID: "ssoid123",
Role: models.ROLE_ADMIN,
IsActive: true,
WhatsappToken: "test-token",
WhatsappPhoneNumberID: "123456",
}
db.Create(&user)
return user
}
func TestCreateUser(t *testing.T) {
db := initTestDB()
h := handlers.CreateUser(db)
user := models.User{
Email: "new@example.com",
Password: "newpass",
SSOID: "newssoid",
Role: models.ROLE_CLIENT,
}
body, _ := json.Marshal(user)
r := httptest.NewRequest("POST", "/api/user/create", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestLoginHandler_Invalid(t *testing.T) {
db := initTestDB()
h := handlers.LoginHandler(db)
r := httptest.NewRequest("POST", "/api/login", bytes.NewReader([]byte(`{}`)))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestSendWhatsAppMessage_Unauthorized(t *testing.T) {
db := initTestDB()
h := handlers.SendWhatsAppMessage(db)
r := httptest.NewRequest("POST", "/api/message/send", nil)
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestBuildMessageFromPayload_Text(t *testing.T) {
payload := map[string]interface{}{
"to": "33600000000",
"type": "text",
"text": map[string]interface{}{"body": "Bonjour"},
}
msg, err := models.BuildMessageFromPayload(payload)
assert.NoError(t, err)
assert.NotNil(t, msg)
}
func TestBuildMessageFromPayload_Invalid(t *testing.T) {
payload := map[string]interface{}{
"to": "33600000000",
"type": "unknown",
}
_, err := models.BuildMessageFromPayload(payload)
assert.Error(t, err)
}
func TestGetAllUsers(t *testing.T) {
db := initTestDB()
createTestUser(db)
h := handlers.GetAllUsers(db)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/user/all", nil)
h(w, r)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestGetUserByID(t *testing.T) {
db := initTestDB()
user := createTestUser(db)
h := handlers.GetUserByID(db)
r := httptest.NewRequest("GET", "/api/user/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusOK, w.Code)
var u models.User
json.Unmarshal(w.Body.Bytes(), &u)
assert.Equal(t, user.Email, u.Email)
}
func TestUpdateUser(t *testing.T) {
db := initTestDB()
createTestUser(db)
h := handlers.UpdateUser(db)
updated := map[string]interface{}{
"email": "updated@example.com",
"role": "ADMIN",
"is_active": true,
}
body, _ := json.Marshal(updated)
r := httptest.NewRequest("PUT", "/api/user/update/1", bytes.NewReader(body))
r = mux.SetURLVars(r, map[string]string{"id": "1"})
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestDeleteUser(t *testing.T) {
db := initTestDB()
createTestUser(db)
h := handlers.DeleteUser(db)
r := httptest.NewRequest("DELETE", "/api/user/delete/1", nil)
r = mux.SetURLVars(r, map[string]string{"id": "1"})
w := httptest.NewRecorder()
h(w, r)
assert.Equal(t, http.StatusNoContent, w.Code)
}

391
backend/handlers/main.go Normal file
View File

@ -0,0 +1,391 @@
package handlers
import (
"bytes"
"cangui/whatsapp/backend/jwt"
"cangui/whatsapp/backend/models"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Simuler un SSO + Redirection selon rôle
func LoginHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var reqUser models.User
if err := json.NewDecoder(r.Body).Decode(&reqUser); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
var user models.User
result := db.Where("email = ?", reqUser.Email).First(&user)
if result.Error != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Vérification du mot de passe hashé
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(reqUser.Password)); err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Création du JWT
fmt.Printf("login");
fmt.Printf(user.SSOID)
tokenString, err := jwt.CreateToken(user.SSOID)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Cookie HTTP-only
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: tokenString,
Path: "/",
HttpOnly: true,
Secure: false, // à mettre à true en prod HTTPS
SameSite: http.SameSiteLaxMode,
})
// HX-Redirect pour HTMX
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message":"Login success"}`))
}
}
func SSOLoginHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
var user models.User
if result := db.Where("sso_id = ?", code).First(&user); result.Error != nil || !user.IsActive {
http.Error(w, "Invalid SSO code", http.StatusUnauthorized)
return
}
token, err := jwt.CreateToken(user.Email)
if err != nil {
http.Error(w, "Token error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: false,
})
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
}
func SendWhatsAppMessage(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
val := r.Context().Value("ssoid")
ssoid, ok := val.(string)
if !ok || ssoid == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Println("✅ SSOID reçu depuis le contexte :", ssoid)
// Récupérer l'utilisateur en base via le SSOID
var user models.User
if err := db.Where("sso_id = ?", ssoid).First(&user).Error; err != nil || user.ID == 0 {
http.Error(w, "Utilisateur introuvable", http.StatusUnauthorized)
return
}
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
fmt.Printf("📨 Payload reçu : %+v\n", payload)
message, err := models.BuildMessageFromPayload(payload)
if err != nil {
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusBadRequest)
return
}
jsonBody, err := json.MarshalIndent(message, "", " ") // joli format
if err != nil {
http.Error(w, "Failed to encode message", http.StatusInternalServerError)
return
}
apiURL := fmt.Sprintf("https://graph.facebook.com/v22.0/%s/messages", user.WhatsappPhoneNumberID)
fmt.Println("📡 Envoi de la requête à :", apiURL)
fmt.Println("📦 JSON envoyé :")
fmt.Println(string(jsonBody))
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
http.Error(w, "Failed to create request", http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+user.WhatsappToken)
fmt.Println("📋 Headers :")
for key, vals := range req.Header {
for _, v := range vals {
fmt.Printf(" %s: %s\n", key, v)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "Failed to contact WhatsApp API", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// 🛠 debug réponse
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("✅ Réponse WhatsApp (%d) : %s\n", resp.StatusCode, string(respBody))
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
}
}
var MessageTypeCreditCost = map[string]uint{
"text": 1,
"image": 2,
"video": 2,
"audio": 1,
"document": 2,
"sticker": 1,
"interactive": 1,
"reaction": 0,
"location": 1,
"contacts": 1,
}
func WebhookHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
entry := body["entry"].([]interface{})[0].(map[string]interface{})
changes := entry["changes"].([]interface{})[0].(map[string]interface{})
value := changes["value"].(map[string]interface{})
// Gestion des messages entrants
if msgs, ok := value["messages"]; ok {
messages := msgs.([]interface{})
for _, msg := range messages {
m := msg.(map[string]interface{})
waID := value["contacts"].([]interface{})[0].(map[string]interface{})["wa_id"].(string)
msgID := m["id"].(string)
typeMsg := m["type"].(string)
content := extractMessageContent(m)
var user models.User
if err := db.Where("sso_id = ?", waID).First(&user).Error; err != nil {
fmt.Println("Utilisateur non trouvé pour:", waID)
continue
}
db.Create(&models.Conversation{
UserID: user.ID,
From: waID,
To: value["metadata"].(map[string]interface{})["display_phone_number"].(string),
MessageID: msgID,
Type: typeMsg,
Content: content,
Direction: "inbound",
})
credit := MessageTypeCreditCost[typeMsg]
if credit > 0 {
ConsumeCredits(db, &user, typeMsg, content, credit)
}
}
}
// Gestion des statuts (delivered, read, etc.)
if statuses, ok := value["statuses"]; ok {
for _, s := range statuses.([]interface{}) {
status := s.(map[string]interface{})
msgID := status["id"].(string)
state := status["status"].(string)
db.Model(&models.Conversation{}).Where("message_id = ?", msgID).Update("status", state)
}
}
w.WriteHeader(http.StatusOK)
}
}
func extractMessageContent(m map[string]interface{}) string {
t := m["type"].(string)
switch t {
case "text":
return m["text"].(map[string]interface{})["body"].(string)
case "image", "video", "audio", "document", "sticker":
return m[t].(map[string]interface{})["id"].(string)
case "interactive":
return m[t].(map[string]interface{})["type"].(string)
default:
return "[non textuel]"
}
}
func ConsumeCredits(db *gorm.DB, user *models.User, messageType, content string, credits uint) {
user.CurrentMonthCredits -= credits
db.Save(user)
db.Create(&models.Consumption{
UserID: user.ID,
MessageType: messageType,
Description: content,
CreditsUsed: credits,
})
month := time.Now().Format("2006-01")
var mc models.MonthlyConsumption
db.Where("user_id = ? AND month = ?", user.ID, month).FirstOrInit(&mc)
mc.UserID = user.ID
mc.Month = month
mc.TotalUsed += credits
db.Save(&mc)
}
func GetUserConversations(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.Atoi(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var conversations []models.Conversation
if err := db.Where("user_id = ?", userID).
Order("created_at desc").
Find(&conversations).Error; err != nil {
http.Error(w, "Error retrieving conversations", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(conversations)
}
}
// WebhookVerifyHandler répond au GET initial de vérification Meta
func WebhookVerifyHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
mode := r.URL.Query().Get("hub.mode")
token := r.URL.Query().Get("hub.verify_token")
challenge := r.URL.Query().Get("hub.challenge")
if mode == "subscribe" && token == os.Getenv("WHATSAPP_WEBHOOK_TOKEN") {
w.WriteHeader(http.StatusOK)
w.Write([]byte(challenge))
return
}
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Invalid verify token"))
}
}
func CreateUser(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var user models.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Hash error", http.StatusInternalServerError)
return
}
user.Password = string(hash)
if err := db.Create(&user).Error; err != nil {
http.Error(w, "Create failed", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
}
func GetAllUsers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var users []models.User
db.Find(&users)
json.NewEncoder(w).Encode(users)
}
}
func GetUserByID(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(mux.Vars(r)["id"])
var user models.User
if err := db.First(&user, id).Error; err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
}
func UpdateUser(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(mux.Vars(r)["id"])
var user models.User
if err := db.First(&user, id).Error; err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
var input models.User
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if input.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
input.Password = string(hash)
} else {
input.Password = user.Password
}
input.ID = user.ID
if err := db.Model(&user).Updates(input).Error; err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
}
func DeleteUser(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(mux.Vars(r)["id"])
if err := db.Delete(&models.User{}, id).Error; err != nil {
http.Error(w, "Delete failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -0,0 +1,79 @@
package jwt
import (
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
/*
Dans le snippet de code ci-dessus, nous importons les colis nécessaires,
y compris github.com/golang-jwt/jwt/v5.
Nous créons un nouveau jeton JWT en utilisant le jwt.NewWithClaims()fonction.
Nous spécifions la méthode de signature comme HS256 et des informations pertinentes telles que le nom d'utilisateur
et la date d'expiration du jeton. Ensuite, nous signons le jeton avec une clé secrète et retournons
le jeton généré comme une chaîne.
*/
var secretKey = []byte("secret-key")
func CreateToken(username string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
tokenString, err := token.SignedString(secretKey)
if err != nil {
return "", err
}
fmt.Sprintf(tokenString)
return tokenString, nil
}
/*
Dans le snippet de code ci-dessus, nous utilisons le jwt.Parse()fonctionner pour
analyser et vérifier le jeton. Nous fournissons une fonction de rappel pour récupérer
la clé secrète utilisée pour signer le jeton. Si le jeton est valide, nous continuons à traiter la demande;
sinon, nous renvoyons une erreur indiquant que le jeton est invalide.
*/
func verifyToken(tokenString string) error {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return secretKey, nil
})
if err != nil {
return err
}
if !token.Valid {
return fmt.Errorf("invalid token")
}
return nil
}
func ProtectedHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
tokenString := r.Header.Get("Authorization")
if tokenString == "" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Missing authorization header")
return
}
tokenString = tokenString[len("Bearer "):]
err := verifyToken(tokenString)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, "Invalid token")
return
}
fmt.Fprint(w, "Welcome to the the protected area")
}

35
backend/main.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"cangui/whatsapp/backend/db"
"cangui/whatsapp/backend/middleware"
"cangui/whatsapp/backend/routes"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
// 1. Démarrer le routeur principal
r := mux.NewRouter()
// 2. Initialiser la DB
bd := db.InitDB()
// 3. Routes non protégées : on les monte sur le routeur principal
routes.RoutesPublic(r, bd)
// 4. Créer un sous-routeur pour les routes protégées
protected := r.PathPrefix("/").Subrouter()
// 5. Appliquer le middleware JWT à ce sous-routeur
protected.Use(middleware.AuthMiddleware)
// 6. Enregistrer les routes protégées sur ce sous-routeur
routes.RoutesProtected(protected, bd)
// 7. Lancer le serveur sur le port 4000
log.Fatal(http.ListenAndServe(":3003", r))
}

View File

@ -0,0 +1,100 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt"
)
var secretKey = []byte("secret-key")
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenString string
// 1. Vérifie Authorization: Bearer ...
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
// 2. Vérifie le cookie si pas de header
if tokenString == "" {
if cookie, err := r.Cookie("token"); err == nil {
tokenString = cookie.Value
}
}
// 3. Vérifie le body JSON uniquement si POST ou PUT
if tokenString == "" && r.Method == http.MethodPost || r.Method == http.MethodPut {
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
bodyCopy, _ := io.ReadAll(r.Body)
var bodyMap map[string]interface{}
if err := json.Unmarshal(bodyCopy, &bodyMap); err == nil {
if val, ok := bodyMap["token"].(string); ok {
tokenString = val
// restaure le body
r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
}
}
}
}
if tokenString == "" {
http.Error(w, "Token manquant", http.StatusUnauthorized)
return
}
// ✅ Vérifie et parse le token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("méthode signature invalide")
}
return secretKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Token invalide", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Claims invalides", http.StatusUnauthorized)
return
}
// vérifie lexpiration
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
http.Error(w, "Token expiré", http.StatusUnauthorized)
return
}
}
// récupère lidentifiant
ssoid, ok := claims["username"].(string)
if !ok {
http.Error(w, "SSOID manquant", http.StatusUnauthorized)
return
}
// injection dans le contexte
ctx := context.WithValue(r.Context(), "ssoid", ssoid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// helper pour remarshal le JSON quand il est relu après parse
func mustMarshal(data map[string]interface{}) []byte {
b, _ := json.Marshal(data)
return b
}

378
backend/models/models.go Normal file
View File

@ -0,0 +1,378 @@
package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
type UserRole string
const (
ROLE_ADMIN UserRole = "ADMIN"
ROLE_CLIENT UserRole = "CLIENT"
)
type User struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Password string `gorm:"not null"` // hashé avec bcrypt
Email string `gorm:"uniqueIndex;not null"`
SSOID string `gorm:"uniqueIndex;not null"`
Role UserRole `gorm:"type:enum('ADMIN','CLIENT');default:'CLIENT';not null"`
IsActive bool `gorm:"default:true"`
WhatsappToken string `gorm:"type:text"` // token Meta spécifique au client
WhatsappPhoneNumberID string `gorm:"type:varchar(50)"` // ID du numéro WhatsApp Business
MonthlyCredits uint `gorm:"default:100"`
CurrentMonthCredits uint `gorm:"default:100"`
LastRecharge *time.Time `gorm:"default:null"`
Consumption []Consumption
MonthlyConsumptions []MonthlyConsumption
}
type Consumption struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
UserID uint `gorm:"index;not null"`
MessageType string `gorm:"type:varchar(20);not null"` // ex: text, image, button
Description string `gorm:"type:text"`
CreditsUsed uint `gorm:"not null"`
}
type MonthlyConsumption struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
UserID uint `gorm:"index;not null"`
Month string `gorm:"type:char(7);index;not null"` // Format YYYY-MM
TotalUsed uint `gorm:"not null"` // Crédits utilisés ce mois
}
type WhatsappMessage struct {
MessagingProduct string `json:"messaging_product"` // always "whatsapp"
To string `json:"to"` // recipient phone number
Type string `json:"type"` // message type (text, image, video, etc.)
Context *Context `json:"context,omitempty"` // for replies
RecipientType string `json:"recipient_type,omitempty"` // optional
Text *Text `json:"text,omitempty"`
Image *Media `json:"image,omitempty"`
Video *Media `json:"video,omitempty"`
Document *Media `json:"document,omitempty"`
Audio *Media `json:"audio,omitempty"`
Sticker *Media `json:"sticker,omitempty"`
Location *Location `json:"location,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Interactive *Interactive `json:"interactive,omitempty"`
Reaction *Reaction `json:"reaction,omitempty"`
}
type Address struct {
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
Zip string `json:"zip,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
Type string `json:"type,omitempty"` // home, work
}
type Contact struct {
Addresses []Address `json:"addresses,omitempty"`
Name *Name `json:"name,omitempty"`
Emails []Email `json:"emails,omitempty"`
Phones []Phone `json:"phones,omitempty"`
}
type Name struct {
FormattedName string `json:"formatted_name"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
}
type Email struct {
Email string `json:"email"`
Type string `json:"type,omitempty"` // work, home
}
type Phone struct {
Phone string `json:"phone"`
Type string `json:"type,omitempty"`
}
type Media struct {
ID string `json:"id,omitempty"` // media ID from upload
Link string `json:"link,omitempty"` // direct URL
Caption string `json:"caption,omitempty"` // for image/video
Filename string `json:"filename,omitempty"` // for documents
MimeType string `json:"mime_type,omitempty"`
}
type Location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Url string `json:"url,omitempty"`
IsLive bool `json:"is_live,omitempty"` // si demande de localisation en direct
}
type Text struct {
Body string `json:"body"`
PreviewURL bool `json:"preview_url,omitempty"`
}
type Context struct {
MessageID string `json:"message_id"`
}
type Reaction struct {
MessageID string `json:"message_id"`
Emoji string `json:"emoji"`
}
type StatusIndicator struct {
Status string `json:"status"` // "read" or "typing"
To string `json:"to"`
}
type Interactive struct {
Type string `json:"type"` // button | list | flow
Body *BodyContent `json:"body"`
Action *Action `json:"action"`
}
type BodyContent struct {
Text string `json:"text"`
}
type Action struct {
Button string `json:"button,omitempty"`
Buttons []ReplyButton `json:"buttons,omitempty"`
Sections []Section `json:"sections,omitempty"` // for list
CatalogID string `json:"catalog_id,omitempty"` // for flow
ProductID string `json:"product_retailer_id,omitempty"` // for flow
}
type ReplyButton struct {
Type string `json:"type"` // reply
Reply ButtonReply `json:"reply"`
}
type ButtonReply struct {
ID string `json:"id"`
Title string `json:"title"`
}
type Section struct {
Title string `json:"title,omitempty"`
Rows []ListItem `json:"rows"`
}
type ListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
type StatusMessage struct {
MessagingProduct string `json:"messaging_product"`
To string `json:"to"`
Type string `json:"type"` // "typing_on", "typing_off", "read"
}
type Conversation struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
UserID uint `gorm:"index;not null"`
From string `gorm:"not null"`
To string `gorm:"not null"`
MessageID string `gorm:"uniqueIndex;not null"`
Type string `gorm:"not null"`
Content string `gorm:"type:text"`
Direction string `gorm:"not null"` // inbound/outbound
Status string `gorm:"type:varchar(20)"` // sent, delivered, read, failed
}
func NewTextMessage(to string, body string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "text",
Text: &Text{Body: body},
}
}
func NewImageMessage(to, mediaID, caption string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "image",
Image: &Media{ID: mediaID, Caption: caption},
}
}
func NewVideoMessage(to, mediaID, caption string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "video",
Video: &Media{ID: mediaID, Caption: caption},
}
}
func NewAudioMessage(to, mediaID string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "audio",
Audio: &Media{ID: mediaID},
}
}
func NewDocumentMessage(to, mediaID, filename string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "document",
Document: &Media{ID: mediaID, Filename: filename},
}
}
func NewStickerMessage(to, mediaID string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "sticker",
Sticker: &Media{ID: mediaID},
}
}
func NewContactMessage(to string, contact Contact) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "contacts",
Contacts: []Contact{contact},
}
}
func NewLocationMessage(to string, lat, lng float64, name, address string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "location",
Location: &Location{Latitude: lat, Longitude: lng, Name: name, Address: address},
}
}
func NewReactionMessage(to, msgID, emoji string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "reaction",
Reaction: &Reaction{MessageID: msgID, Emoji: emoji},
}
}
func NewReplyMessage(to, msgID, body string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "text",
Context: &Context{MessageID: msgID},
Text: &Text{Body: body},
}
}
func NewInteractiveButtons(to string, text string, buttons []ReplyButton) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "interactive",
Interactive: &Interactive{Type: "button", Body: &BodyContent{Text: text}, Action: &Action{Buttons: buttons}},
}
}
func NewInteractiveList(to string, text string, sections []Section) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "interactive",
Interactive: &Interactive{Type: "list", Body: &BodyContent{Text: text}, Action: &Action{Sections: sections}},
}
}
func NewFlowMessage(to, text, catalogID, productID string) WhatsappMessage {
return WhatsappMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "interactive",
Interactive: &Interactive{Type: "flow", Body: &BodyContent{Text: text}, Action: &Action{CatalogID: catalogID, ProductID: productID}},
}
}
func NewTypingIndicator(to string) StatusMessage {
return StatusMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "typing_on",
}
}
func NewReadReceipt(to string) StatusMessage {
return StatusMessage{
MessagingProduct: "whatsapp",
To: to,
Type: "read",
}
}
func BuildMessageFromPayload(payload map[string]interface{}) (interface{}, error) {
to := fmt.Sprintf("%v", payload["to"])
typeStr := fmt.Sprintf("%v", payload["type"])
switch typeStr {
case "text":
if text, ok := payload["text"].(map[string]interface{}); ok {
return NewTextMessage(to, fmt.Sprintf("%v", text["body"])), nil
}
case "image":
if image, ok := payload["image"].(map[string]interface{}); ok {
return NewImageMessage(to, fmt.Sprintf("%v", image["id"]), fmt.Sprintf("%v", image["caption"])), nil
}
case "video":
if video, ok := payload["video"].(map[string]interface{}); ok {
return NewVideoMessage(to, fmt.Sprintf("%v", video["id"]), fmt.Sprintf("%v", video["caption"])), nil
}
case "audio":
if audio, ok := payload["audio"].(map[string]interface{}); ok {
return NewAudioMessage(to, fmt.Sprintf("%v", audio["id"])), nil
}
case "document":
if doc, ok := payload["document"].(map[string]interface{}); ok {
return NewDocumentMessage(to, fmt.Sprintf("%v", doc["id"]), fmt.Sprintf("%v", doc["filename"])), nil
}
case "sticker":
if st, ok := payload["sticker"].(map[string]interface{}); ok {
return NewStickerMessage(to, fmt.Sprintf("%v", st["id"])), nil
}
case "location":
if loc, ok := payload["location"].(map[string]interface{}); ok {
lat := loc["latitude"].(float64)
lng := loc["longitude"].(float64)
return NewLocationMessage(to, lat, lng, fmt.Sprintf("%v", loc["name"]), fmt.Sprintf("%v", loc["address"])), nil
}
case "reaction":
if react, ok := payload["reaction"].(map[string]interface{}); ok {
return NewReactionMessage(to, fmt.Sprintf("%v", react["message_id"]), fmt.Sprintf("%v", react["emoji"])), nil
}
case "typing_on", "typing_off", "read":
return StatusMessage{MessagingProduct: "whatsapp", To: to, Type: typeStr}, nil
default:
return nil, fmt.Errorf("unsupported message type: %s", typeStr)
}
return nil, fmt.Errorf("failed to build message")
}

186
backend/renders/renders.go Normal file
View File

@ -0,0 +1,186 @@
package renders
import (
"cangui/whatsapp/backend/models"
"net/http"
"strconv"
"text/template"
"time"
"github.com/gorilla/mux"
"gorm.io/gorm"
)
func Login(w http.ResponseWriter, r *http.Request){
renderTemplate(w,"login",nil)
}
func JwtTest(w http.ResponseWriter, r *http.Request){
renderTemplate(w,"jwt",nil)
}
func TestMessagesPages(w http.ResponseWriter, r *http.Request){
renderTemplate(w,"test",nil)
}
func AdminUserEdit(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(mux.Vars(r)["id"])
var user models.User
if err := db.First(&user, id).Error; err != nil {
http.Error(w, "Utilisateur introuvable", http.StatusNotFound)
return
}
data := map[string]interface{}{
"User": user,
}
renderPartial(w, "admin_user_edit", data)
}
}
func AdminUserCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"User": models.User{},
}
renderPartial(w, "admin_user_edit", data)
}
}
func AdminConversationPage(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
data := map[string]interface{}{
"UserID": idStr,
}
renderTemplate(w, "admin_conversations", data)
}
}
func AdminConversationRows(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, _ := strconv.Atoi(idStr)
typeFilter := r.URL.Query().Get("type")
var convs []models.Conversation
query := db.Where("user_id = ?", id)
if typeFilter != "" {
query = query.Where("type = ?", typeFilter)
}
query.Order("created_at desc").Find(&convs)
data := map[string]interface{}{
"Conversations": convs,
"UserID": idStr,
}
renderPartial(w, "admin_conversations_rows", data)
}
}
func AdminConversationThread(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, _ := strconv.Atoi(idStr)
var convs []models.Conversation
db.Where("user_id = ?", id).Order("created_at asc").Find(&convs)
data := map[string]interface{}{
"Conversations": convs,
"UserID": idStr,
}
renderPartial(w, "admin_conversations_thread", data)
}
}
func Dashboard(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
val := r.Context().Value("ssoid")
ssoid, ok := val.(string)
if !ok || ssoid == "" {
http.Error(w, "Utilisateur non authentifié", http.StatusUnauthorized)
return
}
var user models.User
if err := db.Where("sso_id = ?", ssoid).First(&user).Error; err != nil {
http.Error(w, "Utilisateur introuvable", http.StatusUnauthorized)
return
}
filter := r.URL.Query().Get("filter")
var fromDate time.Time
now := time.Now()
switch filter {
case "today":
fromDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
case "week":
fromDate = now.AddDate(0, 0, -7)
default:
fromDate = time.Time{} // zero time: no filter
}
var totalUsers int64
var totalCredits uint
var totalConversations int64
var conversations []models.Conversation
if user.Role == models.ROLE_ADMIN {
db.Model(&models.User{}).Count(&totalUsers)
db.Model(&models.Consumption{}).Select("SUM(credits_used)").Scan(&totalCredits)
db.Model(&models.Conversation{}).Where("created_at >= ?", fromDate).Count(&totalConversations)
db.Where("created_at >= ?", fromDate).Order("created_at desc").Limit(10).Find(&conversations)
} else {
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&totalUsers)
db.Model(&models.Consumption{}).Where("user_id = ?", user.ID).Select("SUM(credits_used)").Scan(&totalCredits)
db.Model(&models.Conversation{}).Where("user_id = ? AND created_at >= ?", user.ID, fromDate).Count(&totalConversations)
db.Where("user_id = ? AND created_at >= ?", user.ID, fromDate).Order("created_at desc").Limit(10).Find(&conversations)
}
data := map[string]interface{}{
"User": user,
"Stats": map[string]interface{}{
"TotalUsers": totalUsers,
"TotalCreditsUsed": totalCredits,
"TotalConversations": totalConversations,
},
"Conversations": conversations,
}
renderTemplate(w, "dashboard", data)
}
}
func renderTemplate(w http.ResponseWriter, templ string, data map[string]interface{}) {
t, err := template.ParseFiles(
"./frontend/templates/head.pages.tmpl", // Template inclus
"./frontend/templates/" + templ + ".pages.tmpl", // Template principal
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Exécutez explicitement le template principal
err = t.ExecuteTemplate(w, templ+".pages.tmpl", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func renderPartial(w http.ResponseWriter, templ string, data map[string]interface{}) {
t, err := template.ParseFiles("./frontend/templates/" + templ + ".pages.tmpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

71
backend/routes/routes.go Normal file
View File

@ -0,0 +1,71 @@
package routes
import (
"cangui/whatsapp/backend/handlers"
"cangui/whatsapp/backend/renders"
"net/http"
"github.com/gorilla/mux"
"gorm.io/gorm"
)
// Routes non protégées
func RoutesPublic(r *mux.Router, db *gorm.DB) {
// Fichiers statiques (CSS, JS, etc.)
staticDir := "./frontend/assets/"
r.PathPrefix("/frontend/assets/").Handler(
http.StripPrefix("/frontend/assets/", http.FileServer(http.Dir(staticDir))),
)
// Page de login
r.HandleFunc("/login", renders.Login)
r.HandleFunc("/api/whatsapp/webhook", handlers.WebhookVerifyHandler()).Methods("GET")
// Endpoint d'API pour se logger
r.HandleFunc("/api/login", handlers.LoginHandler(db)).Methods("POST")
}
// Routes protégées
func RoutesProtected(r *mux.Router, db *gorm.DB) {
r.HandleFunc("/jwt", renders.JwtTest)
r.HandleFunc("/api/message/send", handlers.SendWhatsAppMessage(db)).Methods("POST")
r.HandleFunc("/admin/user/{id}/conversations", renders.AdminConversationPage(db))
r.HandleFunc("/api/user/{id}/conversations", renders.AdminConversationRows(db))
r.HandleFunc("/admin/user/{id}/edit", renders.AdminUserEdit(db)).Methods("GET")
r.HandleFunc("/admin/user/new", renders.AdminUserCreate()).Methods("GET")
r.HandleFunc("/dashboard", renders.Dashboard(db))
r.HandleFunc("/test/send", renders.TestMessagesPages)
// // Ici on place les vues et API qui doivent être protégées
// r.HandleFunc("/stream", StreamHandler)
// r.HandleFunc("/dashboard", renders.Dashboard(bd))
// r.HandleFunc("/settings", renders.Settings)
// r.HandleFunc("/library", renders.Library)
// r.HandleFunc("/menuLibary", renders.Library)
// r.HandleFunc("/godownloader/downloads", renders.GoDownload)
// r.HandleFunc("/godownloader/linkcollectors", renders.GoDownloadLinkCollectors)
// r.HandleFunc("/godownloader/settings", renders.GoDownloadSetting)
// // API user
// r.HandleFunc("/api/user/create", users.CreateUser(bd)).Methods("POST")
// r.HandleFunc("/api/user/update/{id}", users.UpdateUser(bd)).Methods("PUT")
// r.HandleFunc("/api/user/delete/{id}", users.DeleteUser(bd)).Methods("DELETE")
// r.HandleFunc("/api/user/all/", users.ReadAllUser(bd)).Methods("GET")
// r.HandleFunc("/api/user/{id}", users.FindUserById(bd)).Methods("GET")
// // API download
// r.HandleFunc("/api/pathDownload/create", download.CreateSavePath(bd)).Methods("POST")
// r.HandleFunc("/api/pathDownload/update/{id}", download.UpdateSavePath(bd)).Methods("PUT")
// r.HandleFunc("/api/pathDownload/delete/{id}", download.DeleteSavePath(bd)).Methods("DELETE")
// r.HandleFunc("/api/pathDownload/all/", download.ReadAllSavePath(bd)).Methods("GET")
// //API Check path
// r.HandleFunc("/validate-path", download.PathValidationHandler)
//API Scan folder
}

54
docker-compose.yml Normal file
View File

@ -0,0 +1,54 @@
version: "3.8"
services:
app:
build: .
container_name: whatsapp_saas_app
platform: linux/amd64
ports:
- "3003:3003"
environment:
- DB_HOST=db
- DB_PORT=3306
- DB_USER=root
- DB_PASS=secret
- DB_NAME=whatsapp_saas
- ENV=dev
- WHATSAPP_TOKEN=${WHATSAPP_TOKEN}
- WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID}
- WHATSAPP_WEBHOOK_TOKEN=${WHATSAPP_WEBHOOK_TOKEN}
volumes:
- ./frontend/templates:/app/frontend/templates
- ./frontend/assets:/app/frontend/assets
depends_on:
- db
db:
image: mariadb:10.6
container_name: whatsapp_saas_db
restart: always
environment:
MARIADB_ROOT_PASSWORD: secret
MARIADB_DATABASE: whatsapp_saas
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: whatsapp_saas_phpmyadmin
restart: always
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: root
PMA_PASSWORD: secret
ports:
- "8081:80"
depends_on:
- db
volumes:
db_data:

6
frontend/assets/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12057
frontend/assets/css/boostrap/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
frontend/assets/css/bulma.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
button.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-out;
}
#path-list .column{
padding-left: inherit;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4494
frontend/assets/js/bootstrap/bootstrap.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
function toggleMenu() {
const menu = document.getElementById('libraryMenu');
menu.hidden = !menu.hidden;
}
function toggleMenuGoDownload() {
const menu = document.getElementById('menuDownload');
menu.hidden = !menu.hidden;
}

View File

@ -0,0 +1,13 @@
document.body.addEventListener('htmx:confirm', function(evt) {
// 0. To modify the behavior only for elements with the hx-confirm attribute,
// check if evt.detail.target.hasAttribute('hx-confirm')
// 1. Prevent the default behavior (this will prevent the request from being issued)
evt.preventDefault();
// 2. Do your own logic here
console.log(evt.detail)
// 3. Manually issue the request when you are ready
evt.detail.issueRequest(); // or evt.detail.issueRequest(true) to skip the built-in window.confirm()
});

5261
frontend/assets/js/htmx.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,85 @@
console.log("la");
console.log("la");
async function validatePath() {
const pathInput = document.getElementById('path-input');
const statusIcon = document.getElementById('path-status-icon');
const validateBtn = document.getElementById('validate-btn');
const inputPath=document.getElementById('path');
const inputPathV=document.getElementById('namePath');
const path = pathInput.value;
if (!path) {
statusIcon.innerHTML = '<i class="fas fa-times has-text-danger"></i>';
validateBtn.disabled = true;
return;
}
statusIcon.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i>'; // Loading icon
try {
const response = await fetch('/validate-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (response.ok) {
statusIcon.innerHTML = '<i class="fas fa-check-square"></i>';
validateBtn.disabled = false;
inputPath.value=path;
inputPathV.style.display="block";
} else {
const result = await response.json();
statusIcon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
validateBtn.disabled = true;
console.error('Error:', result.error);
}
} catch (error) {
statusIcon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
validateBtn.disabled = true;
console.error('Request failed:', error);
}
}
function disableAllInputPath(id){
console.log(this)
var inputs = document.querySelectorAll('#path-'+id+' .fff');
var btn =document.getElementById('btn-path-annuler-'+id)
btn.style.display = "none";
var btn2 =document.getElementById('btn-path-edit-'+id)
btn2.style.display = "block";
var btn3 =document.getElementById('btn-path-valider-'+id)
btn3.style.display = "none";
inputs.forEach(function(input) {
input.disabled = true;
});
}
function enableAllInputPath(id){
console.log(this)
var inputs = document.querySelectorAll('#path-'+id+' .fff');
var btn =document.getElementById('btn-path-annuler-'+id)
btn.style.display = "block";
var btn2 =document.getElementById('btn-path-edit-'+id)
btn2.style.display = "none";
var btn3 =document.getElementById('btn-path-valider-'+id)
btn3.style.display = "block";
inputs.forEach(function(input) {
input.disabled = false;
});
}
function setInputHidden(target,value){
document.getElementById(target).value = value;
}
function hide(target){
var btn =document.getElementById(target)
btn.style.display = "none";
}
document.addEventListener("htmx:afterOnLoad", function (event) {
console.log("Réponse du serveur :", event.detail.xhr.responseText);
});

View File

@ -0,0 +1,36 @@
(function() {
let api
htmx.defineExtension('json-enc', {
init: function(apiRef) {
api = apiRef
},
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json'
}
},
encodeParameters: function(xhr, parameters, elt) {
xhr.overrideMimeType('text/json')
const vals = api.getExpressionVars(elt)
const object = {}
parameters.forEach(function(value, key) {
// FormData encodes values as strings, restore hx-vals/hx-vars with their initial types
const typedValue = Object.hasOwn(vals, key) ? vals[key] : value
if (Object.hasOwn(object, key)) {
if (!Array.isArray(object[key])) {
object[key] = [object[key]]
}
object[key].push(typedValue)
} else {
object[key] = typedValue
}
})
return (JSON.stringify(object))
}
})
})()

291
frontend/assets/js/sse.js Normal file
View File

@ -0,0 +1,291 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

476
frontend/assets/js/ws.js Normal file
View File

@ -0,0 +1,476 @@
/*
WebSockets Extension
============================
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/
(function () {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
htmx.defineExtension("ws", {
/**
* init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef
*/
init: function (apiRef) {
// Store reference to internal API
api = apiRef;
// Default function for creating new EventSource objects
if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket;
}
// Default setting for reconnect delay
if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = "full-jitter";
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function (name, evt) {
var parent = evt.target || evt.detail.elt;
switch (name) {
// Try to close the socket when elements are removed
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(parent)
if (internalData.webSocket) {
internalData.webSocket.close();
}
return;
// Try to create websockets when elements are processed
case "htmx:beforeProcessNode":
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
ensureWebSocket(child)
});
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
ensureWebSocketSend(child)
});
}
}
});
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/);
}
function getLegacyWebsocketURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "connect") {
return value[1];
}
}
}
}
/**
* ensureWebSocket creates a new WebSocket on the designated element, using
* the element's "ws-connect" attribute.
* @param {HTMLElement} socketElt
* @returns
*/
function ensureWebSocket(socketElt) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(socketElt)) {
return;
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
if (wssSource == null || wssSource === "") {
var legacySource = getLegacyWebsocketURL(socketElt);
if (legacySource == null) {
return;
} else {
wssSource = legacySource;
}
}
// Guarantee that the wssSource value is a fully qualified URL
if (wssSource.indexOf("/") === 0) {
var base_part = location.hostname + (location.port ? ':' + location.port : '');
if (location.protocol === 'https:') {
wssSource = "wss://" + base_part + wssSource;
} else if (location.protocol === 'http:') {
wssSource = "ws://" + base_part + wssSource;
}
}
var socketWrapper = createWebsocketWrapper(socketElt, function () {
return htmx.createWebSocket(wssSource)
});
socketWrapper.addEventListener('message', function (event) {
if (maybeCloseWebSocketSource(socketElt)) {
return;
}
var response = event.data;
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
message: response,
socketWrapper: socketWrapper.publicInterface
})) {
return;
}
api.withExtensions(socketElt, function (extension) {
response = extension.transformResponse(response, null, socketElt);
});
var settleInfo = api.makeSettleInfo(socketElt);
var fragment = api.makeFragment(response);
if (fragment.children.length) {
var children = Array.from(fragment.children);
for (var i = 0; i < children.length; i++) {
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
}
}
api.settleImmediately(settleInfo.tasks);
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
});
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(socketElt).webSocket = socketWrapper;
}
/**
* @typedef {Object} WebSocketWrapper
* @property {WebSocket} socket
* @property {Array<{message: string, sendElt: Element}>} messageQueue
* @property {number} retryCount
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
* @property {(message: string, sendElt: Element) => void} send
* @property {(event: string, handler: Function) => void} addEventListener
* @property {() => void} handleQueuedMessages
* @property {() => void} init
* @property {() => void} close
*/
/**
*
* @param socketElt
* @param socketFunc
* @returns {WebSocketWrapper}
*/
function createWebsocketWrapper(socketElt, socketFunc) {
var wrapper = {
socket: null,
messageQueue: [],
retryCount: 0,
/** @type {Object<string, Function[]>} */
events: {},
addEventListener: function (event, handler) {
if (this.socket) {
this.socket.addEventListener(event, handler);
}
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(handler);
},
sendImmediately: function (message, sendElt) {
if (!this.socket) {
api.triggerErrorEvent()
}
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message: message,
socketWrapper: this.publicInterface
})) {
this.socket.send(message);
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message: message,
socketWrapper: this.publicInterface
})
}
},
send: function (message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message: message, sendElt: sendElt });
} else {
this.sendImmediately(message, sendElt);
}
},
handleQueuedMessages: function () {
while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0]
if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
this.messageQueue.shift();
} else {
break;
}
}
},
init: function () {
if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket
this.socket.close()
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = socketFunc();
// The event.type detail is added for interface conformance with the
// other two lifecycle events (open and close) so a single handler method
// can handle them polymorphically, if required.
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
this.socket = socket;
socket.onopen = function (e) {
wrapper.retryCount = 0;
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
wrapper.handleQueuedMessages();
}
socket.onclose = function (e) {
// If socket should not be connected, stop further attempts to establish connection
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
setTimeout(function () {
wrapper.retryCount += 1;
wrapper.init();
}, delay);
}
// Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
};
socket.onerror = function (e) {
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
maybeCloseWebSocketSource(socketElt);
};
var events = this.events;
Object.keys(events).forEach(function (k) {
events[k].forEach(function (e) {
socket.addEventListener(k, e);
})
});
},
close: function () {
this.socket.close()
}
}
wrapper.init();
wrapper.publicInterface = {
send: wrapper.send.bind(wrapper),
sendImmediately: wrapper.sendImmediately.bind(wrapper),
queue: wrapper.messageQueue
};
return wrapper;
}
/**
* ensureWebSocketSend attaches trigger handles to elements with
* "ws-send" attribute
* @param {HTMLElement} elt
*/
function ensureWebSocketSend(elt) {
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
if (legacyAttribute && legacyAttribute !== 'send') {
return;
}
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
processWebSocketSend(webSocketParent, elt);
}
/**
* hasWebSocket function checks if a node has webSocket instance attached
* @param {HTMLElement} node
* @returns {boolean}
*/
function hasWebSocket(node) {
return api.getInternalData(node).webSocket != null;
}
/**
* processWebSocketSend adds event listeners to the <form> element so that
* messages can be sent to the WebSocket server when the form is submitted.
* @param {HTMLElement} socketElt
* @param {HTMLElement} sendElt
*/
function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt);
var triggerSpecs = api.getTriggerSpecs(sendElt);
triggerSpecs.forEach(function (ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) {
return;
}
/** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket;
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
var results = api.getInputValues(sendElt, 'post');
var errors = results.errors;
var rawParameters = results.values;
var expressionVars = api.getExpressionVars(sendElt);
var allParameters = api.mergeObjects(rawParameters, expressionVars);
var filteredParameters = api.filterValues(allParameters, sendElt);
var sendConfig = {
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers: headers,
errors: errors,
triggeringEvent: evt,
messageBody: undefined,
socketWrapper: socketWrapper.publicInterface
};
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
return;
}
if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors);
return;
}
var body = sendConfig.messageBody;
if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters);
if (sendConfig.headers)
toSend['HEADERS'] = headers;
body = JSON.stringify(toSend);
}
socketWrapper.send(body, elt);
if (evt && api.shouldCancel(evt, elt)) {
evt.preventDefault();
}
});
});
}
/**
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
* @param {number} retryCount // The number of retries that have already taken place
* @returns {number}
*/
function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay;
if (typeof delay === 'function') {
return delay(retryCount);
}
if (delay === 'full-jitter') {
var exp = Math.min(retryCount, 6);
var maxDelay = 1000 * Math.pow(2, exp);
return maxDelay * Math.random();
}
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
}
/**
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
* returns FALSE.
*
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
api.getInternalData(elt).webSocket.close();
return true;
}
return false;
}
/**
* createWebSocket is the default method for creating new WebSocket objects.
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
*
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url) {
var sock = new WebSocket(url, []);
sock.binaryType = htmx.config.wsBinaryType;
return sock;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = []
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
result.push(node)
})
return result
}
/**
* @template T
* @param {T[]} arr
* @param {(T) => void} func
*/
function forEach(arr, func) {
if (arr) {
for (var i = 0; i < arr.length; i++) {
func(arr[i]);
}
}
}
})();

View File

@ -0,0 +1,37 @@
<!-- admin_conversations.pages.tmpl -->
{{ define "admin_conversations.pages.tmpl" }}
<h2>Historique des messages de l'utilisateur #{{ .UserID }}</h2>
<div class="mb-3">
<label>Filtrer par type :</label>
<select id="filterType" name="type" hx-get="/api/user/{{ .UserID }}/conversations" hx-target="#conversationRows" hx-trigger="change" hx-include="#filterType">
<option value="">Tous</option>
<option value="text">Texte</option>
<option value="image">Image</option>
<option value="video">Vidéo</option>
<option value="interactive">Interactif</option>
</select>
</div>
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Direction</th>
<th>Expéditeur</th>
<th>Type</th>
<th>Contenu</th>
<th>Statut</th>
</tr>
</thead>
<tbody id="conversationRows"
hx-get="/api/user/{{ .UserID }}/conversations"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
</tbody>
</table>
<hr>
<h3>Conversation complète</h3>
<div id="threadViewer" class="mt-3"></div>
{{ end }}

View File

@ -0,0 +1,15 @@
{{ define "admin_conversations_rows.pages.tmpl" }}
{{ range .Conversations }}
<tr hx-get="/admin/user/{{ $.UserID }}/conversation-thread"
hx-target="#threadViewer"
hx-swap="innerHTML">
<td>{{ .CreatedAt.Format "2006-01-02 15:04:05" }}</td>
<td>{{ .Direction }}</td>
<td>{{ .From }}</td>
<td>{{ .Type }}</td>
<td>{{ .Content }}</td>
<td>{{ .Status }}</td>
</tr>
{{ end }}
{{ end }}

View File

@ -0,0 +1,45 @@
{{ define "admin_conversations_thread.pages.tmpl" }}
<div class="chat-thread">
{{ range .Conversations }}
<div class="bubble {{ if eq .Direction "inbound" }}left{{ else }}right{{ end }}">
<div class="meta">
<small><strong>{{ .From }}</strong> — {{ .CreatedAt.Format "2006-01-02 15:04:05" }}</small>
</div>
<div class="content">
{{ .Content }}
</div>
</div>
{{ end }}
</div>
<style>
.chat-thread {
padding: 1rem;
background: #f5f5f5;
border: 1px solid #ccc;
border-radius: 0.5rem;
max-height: 400px;
overflow-y: auto;
}
.bubble {
padding: 0.5rem 1rem;
margin-bottom: 0.5rem;
border-radius: 1rem;
max-width: 75%;
clear: both;
display: inline-block;
}
.bubble.left {
background-color: #e0e0e0;
float: left;
}
.bubble.right {
background-color: #d1e7dd;
float: right;
}
.meta {
font-size: 0.75rem;
color: #666;
margin-bottom: 0.25rem;
}
</style>
{{ end }}

View File

@ -0,0 +1,53 @@
{{ define "admin_user_edit.pages.tmpl" }}
<h2 class="title is-4">Modifier l'utilisateur #{{ .User.ID }}</h2>
<form hx-put="/api/user/update/{{ .User.ID }}" hx-target="#userList" hx-swap="outerHTML" class="box">
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email" value="{{ .User.Email }}" required>
</div>
</div>
<div class="field">
<label class="label">Mot de passe (laisser vide pour ne pas changer)</label>
<div class="control">
<input class="input" type="password" name="password">
</div>
</div>
<div class="field">
<label class="label">Role</label>
<div class="control">
<div class="select">
<select name="role">
<option value="CLIENT" {{ if eq .User.Role "CLIENT" }}selected{{ end }}>Client</option>
<option value="ADMIN" {{ if eq .User.Role "ADMIN" }}selected{{ end }}>Admin</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="is_active" {{ if .User.IsActive }}checked{{ end }}> Actif
</label>
</div>
<div class="field">
<label class="label">Token WhatsApp</label>
<input class="input" name="whatsapp_token" type="text" value="{{ .User.WhatsappToken }}">
</div>
<div class="field">
<label class="label">Phone Number ID</label>
<input class="input" name="whatsapp_phone_number_id" type="text" value="{{ .User.WhatsappPhoneNumberID }}">
</div>
<div class="field">
<div class="control">
<button class="button is-success" type="submit">💾 Enregistrer</button>
</div>
</div>
</form>
{{ end }}

View File

@ -0,0 +1,37 @@
{{ define "admin_users.pages.tmpl" }}
<h1 class="title">Gestion des utilisateurs</h1>
<button class="button is-primary"
hx-get="/admin/user/new"
hx-target="#editForm"
hx-swap="innerHTML">
Créer un utilisateur
</button>
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Role</th>
<th>Actif</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="userList">
{{ range .Users }}
<tr>
<td>{{ .ID }}</td>
<td>{{ .Email }}</td>
<td>{{ .Role }}</td>
<td>{{ if .IsActive }}✅{{ else }}❌{{ end }}</td>
<td>
<button class="button is-small is-info" hx-get="/admin/user/{{ .ID }}/edit" hx-target="#editForm" hx-swap="innerHTML">✏️ Modifier</button>
<button class="button is-small is-danger" hx-delete="/api/user/delete/{{ .ID }}" hx-confirm="Confirmer suppression ?" hx-target="#userList" hx-swap="outerHTML">🗑️ Supprimer</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
<hr>
<div id="editForm"></div>
{{ end }}

View File

@ -0,0 +1,77 @@
{{ define "dashboard.pages.tmpl" }}
<div class="columns">
<!-- Sidebar -->
<aside class="menu column is-2">
<p class="menu-label">Navigation</p>
<ul class="menu-list">
<li><a href="/dashboard">🏠 Dashboard</a></li>
{{ if eq .User.Role "ADMIN" }}
<li><a href="/admin/users">👤 Utilisateurs</a></li>
<li><a href="/test/send">📤 Test envoi</a></li>
{{ end }}
</ul>
</aside>
<!-- Main content -->
<div class="column is-10">
<h1 class="title">Tableau de bord</h1>
<div class="columns is-multiline">
<div class="column is-4">
<div class="box has-background-light">
<p class="title is-5">👥 Total utilisateurs</p>
<p class="subtitle is-4">{{ .Stats.TotalUsers }}</p>
</div>
</div>
<div class="column is-4">
<div class="box has-background-light">
<p class="title is-5">📈 Crédits consommés</p>
<p class="subtitle is-4">{{ .Stats.TotalCreditsUsed }}</p>
</div>
</div>
<div class="column is-4">
<div class="box has-background-light">
<p class="title is-5">💬 Conversations</p>
<p class="subtitle is-4">{{ .Stats.TotalConversations }}</p>
</div>
</div>
</div>
<h2 class="title is-5">Dernières conversations</h2>
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<a class="button is-link is-light" href="/dashboard?filter=today">📅 Aujourd'hui</a>
</div>
<div class="control">
<a class="button is-link is-light" href="/dashboard?filter=week">📆 Cette semaine</a>
</div>
<div class="control">
<a class="button is-link is-light" href="/dashboard">🔁 Tout</a>
</div>
</div>
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Date</th>
<th>De</th>
<th>À</th>
<th>Type</th>
<th>Contenu</th>
</tr>
</thead>
<tbody>
{{ range .Conversations }}
<tr>
<td>{{ .CreatedAt.Format "02/01/2006 15:04" }}</td>
<td>{{ .From }}</td>
<td>{{ .To }}</td>
<td>{{ .Type }}</td>
<td>{{ .Content }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,26 @@
{{ define "head" }}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="/frontend/assets/css/bulma.min.css">
<link
rel="stylesheet"
href="/frontend/assets/css/styles.css">
<script src="/frontend/assets/js/htmx.js" ></script>
<script src="/frontend/assets/js/sse.js"></script>
<script src="/frontend/assets/js/index.js" ></script>
<script src="/frontend/assets/js/json-enc.js"></script>
<script src="/frontend/assets/js/ws.js"></script>
<script src="/frontend/assets/js/function/functions.js"></script>
<title>Login</title>
</head>
{{ end }}

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Générateur JWT (test WhatsApp)</title>
<script src="https://cdn.jsdelivr.net/npm/jsonwebtoken@9.0.2/index.min.js"></script>
<script>
function generateJWT() {
const ssoid = document.getElementById("ssoid").value;
const secret = document.getElementById("secret").value;
const exp = Math.floor(Date.now() / 1000) + 60 * 60; // 1h
const payload = {
username: ssoid,
exp: exp
};
const token = window.jwt.sign(payload, secret);
document.getElementById("output").textContent = token;
document.getElementById("copyBtn").disabled = false;
}
function copyToken() {
const token = document.getElementById("output").textContent;
navigator.clipboard.writeText(token).then(() => {
alert("Token copié dans le presse-papier ✅");
});
}
</script>
</head>
<body>
<h1>Générateur JWT de test</h1>
<label>SSOID (username dans token):</label><br>
<input type="text" id="ssoid" value="admin001" size="30"><br><br>
<label>Clé secrète :</label><br>
<input type="text" id="secret" value="secret-key" size="30"><br><br>
<button onclick="generateJWT()">Générer le token JWT</button>
<h2>Token :</h2>
<pre id="output" style="white-space: pre-wrap; background: #f9f9f9; border: 1px solid #ccc; padding: 1rem;"></pre>
<button id="copyBtn" onclick="copyToken()" disabled>📋 Copier le token</button>
</body>
</html>

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head" . }}
<body>
<section class="hero is-fullheight is-flex is-justify-content-center is-align-items-center">
<div class="card" style="width: 400px;">
<div class="card-content">
<div class="content">
<div class="container has-text-centered">
<h1 >Login Panel</p>
</div>
<form >
<div class="mb-3">
<label for="formGroupExampleInput" class="form-label">Email</label>
<input type="text" class="input" id="email" placeholder="Votre email" name="email">
</div>
<div class="mb-3">
<label for="formGroupExampleInput2" class="form-label">Password</label>
<input type="password" class="input" id="password" placeholder="Password" name="password">
</div>
<br />
<div class="container has-text-centered">
<button class="button is-primary is-outlined" type="submit" hx-post="/api/login"
hx-ext="json-enc">
Login
</button>
</div>
</form>
</div>
</div>
</div>
</section>
</body>
<script src="/frontend/assets/js/function/login.js"></script>
</html>

View File

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Test Envoi WhatsApp</title>
<style>
body { font-family: Arial, sans-serif; padding: 2rem; }
fieldset { margin-bottom: 2rem; padding: 1rem; border: 1px solid #ccc; }
legend { font-weight: bold; }
label { display: block; margin-top: 0.5rem; }
textarea { width: 100%; height: 4rem; }
input[type="text"] { width: 100%; }
button { margin-top: 1rem; }
</style>
</head>
<body>
<h1>Tests d'envoi WhatsApp (JWT requis)</h1>
<!-- Bloc 1 : message texte simple -->
<fieldset>
<legend>1. Message texte simple</legend>
<label>Numéro (E.164) :</label>
<input type="text" id="to1" value="33600000000">
<label>Message :</label>
<textarea id="text1">Bonjour depuis le test simple !</textarea>
<button onclick="sendText()">Envoyer</button>
</fieldset>
<!-- Bloc 2 : message texte + média -->
<fieldset>
<legend>2. Message image + texte</legend>
<label>Numéro :</label>
<input type="text" id="to2" value="33600000000">
<label>ID média :</label>
<input type="text" id="media2" placeholder="media_id">
<label>Légende :</label>
<input type="text" id="caption2" value="Voici une image !">
<button onclick="sendImage()">Envoyer</button>
</fieldset>
<!-- Bloc 3 : message interactif -->
<fieldset>
<legend>3. Message interactif (image + bouton URL)</legend>
<label>Numéro :</label>
<input type="text" id="to3" value="33600000000">
<label>Texte :</label>
<input type="text" id="text3" value="Cliquez ci-dessous pour en savoir plus">
<label>Image (ID média) :</label>
<input type="text" id="img3" placeholder="media_id">
<label>Titre :</label>
<input type="text" id="title3" value="Promo spéciale">
<label>URL 1 :</label>
<input type="text" id="url1" value="https://example.com">
<label>URL 2 :</label>
<input type="text" id="url2" value="https://google.com">
<button onclick="sendInteractive()">Envoyer</button>
</fieldset>
<h2>Réponse :</h2>
<pre id="response" style="background:#f4f4f4; border:1px solid #ccc; padding:1rem;"></pre>
<script>
async function send(payload) {
const res = await fetch('/api/message/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const txt = await res.text();
document.getElementById('response').textContent = txt;
}
function sendText() {
const to = document.getElementById('to1').value;
const text = document.getElementById('text1').value;
send({ to, type: 'text', text: { body: text } });
}
function sendImage() {
const to = document.getElementById('to2').value;
const mediaID = document.getElementById('media2').value;
const caption = document.getElementById('caption2').value;
send({ to, type: 'image', image: { id: mediaID, caption: caption } });
}
function sendInteractive() {
const to = document.getElementById('to3').value;
const body = document.getElementById('text3').value;
const imageID = document.getElementById('img3').value;
const title = document.getElementById('title3').value;
const url1 = document.getElementById('url1').value;
const url2 = document.getElementById('url2').value;
const payload = {
to,
type: 'interactive',
interactive: {
type: 'button',
body: { text: body },
header: { type: 'image', image: { id: imageID } },
footer: { text: title },
action: {
buttons: [
{ type: 'url', url: url1, text: 'Visiter 1' },
{ type: 'url', url: url2, text: 'Visiter 2' }
]
}
}
};
send(payload);
}
</script>
</body>
</html>

29
go.mod Normal file
View File

@ -0,0 +1,29 @@
module cangui/whatsapp
go 1.24.0
require (
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/gorilla/mux v1.8.1
gorm.io/gorm v1.26.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/text v0.25.0 // indirect
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
)

38
go.sum Normal file
View File

@ -0,0 +1,38 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

93
note.txt Normal file
View File

@ -0,0 +1,93 @@
# 📘 Documentation Admin Intégration WhatsApp Business API
## 🔐 Configuration de base
### `.env` requis
```env
WHATSAPP_TOKEN=EAA...
WHATSAPP_PHONE_NUMBER_ID=1234567890
WHATSAPP_WEBHOOK_TOKEN=secrettoken
```
### Vérification du webhook (Meta)
- URL : `https://yourdomain/api/whatsapp/webhook`
- Token de vérification : doit correspondre à `WHATSAPP_WEBHOOK_TOKEN`
---
## 📩 Envoi de message
**Route :** `POST /api/message/send`
### Exemple JSON (texte)
```json
{
"to": "33612345678",
"type": "text",
"text": {
"body": "Bonjour 👋"
}
}
```
> Supporte aussi `image`, `video`, `document`, `interactive`, etc.
---
## 📥 Réception de messages / statuts
- Le webhook `POST /api/whatsapp/webhook` :
- enregistre les messages reçus dans la table `conversations`
- décrémente les crédits `CurrentMonthCredits`
- met à jour le `status` d'un message (`delivered`, `read`, etc.)
---
## 🧾 Historique utilisateur
### Interface admin
- URL : `/admin/user/{id}/conversations`
- Permet :
- de visualiser tous les messages avec filtres
- de voir la conversation complète au clic sur une ligne (HTMX)
### API :
`GET /api/user/{id}/conversations`
---
## 💬 Modèle de conversation
```go
type Conversation struct {
UserID uint
From string
To string
MessageID string
Type string
Content string
Direction string // inbound / outbound
Status string // delivered, read, sent...
}
```
---
## 🧮 Crédit consommé par message
| Type | Crédit |
|--------------|--------|
| text | 1 |
| image/video | 2 |
| document | 2 |
| interactive | 1 |
| sticker/audio| 1 |
| reaction | 0 |
---
## ✅ À tester
- [ ] Vérifier réception via webhook
- [ ] Vérifier affichage des conversations admin
- [ ] Vérifier décompte de crédits
- [ ] Tester `GET` de validation webhook avec Meta