feat(ui): add message cache management page and dashboard enhancements
- Introduced MessageCachePage for browsing and managing cached webhook events. - Enhanced DashboardPage to display runtime stats and message cache information. - Added new API types for message cache events and system stats. - Integrated SwaggerPage for API documentation and live request testing.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,3 +51,5 @@ Thumbs.db
|
|||||||
server.log
|
server.log
|
||||||
/data/*
|
/data/*
|
||||||
cmd/server/__debug*
|
cmd/server/__debug*
|
||||||
|
.gocache/
|
||||||
|
.pnpm-store/
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Add a new WhatsApp account to the system.
|
|||||||
|
|
||||||
**Endpoint:** `POST /api/accounts/add`
|
**Endpoint:** `POST /api/accounts/add`
|
||||||
|
|
||||||
**Request Body (WhatsApp Web/WhatsMe ow):**
|
**Request Body (Whatsapp):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -420,7 +420,7 @@ INFO Skipping disabled account account_id=business
|
|||||||
- Account will be reconnected automatically if enabled
|
- Account will be reconnected automatically if enabled
|
||||||
|
|
||||||
3. **Session Management**:
|
3. **Session Management**:
|
||||||
- WhatsMe ow sessions are stored in `session_path`
|
- Whatsapp sessions are stored in `session_path`
|
||||||
- Removing an account doesn't delete session files
|
- Removing an account doesn't delete session files
|
||||||
- Clean up manually if needed
|
- Clean up manually if needed
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,7 @@ Add to your `config.json`:
|
|||||||
{
|
{
|
||||||
"message_cache": {
|
"message_cache": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"storage": "database",
|
||||||
"data_path": "./data/message_cache",
|
"data_path": "./data/message_cache",
|
||||||
"max_age_days": 7,
|
"max_age_days": 7,
|
||||||
"max_events": 10000
|
"max_events": 10000
|
||||||
@@ -832,6 +833,7 @@ Here's a complete `config.json` with all Business API features:
|
|||||||
},
|
},
|
||||||
"message_cache": {
|
"message_cache": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"storage": "database",
|
||||||
"data_path": "./data/message_cache",
|
"data_path": "./data/message_cache",
|
||||||
"max_age_days": 7,
|
"max_age_days": 7,
|
||||||
"max_events": 10000
|
"max_events": 10000
|
||||||
|
|||||||
@@ -114,5 +114,12 @@
|
|||||||
"file_dir": "./data/events",
|
"file_dir": "./data/events",
|
||||||
"table_name": "event_logs"
|
"table_name": "event_logs"
|
||||||
},
|
},
|
||||||
|
"message_cache": {
|
||||||
|
"enabled": true,
|
||||||
|
"storage": "database",
|
||||||
|
"data_path": "./data/message_cache",
|
||||||
|
"max_age_days": 7,
|
||||||
|
"max_events": 10000
|
||||||
|
},
|
||||||
"log_level": "info"
|
"log_level": "info"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/api"
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Example: Initialize Phase 2 components and start the API server
|
|
||||||
func main() {
|
|
||||||
// Setup logging
|
|
||||||
logging.Init("info")
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
cfg, err := config.Load("config.json")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to load configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if database configuration is provided
|
|
||||||
if cfg.Database.Type == "" {
|
|
||||||
log.Warn().Msg("No database configuration found, using SQLite default")
|
|
||||||
cfg.Database.Type = "sqlite"
|
|
||||||
cfg.Database.SQLitePath = "./data/whatshooked.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
log.Info().
|
|
||||||
Str("type", cfg.Database.Type).
|
|
||||||
Msg("Initializing database")
|
|
||||||
|
|
||||||
if err := storage.Initialize(&cfg.Database); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to initialize database")
|
|
||||||
}
|
|
||||||
defer storage.Close()
|
|
||||||
|
|
||||||
db := storage.GetDB()
|
|
||||||
|
|
||||||
// Create tables using BUN
|
|
||||||
log.Info().Msg("Creating database tables")
|
|
||||||
if err := storage.CreateTables(context.Background()); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to create tables")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed default data (creates admin user if not exists)
|
|
||||||
log.Info().Msg("Seeding default data")
|
|
||||||
if err := storage.SeedData(context.Background()); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to seed data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure API config is present
|
|
||||||
if !cfg.API.Enabled {
|
|
||||||
log.Warn().Msg("API server not enabled in config, enabling with defaults")
|
|
||||||
cfg.API.Enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create API server
|
|
||||||
log.Info().Msg("Creating API server with ResolveSpec")
|
|
||||||
server, err := api.NewServer(cfg, db)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to create API server")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.API.Host, cfg.API.Port)
|
|
||||||
log.Info().
|
|
||||||
Str("address", addr).
|
|
||||||
Msg("Starting API server")
|
|
||||||
log.Info().Msg("Default admin credentials: username=admin, password=admin123")
|
|
||||||
log.Info().Msg("⚠️ Please change the default password after first login!")
|
|
||||||
log.Info().Msg("")
|
|
||||||
log.Info().Msg("API Endpoints:")
|
|
||||||
log.Info().Msgf(" - POST %s/api/v1/auth/login - Login to get JWT token", addr)
|
|
||||||
log.Info().Msgf(" - POST %s/api/v1/auth/logout - Logout and invalidate token", addr)
|
|
||||||
log.Info().Msgf(" - GET %s/api/v1/users - List users (requires auth)", addr)
|
|
||||||
log.Info().Msgf(" - GET %s/api/v1/hooks - List hooks (requires auth)", addr)
|
|
||||||
log.Info().Msgf(" - GET %s/health - Health check", addr)
|
|
||||||
|
|
||||||
// Start the server (blocking call)
|
|
||||||
if err := server.Start(); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("API server error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -41,6 +47,16 @@ type Server struct {
|
|||||||
wh WhatsHookedInterface
|
wh WhatsHookedInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type systemStatsSampler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCPUJiffies uint64
|
||||||
|
lastCPUTimestamp time.Time
|
||||||
|
lastNetTotal uint64
|
||||||
|
lastNetTimestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeStatsSampler = &systemStatsSampler{}
|
||||||
|
|
||||||
// NewServer creates a new API server with ResolveSpec integration
|
// NewServer creates a new API server with ResolveSpec integration
|
||||||
func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) {
|
func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) {
|
||||||
// Create model registry and register models
|
// Create model registry and register models
|
||||||
@@ -85,6 +101,7 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
|
|||||||
|
|
||||||
// Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD)
|
// Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD)
|
||||||
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
|
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
|
||||||
|
apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET")
|
||||||
|
|
||||||
// Add custom routes (login, logout, etc.) on main router
|
// Add custom routes (login, logout, etc.) on main router
|
||||||
SetupCustomRoutes(router, secProvider, db)
|
SetupCustomRoutes(router, secProvider, db)
|
||||||
@@ -126,6 +143,179 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleSystemStats(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
var mem runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&mem)
|
||||||
|
|
||||||
|
cpuPercent := runtimeStatsSampler.sampleCPUPercent()
|
||||||
|
rxBytes, txBytes := readNetworkBytes()
|
||||||
|
netTotal := rxBytes + txBytes
|
||||||
|
netBytesPerSec := runtimeStatsSampler.sampleNetworkBytesPerSec(netTotal)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"go_memory_bytes": mem.Alloc,
|
||||||
|
"go_memory_mb": float64(mem.Alloc) / (1024.0 * 1024.0),
|
||||||
|
"go_sys_memory_bytes": mem.Sys,
|
||||||
|
"go_sys_memory_mb": float64(mem.Sys) / (1024.0 * 1024.0),
|
||||||
|
"go_goroutines": runtime.NumGoroutine(),
|
||||||
|
"go_cpu_percent": cpuPercent,
|
||||||
|
"network_rx_bytes": rxBytes,
|
||||||
|
"network_tx_bytes": txBytes,
|
||||||
|
"network_total_bytes": netTotal,
|
||||||
|
"network_bytes_per_sec": netBytesPerSec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProcessCPUJiffies() (uint64, bool) {
|
||||||
|
data, err := os.ReadFile("/proc/self/stat")
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(string(data))
|
||||||
|
if len(parts) < 15 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
utime, err := strconv.ParseUint(parts[13], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
stime, err := strconv.ParseUint(parts[14], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return utime + stime, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *systemStatsSampler) sampleCPUPercent() float64 {
|
||||||
|
const clockTicksPerSecond = 100.0 // Linux default USER_HZ
|
||||||
|
|
||||||
|
jiffies, ok := readProcessCPUJiffies()
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.lastCPUTimestamp.IsZero() || s.lastCPUJiffies == 0 {
|
||||||
|
s.lastCPUTimestamp = now
|
||||||
|
s.lastCPUJiffies = jiffies
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(s.lastCPUTimestamp).Seconds()
|
||||||
|
deltaJiffies := jiffies - s.lastCPUJiffies
|
||||||
|
|
||||||
|
s.lastCPUTimestamp = now
|
||||||
|
s.lastCPUJiffies = jiffies
|
||||||
|
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuSeconds := float64(deltaJiffies) / clockTicksPerSecond
|
||||||
|
cores := float64(runtime.NumCPU())
|
||||||
|
if cores < 1 {
|
||||||
|
cores = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
percent := (cpuSeconds / elapsed) * 100.0 / cores
|
||||||
|
if percent < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if percent > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return math.Round(percent*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNetworkBytes() (uint64, uint64) {
|
||||||
|
file, err := os.Open("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var rxBytes uint64
|
||||||
|
var txBytes uint64
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
// Skip headers.
|
||||||
|
if lineNo <= 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.TrimSpace(parts[0])
|
||||||
|
if iface == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(parts[1])
|
||||||
|
if len(fields) < 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rx, err := strconv.ParseUint(fields[0], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
rxBytes += rx
|
||||||
|
}
|
||||||
|
tx, err := strconv.ParseUint(fields[8], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
txBytes += tx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rxBytes, txBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *systemStatsSampler) sampleNetworkBytesPerSec(totalBytes uint64) float64 {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.lastNetTimestamp.IsZero() {
|
||||||
|
s.lastNetTimestamp = now
|
||||||
|
s.lastNetTotal = totalBytes
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(s.lastNetTimestamp).Seconds()
|
||||||
|
deltaBytes := totalBytes - s.lastNetTotal
|
||||||
|
|
||||||
|
s.lastNetTimestamp = now
|
||||||
|
s.lastNetTotal = totalBytes
|
||||||
|
|
||||||
|
if elapsed <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
bps := float64(deltaBytes) / elapsed
|
||||||
|
if bps < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return math.Round(bps*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
// Start starts the API server
|
// Start starts the API server
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
return s.serverMgr.ServeWithGracefulShutdown()
|
return s.serverMgr.ServeWithGracefulShutdown()
|
||||||
@@ -171,6 +361,7 @@ func SetupWhatsAppRoutes(router *mux.Router, wh WhatsHookedInterface, distFS fs.
|
|||||||
|
|
||||||
// Account management (with auth)
|
// Account management (with auth)
|
||||||
router.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
router.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
||||||
|
router.HandleFunc("/api/accounts/status", h.Auth(h.AccountStatuses)).Methods("GET")
|
||||||
router.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)).Methods("POST")
|
router.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)).Methods("POST")
|
||||||
router.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)).Methods("POST")
|
router.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)).Methods("POST")
|
||||||
router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST")
|
router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST")
|
||||||
|
|||||||
521
pkg/cache/message_cache.go
vendored
521
pkg/cache/message_cache.go
vendored
@@ -1,21 +1,34 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
storageDatabase = "database"
|
||||||
|
storageDisk = "disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CachedEvent represents an event stored in cache
|
// CachedEvent represents an event stored in cache
|
||||||
type CachedEvent struct {
|
type CachedEvent struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Event events.Event `json:"event"`
|
Event events.Event `json:"event"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
FromNumber string `json:"from_number,omitempty"`
|
||||||
|
ToNumber string `json:"to_number,omitempty"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Attempts int `json:"attempts"`
|
Attempts int `json:"attempts"`
|
||||||
@@ -28,6 +41,9 @@ type MessageCache struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
dataPath string
|
dataPath string
|
||||||
enabled bool
|
enabled bool
|
||||||
|
storage string
|
||||||
|
dbType string
|
||||||
|
db *bun.DB
|
||||||
maxAge time.Duration // Maximum age before events are purged
|
maxAge time.Duration // Maximum age before events are purged
|
||||||
maxEvents int // Maximum number of events to keep
|
maxEvents int // Maximum number of events to keep
|
||||||
}
|
}
|
||||||
@@ -35,17 +51,47 @@ type MessageCache struct {
|
|||||||
// Config holds cache configuration
|
// Config holds cache configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Storage string `json:"storage"`
|
||||||
DataPath string `json:"data_path"`
|
DataPath string `json:"data_path"`
|
||||||
|
DBType string `json:"db_type"`
|
||||||
|
DB *bun.DB `json:"-"`
|
||||||
MaxAge time.Duration `json:"max_age"` // Default: 7 days
|
MaxAge time.Duration `json:"max_age"` // Default: 7 days
|
||||||
MaxEvents int `json:"max_events"` // Default: 10000
|
MaxEvents int `json:"max_events"` // Default: 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheDBRow is the persistence representation for database-backed cache entries.
|
||||||
|
type cacheDBRow struct {
|
||||||
|
ID string `bun:"id"`
|
||||||
|
AccountID string `bun:"account_id"`
|
||||||
|
EventType string `bun:"event_type"`
|
||||||
|
EventData string `bun:"event_data"`
|
||||||
|
MessageID string `bun:"message_id"`
|
||||||
|
FromNumber string `bun:"from_number"`
|
||||||
|
ToNumber string `bun:"to_number"`
|
||||||
|
Reason string `bun:"reason"`
|
||||||
|
Attempts int `bun:"attempts"`
|
||||||
|
Timestamp time.Time `bun:"timestamp"`
|
||||||
|
LastAttempt *time.Time `bun:"last_attempt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName tells bun which table to use for cacheDBRow.
|
||||||
|
func (cacheDBRow) TableName() string {
|
||||||
|
return "message_cache"
|
||||||
|
}
|
||||||
|
|
||||||
// NewMessageCache creates a new message cache
|
// NewMessageCache creates a new message cache
|
||||||
func NewMessageCache(cfg Config) (*MessageCache, error) {
|
func NewMessageCache(cfg Config) (*MessageCache, error) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return &MessageCache{
|
return &MessageCache{enabled: false}, nil
|
||||||
enabled: false,
|
}
|
||||||
}, nil
|
|
||||||
|
if cfg.Storage == "" {
|
||||||
|
cfg.Storage = storageDatabase
|
||||||
|
}
|
||||||
|
cfg.Storage = strings.ToLower(cfg.Storage)
|
||||||
|
if cfg.Storage != storageDatabase && cfg.Storage != storageDisk {
|
||||||
|
logging.Warn("Unknown message cache storage backend, defaulting to disk", "storage", cfg.Storage)
|
||||||
|
cfg.Storage = storageDisk
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.DataPath == "" {
|
if cfg.DataPath == "" {
|
||||||
@@ -58,22 +104,25 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
|
|||||||
cfg.MaxEvents = 10000
|
cfg.MaxEvents = 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache directory
|
if cfg.Storage == storageDisk {
|
||||||
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
|
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &MessageCache{
|
cache := &MessageCache{
|
||||||
events: make(map[string]*CachedEvent),
|
events: make(map[string]*CachedEvent),
|
||||||
dataPath: cfg.DataPath,
|
dataPath: cfg.DataPath,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
storage: cfg.Storage,
|
||||||
|
dbType: strings.ToLower(cfg.DBType),
|
||||||
|
db: cfg.DB,
|
||||||
maxAge: cfg.MaxAge,
|
maxAge: cfg.MaxAge,
|
||||||
maxEvents: cfg.MaxEvents,
|
maxEvents: cfg.MaxEvents,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing cached events
|
if err := cache.loadPersistedEvents(); err != nil {
|
||||||
if err := cache.loadFromDisk(); err != nil {
|
return nil, err
|
||||||
logging.Warn("Failed to load cached events from disk", "error", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start cleanup goroutine
|
// Start cleanup goroutine
|
||||||
@@ -81,6 +130,7 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
|
|||||||
|
|
||||||
logging.Info("Message cache initialized",
|
logging.Info("Message cache initialized",
|
||||||
"enabled", cfg.Enabled,
|
"enabled", cfg.Enabled,
|
||||||
|
"storage", cache.storage,
|
||||||
"data_path", cfg.DataPath,
|
"data_path", cfg.DataPath,
|
||||||
"max_age", cfg.MaxAge,
|
"max_age", cfg.MaxAge,
|
||||||
"max_events", cfg.MaxEvents)
|
"max_events", cfg.MaxEvents)
|
||||||
@@ -88,6 +138,22 @@ func NewMessageCache(cfg Config) (*MessageCache, error) {
|
|||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigureDatabase attaches a database to the cache and loads persisted entries.
|
||||||
|
func (c *MessageCache) ConfigureDatabase(db *bun.DB) error {
|
||||||
|
if !c.enabled || c.storage != storageDatabase {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database handle is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.db = db
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.loadPersistedEvents()
|
||||||
|
}
|
||||||
|
|
||||||
// Store adds an event to the cache
|
// Store adds an event to the cache
|
||||||
func (c *MessageCache) Store(event events.Event, reason string) error {
|
func (c *MessageCache) Store(event events.Event, reason string) error {
|
||||||
if !c.enabled {
|
if !c.enabled {
|
||||||
@@ -109,6 +175,20 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
|
|||||||
cached := &CachedEvent{
|
cached := &CachedEvent{
|
||||||
ID: id,
|
ID: id,
|
||||||
Event: event,
|
Event: event,
|
||||||
|
AccountID: stringFromEventData(event.Data, "account_id", "accountID"),
|
||||||
|
FromNumber: stringFromEventData(
|
||||||
|
event.Data,
|
||||||
|
"from",
|
||||||
|
"from_number",
|
||||||
|
"fromNumber",
|
||||||
|
),
|
||||||
|
ToNumber: stringFromEventData(
|
||||||
|
event.Data,
|
||||||
|
"to",
|
||||||
|
"to_number",
|
||||||
|
"toNumber",
|
||||||
|
),
|
||||||
|
MessageID: stringFromEventData(event.Data, "message_id", "messageId"),
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
Attempts: 0,
|
Attempts: 0,
|
||||||
@@ -116,8 +196,8 @@ func (c *MessageCache) Store(event events.Event, reason string) error {
|
|||||||
|
|
||||||
c.events[id] = cached
|
c.events[id] = cached
|
||||||
|
|
||||||
// Save to disk asynchronously
|
// Persist asynchronously
|
||||||
go c.saveToDisk(cached)
|
go c.persistCachedEvent(cached)
|
||||||
|
|
||||||
logging.Debug("Event cached",
|
logging.Debug("Event cached",
|
||||||
"event_id", id,
|
"event_id", id,
|
||||||
@@ -177,6 +257,53 @@ func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEven
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPaged returns cached events filtered by event type and paged by limit/offset.
|
||||||
|
// Events are sorted by cache timestamp (newest first).
|
||||||
|
// A limit <= 0 returns all events from offset onward.
|
||||||
|
func (c *MessageCache) ListPaged(eventType events.EventType, limit, offset int) ([]*CachedEvent, int) {
|
||||||
|
if !c.enabled {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*CachedEvent, 0, len(c.events))
|
||||||
|
for _, cached := range c.events {
|
||||||
|
if eventType != "" && cached.Event.Type != eventType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
if filtered[i].Timestamp.Equal(filtered[j].Timestamp) {
|
||||||
|
return filtered[i].ID > filtered[j].ID
|
||||||
|
}
|
||||||
|
return filtered[i].Timestamp.After(filtered[j].Timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
if offset >= total {
|
||||||
|
return []*CachedEvent{}, total
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
return filtered[offset:], total
|
||||||
|
}
|
||||||
|
|
||||||
|
end := offset + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered[offset:end], total
|
||||||
|
}
|
||||||
|
|
||||||
// Remove deletes an event from the cache
|
// Remove deletes an event from the cache
|
||||||
func (c *MessageCache) Remove(id string) error {
|
func (c *MessageCache) Remove(id string) error {
|
||||||
if !c.enabled {
|
if !c.enabled {
|
||||||
@@ -192,8 +319,8 @@ func (c *MessageCache) Remove(id string) error {
|
|||||||
|
|
||||||
delete(c.events, id)
|
delete(c.events, id)
|
||||||
|
|
||||||
// Remove from disk
|
// Remove persisted record asynchronously
|
||||||
go c.removeFromDisk(id)
|
go c.removePersistedEvent(id)
|
||||||
|
|
||||||
logging.Debug("Event removed from cache", "event_id", id)
|
logging.Debug("Event removed from cache", "event_id", id)
|
||||||
|
|
||||||
@@ -218,8 +345,8 @@ func (c *MessageCache) IncrementAttempts(id string) error {
|
|||||||
cached.Attempts++
|
cached.Attempts++
|
||||||
cached.LastAttempt = &now
|
cached.LastAttempt = &now
|
||||||
|
|
||||||
// Update on disk
|
// Persist asynchronously
|
||||||
go c.saveToDisk(cached)
|
go c.persistCachedEvent(cached)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -235,8 +362,8 @@ func (c *MessageCache) Clear() error {
|
|||||||
|
|
||||||
c.events = make(map[string]*CachedEvent)
|
c.events = make(map[string]*CachedEvent)
|
||||||
|
|
||||||
// Clear disk cache
|
// Clear persisted cache asynchronously
|
||||||
go c.clearDisk()
|
go c.clearPersistedEvents()
|
||||||
|
|
||||||
logging.Info("Message cache cleared")
|
logging.Info("Message cache cleared")
|
||||||
|
|
||||||
@@ -274,7 +401,7 @@ func (c *MessageCache) removeOldest() {
|
|||||||
|
|
||||||
if oldestID != "" {
|
if oldestID != "" {
|
||||||
delete(c.events, oldestID)
|
delete(c.events, oldestID)
|
||||||
go c.removeFromDisk(oldestID)
|
go c.removePersistedEvent(oldestID)
|
||||||
logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID)
|
logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +436,7 @@ func (c *MessageCache) cleanup() {
|
|||||||
|
|
||||||
for _, id := range expiredIDs {
|
for _, id := range expiredIDs {
|
||||||
delete(c.events, id)
|
delete(c.events, id)
|
||||||
go c.removeFromDisk(id)
|
go c.removePersistedEvent(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(expiredIDs) > 0 {
|
if len(expiredIDs) > 0 {
|
||||||
@@ -317,6 +444,332 @@ func (c *MessageCache) cleanup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPersistedEvents loads events from the configured persistence backend.
|
||||||
|
func (c *MessageCache) loadPersistedEvents() error {
|
||||||
|
switch c.storage {
|
||||||
|
case storageDatabase:
|
||||||
|
if c.db == nil {
|
||||||
|
logging.Warn("Message cache database storage selected but database is not available yet")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.ensureDatabaseTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.loadFromDatabase(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case storageDisk:
|
||||||
|
if err := c.loadFromDisk(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) persistCachedEvent(cached *CachedEvent) {
|
||||||
|
switch c.storage {
|
||||||
|
case storageDatabase:
|
||||||
|
c.saveToDatabase(cached)
|
||||||
|
case storageDisk:
|
||||||
|
c.saveToDisk(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) removePersistedEvent(id string) {
|
||||||
|
switch c.storage {
|
||||||
|
case storageDatabase:
|
||||||
|
c.removeFromDatabase(id)
|
||||||
|
case storageDisk:
|
||||||
|
c.removeFromDisk(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) clearPersistedEvents() {
|
||||||
|
switch c.storage {
|
||||||
|
case storageDatabase:
|
||||||
|
c.clearDatabase()
|
||||||
|
case storageDisk:
|
||||||
|
c.clearDisk()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDatabaseTable creates/updates the message_cache table shape for cache events.
|
||||||
|
func (c *MessageCache) ensureDatabaseTable() error {
|
||||||
|
if c.db == nil {
|
||||||
|
return fmt.Errorf("database handle not set for message cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
switch c.dbType {
|
||||||
|
case "postgres", "postgresql":
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
|
account_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
event_data JSONB NOT NULL,
|
||||||
|
message_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
from_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
to_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
last_attempt TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS account_id VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_type VARCHAR(100) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_data JSONB NOT NULL DEFAULT '{}'::jsonb`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS message_id VARCHAR(255) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS from_number VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS to_number VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS last_attempt TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
|
||||||
|
}
|
||||||
|
for _, q := range queries {
|
||||||
|
if _, err := c.db.ExecContext(ctx, q); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure message_cache table (postgres): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL DEFAULT '',
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
event_data TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL DEFAULT '',
|
||||||
|
from_number TEXT NOT NULL DEFAULT '',
|
||||||
|
to_number TEXT NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
last_attempt DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
|
||||||
|
}
|
||||||
|
for _, q := range queries {
|
||||||
|
if _, err := c.db.ExecContext(ctx, q); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure message_cache table (sqlite): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("account_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("event_type", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("event_data", "TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("message_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("from_number", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("to_number", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("reason", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("attempts", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("timestamp", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("last_attempt", "DATETIME"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureSQLiteColumn("created_at", "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) ensureSQLiteColumn(name, definition string) error {
|
||||||
|
rows, err := c.db.QueryContext(context.Background(), `PRAGMA table_info(message_cache)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to inspect sqlite message_cache table: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
exists := false
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var colName string
|
||||||
|
var colType string
|
||||||
|
var notNull int
|
||||||
|
var defaultValue any
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &colName, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||||
|
return fmt.Errorf("failed to scan sqlite table info: %w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(colName, name) {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("failed reading sqlite table info: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("ALTER TABLE message_cache ADD COLUMN %s %s", name, definition)
|
||||||
|
if _, err := c.db.ExecContext(context.Background(), query); err != nil {
|
||||||
|
return fmt.Errorf("failed to add sqlite message_cache column %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) loadFromDatabase() error {
|
||||||
|
if c.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
rows := make([]cacheDBRow, 0)
|
||||||
|
if err := c.db.NewSelect().
|
||||||
|
Model(&rows).
|
||||||
|
Table("message_cache").
|
||||||
|
Column("id", "account_id", "event_type", "event_data", "message_id", "from_number", "to_number", "reason", "attempts", "timestamp", "last_attempt").
|
||||||
|
Scan(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to load cached events from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded := 0
|
||||||
|
now := time.Now()
|
||||||
|
expiredIDs := make([]string, 0)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
for _, row := range rows {
|
||||||
|
var evt events.Event
|
||||||
|
if err := json.Unmarshal([]byte(row.EventData), &evt); err != nil {
|
||||||
|
logging.Warn("Failed to unmarshal cached event row", "event_id", row.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if evt.Type == "" {
|
||||||
|
evt.Type = events.EventType(row.EventType)
|
||||||
|
}
|
||||||
|
if evt.Timestamp.IsZero() {
|
||||||
|
evt.Timestamp = row.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
cached := &CachedEvent{
|
||||||
|
ID: row.ID,
|
||||||
|
Event: evt,
|
||||||
|
AccountID: firstNonEmpty(row.AccountID, stringFromEventData(evt.Data, "account_id", "accountID")),
|
||||||
|
FromNumber: firstNonEmpty(row.FromNumber, stringFromEventData(evt.Data, "from", "from_number", "fromNumber")),
|
||||||
|
ToNumber: firstNonEmpty(row.ToNumber, stringFromEventData(evt.Data, "to", "to_number", "toNumber")),
|
||||||
|
MessageID: firstNonEmpty(row.MessageID, stringFromEventData(evt.Data, "message_id", "messageId")),
|
||||||
|
Timestamp: row.Timestamp,
|
||||||
|
Reason: row.Reason,
|
||||||
|
Attempts: row.Attempts,
|
||||||
|
LastAttempt: row.LastAttempt,
|
||||||
|
}
|
||||||
|
if now.Sub(cached.Timestamp) > c.maxAge {
|
||||||
|
expiredIDs = append(expiredIDs, row.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.events[cached.ID] = cached
|
||||||
|
loaded++
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range expiredIDs {
|
||||||
|
go c.removeFromDatabase(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded > 0 {
|
||||||
|
logging.Info("Loaded cached events from database", "count", loaded)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) saveToDatabase(cached *CachedEvent) {
|
||||||
|
if c.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventData, err := json.Marshal(cached.Event)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to marshal cached event", "event_id", cached.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row := cacheDBRow{
|
||||||
|
ID: cached.ID,
|
||||||
|
AccountID: cached.AccountID,
|
||||||
|
EventType: string(cached.Event.Type),
|
||||||
|
EventData: string(eventData),
|
||||||
|
MessageID: cached.MessageID,
|
||||||
|
FromNumber: cached.FromNumber,
|
||||||
|
ToNumber: cached.ToNumber,
|
||||||
|
Reason: cached.Reason,
|
||||||
|
Attempts: cached.Attempts,
|
||||||
|
Timestamp: cached.Timestamp,
|
||||||
|
LastAttempt: cached.LastAttempt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.NewInsert().
|
||||||
|
Model(&row).
|
||||||
|
Table("message_cache").
|
||||||
|
On("CONFLICT (id) DO UPDATE").
|
||||||
|
Set("account_id = EXCLUDED.account_id").
|
||||||
|
Set("event_type = EXCLUDED.event_type").
|
||||||
|
Set("event_data = EXCLUDED.event_data").
|
||||||
|
Set("message_id = EXCLUDED.message_id").
|
||||||
|
Set("from_number = EXCLUDED.from_number").
|
||||||
|
Set("to_number = EXCLUDED.to_number").
|
||||||
|
Set("reason = EXCLUDED.reason").
|
||||||
|
Set("attempts = EXCLUDED.attempts").
|
||||||
|
Set("timestamp = EXCLUDED.timestamp").
|
||||||
|
Set("last_attempt = EXCLUDED.last_attempt").
|
||||||
|
Exec(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to persist cached event to database", "event_id", cached.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) removeFromDatabase(id string) {
|
||||||
|
if c.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.db.NewDelete().
|
||||||
|
Table("message_cache").
|
||||||
|
Where("id = ?", id).
|
||||||
|
Exec(context.Background()); err != nil {
|
||||||
|
logging.Error("Failed to remove cached event from database", "event_id", id, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MessageCache) clearDatabase() {
|
||||||
|
if c.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.db.NewDelete().
|
||||||
|
Table("message_cache").
|
||||||
|
Where("1 = 1").
|
||||||
|
Exec(context.Background()); err != nil {
|
||||||
|
logging.Error("Failed to clear cached events from database", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// saveToDisk saves a cached event to disk
|
// saveToDisk saves a cached event to disk
|
||||||
func (c *MessageCache) saveToDisk(cached *CachedEvent) {
|
func (c *MessageCache) saveToDisk(cached *CachedEvent) {
|
||||||
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID))
|
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID))
|
||||||
@@ -392,3 +845,33 @@ func (c *MessageCache) clearDisk() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stringFromEventData(data map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := data[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if typed != "" {
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
asString := fmt.Sprintf("%v", typed)
|
||||||
|
if asString != "" && asString != "<nil>" {
|
||||||
|
return asString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ type WhatsAppConfig struct {
|
|||||||
type BusinessAPIConfig struct {
|
type BusinessAPIConfig struct {
|
||||||
PhoneNumberID string `json:"phone_number_id"`
|
PhoneNumberID string `json:"phone_number_id"`
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
WABAId string `json:"waba_id,omitempty"` // WhatsApp Business Account ID (resolved at connect time)
|
WABAId string `json:"waba_id,omitempty"` // WhatsApp Business Account ID (resolved at connect time)
|
||||||
BusinessAccountID string `json:"business_account_id,omitempty"` // Facebook Business Manager ID (optional)
|
BusinessAccountID string `json:"business_account_id,omitempty"` // Facebook Business Manager ID (optional)
|
||||||
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
|
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
|
||||||
WebhookPath string `json:"webhook_path,omitempty"`
|
WebhookPath string `json:"webhook_path,omitempty"`
|
||||||
@@ -131,6 +131,7 @@ type MQTTConfig struct {
|
|||||||
// MessageCacheConfig holds message cache configuration
|
// MessageCacheConfig holds message cache configuration
|
||||||
type MessageCacheConfig struct {
|
type MessageCacheConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Enable message caching
|
Enabled bool `json:"enabled"` // Enable message caching
|
||||||
|
Storage string `json:"storage,omitempty"` // Storage backend: "database" (default) or "disk"
|
||||||
DataPath string `json:"data_path,omitempty"` // Directory to store cached events
|
DataPath string `json:"data_path,omitempty"` // Directory to store cached events
|
||||||
MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7)
|
MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7)
|
||||||
MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000)
|
MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000)
|
||||||
@@ -207,6 +208,9 @@ func Load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set message cache defaults
|
// Set message cache defaults
|
||||||
|
if cfg.MessageCache.Storage == "" {
|
||||||
|
cfg.MessageCache.Storage = "database"
|
||||||
|
}
|
||||||
if cfg.MessageCache.DataPath == "" {
|
if cfg.MessageCache.DataPath == "" {
|
||||||
cfg.MessageCache.DataPath = "./data/message_cache"
|
cfg.MessageCache.DataPath = "./data/message_cache"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,123 @@ import (
|
|||||||
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type accountRuntimeStatus struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
QRAvailable bool `json:"qr_available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountConfigWithStatus struct {
|
||||||
|
config.WhatsAppConfig
|
||||||
|
Status string `json:"status"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
QRAvailable bool `json:"qr_available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) getAccountStatusMapFromDB() map[string]accountRuntimeStatus {
|
||||||
|
result := map[string]accountRuntimeStatus{}
|
||||||
|
|
||||||
|
db := storage.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRow struct {
|
||||||
|
ID string `bun:"id"`
|
||||||
|
AccountType string `bun:"account_type"`
|
||||||
|
Status string `bun:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]statusRow, 0)
|
||||||
|
err := db.NewSelect().
|
||||||
|
Table("whatsapp_account").
|
||||||
|
Column("id", "account_type", "status").
|
||||||
|
Scan(context.Background(), &rows)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn("Failed to load account statuses from database", "error", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
accountID := row.ID
|
||||||
|
status := row.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "disconnected"
|
||||||
|
}
|
||||||
|
result[accountID] = accountRuntimeStatus{
|
||||||
|
AccountID: accountID,
|
||||||
|
Type: row.AccountType,
|
||||||
|
Status: status,
|
||||||
|
Connected: status == "connected",
|
||||||
|
QRAvailable: status == "pairing",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Accounts returns the list of all configured WhatsApp accounts
|
// Accounts returns the list of all configured WhatsApp accounts
|
||||||
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
writeJSON(w, h.config.WhatsApp)
|
|
||||||
|
statusByAccountID := h.getAccountStatusMapFromDB()
|
||||||
|
accounts := make([]accountConfigWithStatus, 0, len(h.config.WhatsApp))
|
||||||
|
for _, account := range h.config.WhatsApp {
|
||||||
|
status := accountRuntimeStatus{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Type: account.Type,
|
||||||
|
Status: "disconnected",
|
||||||
|
}
|
||||||
|
if account.Disabled {
|
||||||
|
status.Status = "disconnected"
|
||||||
|
status.Connected = false
|
||||||
|
status.QRAvailable = false
|
||||||
|
} else if fromDB, exists := statusByAccountID[account.ID]; exists {
|
||||||
|
status = fromDB
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts = append(accounts, accountConfigWithStatus{
|
||||||
|
WhatsAppConfig: account,
|
||||||
|
Status: status.Status,
|
||||||
|
Connected: status.Connected,
|
||||||
|
QRAvailable: status.QRAvailable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountStatuses returns status values persisted in the database.
|
||||||
|
// GET /api/accounts/status
|
||||||
|
func (h *Handlers) AccountStatuses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusByAccountID := h.getAccountStatusMapFromDB()
|
||||||
|
statuses := make([]accountRuntimeStatus, 0, len(h.config.WhatsApp))
|
||||||
|
for _, account := range h.config.WhatsApp {
|
||||||
|
status := accountRuntimeStatus{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Type: account.Type,
|
||||||
|
Status: "disconnected",
|
||||||
|
}
|
||||||
|
if account.Disabled {
|
||||||
|
status.Status = "disconnected"
|
||||||
|
status.Connected = false
|
||||||
|
status.QRAvailable = false
|
||||||
|
} else if fromDB, exists := statusByAccountID[account.ID]; exists {
|
||||||
|
status = fromDB
|
||||||
|
}
|
||||||
|
statuses = append(statuses, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]interface{}{
|
||||||
|
"statuses": statuses,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAccount adds a new WhatsApp account to the system
|
// AddAccount adds a new WhatsApp account to the system
|
||||||
@@ -35,6 +148,13 @@ func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if db := storage.GetDB(); db != nil {
|
||||||
|
repo := storage.NewWhatsAppAccountRepository(db)
|
||||||
|
if err := repo.UpdateStatus(context.Background(), account.ID, "connecting"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to connecting", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
h.config.WhatsApp = append(h.config.WhatsApp, account)
|
h.config.WhatsApp = append(h.config.WhatsApp, account)
|
||||||
if h.configPath != "" {
|
if h.configPath != "" {
|
||||||
@@ -70,6 +190,13 @@ func (h *Handlers) RemoveAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Continue with removal even if disconnect fails
|
// Continue with removal even if disconnect fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if db := storage.GetDB(); db != nil {
|
||||||
|
repo := storage.NewWhatsAppAccountRepository(db)
|
||||||
|
if err := repo.UpdateStatus(context.Background(), req.ID, "disconnected"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to disconnected after removal", "account_id", req.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from config
|
// Remove from config
|
||||||
found := false
|
found := false
|
||||||
newAccounts := make([]config.WhatsAppConfig, 0)
|
newAccounts := make([]config.WhatsAppConfig, 0)
|
||||||
@@ -137,6 +264,13 @@ func (h *Handlers) DisableAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Mark as disabled
|
// Mark as disabled
|
||||||
h.config.WhatsApp[i].Disabled = true
|
h.config.WhatsApp[i].Disabled = true
|
||||||
logging.Info("Account disabled", "account_id", req.ID)
|
logging.Info("Account disabled", "account_id", req.ID)
|
||||||
|
|
||||||
|
if db := storage.GetDB(); db != nil {
|
||||||
|
repo := storage.NewWhatsAppAccountRepository(db)
|
||||||
|
if err := repo.UpdateStatus(context.Background(), req.ID, "disconnected"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to disconnected", "account_id", req.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +341,13 @@ func (h *Handlers) EnableAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if db := storage.GetDB(); db != nil {
|
||||||
|
repo := storage.NewWhatsAppAccountRepository(db)
|
||||||
|
if err := repo.UpdateStatus(context.Background(), req.ID, "connecting"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to connecting", "account_id", req.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logging.Info("Account enabled and connected", "account_id", req.ID)
|
logging.Info("Account enabled and connected", "account_id", req.ID)
|
||||||
|
|
||||||
// Save config
|
// Save config
|
||||||
@@ -277,6 +418,15 @@ func (h *Handlers) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := accountRepo.UpdateConfig(context.Background(), updates.ID, updates.PhoneNumber, cfgJSON, !updates.Disabled); err != nil {
|
if err := accountRepo.UpdateConfig(context.Background(), updates.ID, updates.PhoneNumber, cfgJSON, !updates.Disabled); err != nil {
|
||||||
logging.Warn("Failed to sync updated account config to database", "account_id", updates.ID, "error", err)
|
logging.Warn("Failed to sync updated account config to database", "account_id", updates.ID, "error", err)
|
||||||
}
|
}
|
||||||
|
if updates.Disabled {
|
||||||
|
if err := accountRepo.UpdateStatus(context.Background(), updates.ID, "disconnected"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to disconnected after update", "account_id", updates.ID, "error", err)
|
||||||
|
}
|
||||||
|
} else if oldConfig.Disabled {
|
||||||
|
if err := accountRepo.UpdateStatus(context.Background(), updates.ID, "connecting"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to connecting after update", "account_id", updates.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the account was enabled and settings changed, reconnect it
|
// If the account was enabled and settings changed, reconnect it
|
||||||
|
|||||||
@@ -25,17 +25,60 @@ func (h *Handlers) GetCachedEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Optional event_type filter
|
// Optional event_type filter
|
||||||
eventType := r.URL.Query().Get("event_type")
|
eventType := r.URL.Query().Get("event_type")
|
||||||
|
limit := 0
|
||||||
|
offset := 0
|
||||||
|
limitProvided := false
|
||||||
|
offsetProvided := false
|
||||||
|
|
||||||
var cachedEvents interface{}
|
limitParam := r.URL.Query().Get("limit")
|
||||||
if eventType != "" {
|
if limitParam != "" {
|
||||||
cachedEvents = cache.ListByEventType(events.EventType(eventType))
|
parsedLimit, err := strconv.Atoi(limitParam)
|
||||||
} else {
|
if err != nil {
|
||||||
cachedEvents = cache.List()
|
http.Error(w, "Invalid limit parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsedLimit <= 0 {
|
||||||
|
http.Error(w, "Limit must be greater than 0", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = parsedLimit
|
||||||
|
limitProvided = true
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetParam := r.URL.Query().Get("offset")
|
||||||
|
if offsetParam != "" {
|
||||||
|
parsedOffset, err := strconv.Atoi(offsetParam)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid offset parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsedOffset < 0 {
|
||||||
|
http.Error(w, "Offset must be greater than or equal to 0", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset = parsedOffset
|
||||||
|
offsetProvided = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If offset is provided without limit, use a sensible page size.
|
||||||
|
if offsetProvided && !limitProvided {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedEvents, filteredCount := cache.ListPaged(events.EventType(eventType), limit, offset)
|
||||||
|
if !limitProvided && !offsetProvided {
|
||||||
|
// Backward-compatible response values when pagination is not requested.
|
||||||
|
limit = len(cachedEvents)
|
||||||
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{
|
||||||
"cached_events": cachedEvents,
|
"cached_events": cachedEvents,
|
||||||
"count": cache.Count(),
|
"count": cache.Count(),
|
||||||
|
"filtered_count": filteredCount,
|
||||||
|
"returned_count": len(cachedEvents),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import (
|
|||||||
|
|
||||||
type ModelPublicMessageCache struct {
|
type ModelPublicMessageCache struct {
|
||||||
bun.BaseModel `bun:"table:public.message_cache,alias:message_cache"`
|
bun.BaseModel `bun:"table:public.message_cache,alias:message_cache"`
|
||||||
ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID
|
ID resolvespec_common.SqlString `bun:"id,type:varchar(128),pk," json:"id"`
|
||||||
AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(36),notnull," json:"account_id"`
|
AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(64),notnull," json:"account_id"`
|
||||||
ChatID resolvespec_common.SqlString `bun:"chat_id,type:varchar(255),notnull," json:"chat_id"`
|
EventType resolvespec_common.SqlString `bun:"event_type,type:varchar(100),notnull," json:"event_type"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"` // JSON encoded message content
|
EventData resolvespec_common.SqlString `bun:"event_data,type:jsonb,notnull," json:"event_data"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`
|
|
||||||
FromMe bool `bun:"from_me,type:boolean,notnull," json:"from_me"`
|
|
||||||
MessageID resolvespec_common.SqlString `bun:"message_id,type:varchar(255),notnull," json:"message_id"`
|
MessageID resolvespec_common.SqlString `bun:"message_id,type:varchar(255),notnull," json:"message_id"`
|
||||||
MessageType resolvespec_common.SqlString `bun:"message_type,type:varchar(50),notnull," json:"message_type"` // text
|
FromNumber resolvespec_common.SqlString `bun:"from_number,type:varchar(64),notnull," json:"from_number"`
|
||||||
Timestamp resolvespec_common.SqlTimeStamp `bun:"timestamp,type:timestamp,notnull," json:"timestamp"`
|
ToNumber resolvespec_common.SqlString `bun:"to_number,type:varchar(64),notnull," json:"to_number"`
|
||||||
|
Reason resolvespec_common.SqlString `bun:"reason,type:text,notnull," json:"reason"`
|
||||||
|
Attempts int `bun:"attempts,type:integer,default:0,notnull," json:"attempts"`
|
||||||
|
Timestamp resolvespec_common.SqlTimeStamp `bun:"timestamp,type:timestamp,default:now(),notnull," json:"timestamp"`
|
||||||
|
LastAttempt resolvespec_common.SqlTimeStamp `bun:"last_attempt,type:timestamp,nullzero," json:"last_attempt"`
|
||||||
|
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicMessageCache
|
// TableName returns the table name for ModelPublicMessageCache
|
||||||
|
|||||||
1
pkg/serverembed/dist/assets/SwaggerPage-DyOXnmua.css
vendored
Normal file
1
pkg/serverembed/dist/assets/SwaggerPage-DyOXnmua.css
vendored
Normal file
File diff suppressed because one or more lines are too long
257
pkg/serverembed/dist/assets/SwaggerPage-DzmFUDQ3.js
vendored
Normal file
257
pkg/serverembed/dist/assets/SwaggerPage-DzmFUDQ3.js
vendored
Normal file
File diff suppressed because one or more lines are too long
70
pkg/serverembed/dist/assets/index-BKXFy3Jy.js
vendored
Normal file
70
pkg/serverembed/dist/assets/index-BKXFy3Jy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
73
pkg/serverembed/dist/assets/index-_R1QOTag.js
vendored
File diff suppressed because one or more lines are too long
BIN
pkg/serverembed/dist/favicon.ico
vendored
Normal file
BIN
pkg/serverembed/dist/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
4
pkg/serverembed/dist/index.html
vendored
4
pkg/serverembed/dist/index.html
vendored
@@ -2,10 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>web</title>
|
||||||
<script type="module" crossorigin src="/ui/assets/index-_R1QOTag.js"></script>
|
<script type="module" crossorigin src="/ui/assets/index-BKXFy3Jy.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
|
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
pkg/serverembed/dist/logo.png
vendored
Normal file
BIN
pkg/serverembed/dist/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
4
pkg/serverembed/dist/swagger-icon.svg
vendored
Normal file
4
pkg/serverembed/dist/swagger-icon.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Swagger icon">
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#85EA2D"/>
|
||||||
|
<path d="M17 24c0-4.97 4.03-9 9-9h12v8H26a1 1 0 0 0 0 2h12c4.97 0 9 4.03 9 9s-4.03 9-9 9H26v6h-8V40h20a1 1 0 0 0 0-2H26c-4.97 0-9-4.03-9-9 0-2.2.79-4.22 2.1-5.8A8.94 8.94 0 0 0 17 24z" fill="#173647"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
29
pkg/serverembed/dist/swagger/index.html
vendored
29
pkg/serverembed/dist/swagger/index.html
vendored
@@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>WhatsHooked API</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
||||||
<style>
|
|
||||||
body { margin: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
||||||
<script>
|
|
||||||
SwaggerUIBundle({
|
|
||||||
url: "../api.json",
|
|
||||||
dom_id: "#swagger-ui",
|
|
||||||
presets: [
|
|
||||||
SwaggerUIBundle.presets.apis,
|
|
||||||
SwaggerUIBundle.SwaggerUIStandalonePreset,
|
|
||||||
],
|
|
||||||
layout: "BaseLayout",
|
|
||||||
deepLinking: true,
|
|
||||||
tryItOutEnabled: true,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -84,7 +84,6 @@ func CreateTables(ctx context.Context) error {
|
|||||||
(*models.ModelPublicWhatsappAccount)(nil),
|
(*models.ModelPublicWhatsappAccount)(nil),
|
||||||
(*models.ModelPublicEventLog)(nil),
|
(*models.ModelPublicEventLog)(nil),
|
||||||
(*models.ModelPublicSession)(nil),
|
(*models.ModelPublicSession)(nil),
|
||||||
(*models.ModelPublicMessageCache)(nil),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
@@ -94,6 +93,10 @@ func CreateTables(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureMessageCacheTable(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +156,16 @@ func createTablesSQLite(ctx context.Context) error {
|
|||||||
)`,
|
)`,
|
||||||
|
|
||||||
// WhatsApp Accounts table
|
// WhatsApp Accounts table
|
||||||
`CREATE TABLE IF NOT EXISTS whatsapp_accounts (
|
`CREATE TABLE IF NOT EXISTS whatsapp_account (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
user_id VARCHAR(36) NOT NULL,
|
user_id VARCHAR(36) NOT NULL,
|
||||||
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
phone_number VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(255),
|
||||||
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
|
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
|
||||||
business_api_config TEXT,
|
config TEXT,
|
||||||
active BOOLEAN NOT NULL DEFAULT 1,
|
active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
connected BOOLEAN NOT NULL DEFAULT 0,
|
status VARCHAR(50) NOT NULL DEFAULT 'disconnected',
|
||||||
|
session_path TEXT,
|
||||||
last_connected_at TIMESTAMP,
|
last_connected_at TIMESTAMP,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -181,7 +186,7 @@ func createTablesSQLite(ctx context.Context) error {
|
|||||||
status VARCHAR(50),
|
status VARCHAR(50),
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET NULL
|
FOREIGN KEY (account_id) REFERENCES whatsapp_account(id) ON DELETE SET NULL
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
// Sessions table
|
// Sessions table
|
||||||
@@ -198,18 +203,22 @@ func createTablesSQLite(ctx context.Context) error {
|
|||||||
|
|
||||||
// Message Cache table
|
// Message Cache table
|
||||||
`CREATE TABLE IF NOT EXISTS message_cache (
|
`CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
account_id VARCHAR(36),
|
account_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
event_type VARCHAR(100) NOT NULL,
|
event_type VARCHAR(100) NOT NULL,
|
||||||
event_data TEXT NOT NULL,
|
event_data TEXT NOT NULL,
|
||||||
message_id VARCHAR(255),
|
message_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
from_number VARCHAR(20),
|
from_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
to_number VARCHAR(20),
|
to_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
processed BOOLEAN NOT NULL DEFAULT 0,
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
processed_at TIMESTAMP,
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET NULL
|
last_attempt TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sql := range tables {
|
for _, sql := range tables {
|
||||||
@@ -218,6 +227,236 @@ func createTablesSQLite(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := migrateLegacyWhatsAppAccountsTable(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureWhatsAppAccountColumnsSQLite(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureMessageCacheTable(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateLegacyWhatsAppAccountsTable(ctx context.Context) error {
|
||||||
|
if DB == nil {
|
||||||
|
return fmt.Errorf("database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var legacyCount int
|
||||||
|
if err := DB.NewSelect().
|
||||||
|
Table("sqlite_master").
|
||||||
|
ColumnExpr("COUNT(1)").
|
||||||
|
Where("type = 'table' AND name = 'whatsapp_accounts'").
|
||||||
|
Scan(ctx, &legacyCount); err != nil {
|
||||||
|
return fmt.Errorf("failed to inspect legacy whatsapp_accounts table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentCount int
|
||||||
|
if err := DB.NewSelect().
|
||||||
|
Table("sqlite_master").
|
||||||
|
ColumnExpr("COUNT(1)").
|
||||||
|
Where("type = 'table' AND name = 'whatsapp_account'").
|
||||||
|
Scan(ctx, ¤tCount); err != nil {
|
||||||
|
return fmt.Errorf("failed to inspect whatsapp_account table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if legacyCount > 0 {
|
||||||
|
if currentCount == 0 {
|
||||||
|
if _, err := DB.ExecContext(ctx, `ALTER TABLE whatsapp_accounts RENAME TO whatsapp_account`); err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate table whatsapp_accounts -> whatsapp_account: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeSQL := `INSERT OR IGNORE INTO whatsapp_account
|
||||||
|
(id, user_id, phone_number, display_name, account_type, config, active, status, session_path, last_connected_at, created_at, updated_at, deleted_at)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
phone_number,
|
||||||
|
'' AS display_name,
|
||||||
|
COALESCE(account_type, 'whatsmeow') AS account_type,
|
||||||
|
COALESCE(business_api_config, '') AS config,
|
||||||
|
COALESCE(active, 1) AS active,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(active, 1) = 0 THEN 'disconnected'
|
||||||
|
WHEN COALESCE(connected, 0) = 1 THEN 'connected'
|
||||||
|
ELSE 'disconnected'
|
||||||
|
END AS status,
|
||||||
|
'' AS session_path,
|
||||||
|
last_connected_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at
|
||||||
|
FROM whatsapp_accounts`
|
||||||
|
if _, err := DB.ExecContext(ctx, mergeSQL); err != nil {
|
||||||
|
return fmt.Errorf("failed to merge legacy whatsapp_accounts data: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := DB.ExecContext(ctx, `DROP TABLE whatsapp_accounts`); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop legacy whatsapp_accounts table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureWhatsAppAccountColumnsSQLite(ctx context.Context) error {
|
||||||
|
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "display_name", "VARCHAR(255)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "config", "TEXT"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "status", "VARCHAR(50) NOT NULL DEFAULT 'disconnected'"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "session_path", "TEXT"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureSQLiteColumn(ctx, "whatsapp_account", "updated_at", "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill config/status from legacy columns if they still exist on this table.
|
||||||
|
if hasBusinessAPIConfig, err := sqliteColumnExists(ctx, "whatsapp_account", "business_api_config"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if hasBusinessAPIConfig {
|
||||||
|
if _, err := DB.ExecContext(ctx, `UPDATE whatsapp_account SET config = COALESCE(config, business_api_config, '')`); err != nil {
|
||||||
|
return fmt.Errorf("failed to backfill config from business_api_config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasConnected, err := sqliteColumnExists(ctx, "whatsapp_account", "connected"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if hasConnected {
|
||||||
|
if _, err := DB.ExecContext(ctx, `UPDATE whatsapp_account
|
||||||
|
SET status = CASE
|
||||||
|
WHEN COALESCE(active, 1) = 0 THEN 'disconnected'
|
||||||
|
WHEN COALESCE(connected, 0) = 1 THEN 'connected'
|
||||||
|
WHEN status = '' OR status IS NULL THEN 'disconnected'
|
||||||
|
ELSE status
|
||||||
|
END`); err != nil {
|
||||||
|
return fmt.Errorf("failed to backfill status from connected column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqliteColumnExists(ctx context.Context, table, column string) (bool, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to inspect sqlite table %s: %w", table, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var colName string
|
||||||
|
var colType string
|
||||||
|
var notNull int
|
||||||
|
var defaultValue any
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &colName, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to scan sqlite table info for %s: %w", table, err)
|
||||||
|
}
|
||||||
|
if colName == column {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return false, fmt.Errorf("failed reading sqlite table info for %s: %w", table, err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSQLiteColumn(ctx context.Context, table, name, definition string) error {
|
||||||
|
exists, err := sqliteColumnExists(ctx, table, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, name, definition)
|
||||||
|
if _, err := DB.ExecContext(ctx, query); err != nil {
|
||||||
|
return fmt.Errorf("failed to add sqlite column %s.%s: %w", table, name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMessageCacheTable(ctx context.Context) error {
|
||||||
|
if DB == nil {
|
||||||
|
return fmt.Errorf("database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbType == "postgres" || dbType == "postgresql" {
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
|
account_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
event_data JSONB NOT NULL,
|
||||||
|
message_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
from_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
to_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
last_attempt TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS account_id VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_type VARCHAR(100) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS event_data JSONB NOT NULL DEFAULT '{}'::jsonb`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS message_id VARCHAR(255) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS from_number VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS to_number VARCHAR(64) NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS last_attempt TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE message_cache ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache (timestamp DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache (event_type)`,
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := DB.ExecContext(ctx, query); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure postgres message_cache table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL DEFAULT '',
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
event_data TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL DEFAULT '',
|
||||||
|
from_number TEXT NOT NULL DEFAULT '',
|
||||||
|
to_number TEXT NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_attempt DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type)`,
|
||||||
|
}
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := DB.ExecContext(ctx, query); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure sqlite message_cache table: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
||||||
@@ -202,29 +203,63 @@ func (r *WhatsAppAccountRepository) GetByPhoneNumber(ctx context.Context, phoneN
|
|||||||
|
|
||||||
// UpdateConfig updates the config JSON column and phone number for a WhatsApp account
|
// UpdateConfig updates the config JSON column and phone number for a WhatsApp account
|
||||||
func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error {
|
func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error {
|
||||||
_, err := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
|
now := time.Now()
|
||||||
Set("config = ?", cfgJSON).
|
updated, err := r.updateAccountTable(ctx, id, map[string]any{
|
||||||
Set("phone_number = ?", phoneNumber).
|
"config": cfgJSON,
|
||||||
Set("active = ?", active).
|
"phone_number": phoneNumber,
|
||||||
Set("updated_at = ?", time.Now()).
|
"active": active,
|
||||||
Where("id = ?", id).
|
"updated_at": now,
|
||||||
Exec(ctx)
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no whatsapp account row found for id=%s", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateStatus updates the status of a WhatsApp account
|
// UpdateStatus updates the status of a WhatsApp account
|
||||||
func (r *WhatsAppAccountRepository) UpdateStatus(ctx context.Context, id string, status string) error {
|
func (r *WhatsAppAccountRepository) UpdateStatus(ctx context.Context, id string, status string) error {
|
||||||
query := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
|
now := time.Now()
|
||||||
Set("status = ?", status).
|
fields := map[string]any{
|
||||||
Where("id = ?", id)
|
"status": status,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
if status == "connected" {
|
if status == "connected" {
|
||||||
now := time.Now()
|
fields["last_connected_at"] = now
|
||||||
query = query.Set("last_connected_at = ?", now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := query.Exec(ctx)
|
updated, err := r.updateAccountTable(ctx, id, fields)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if updated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no whatsapp account row found for id=%s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WhatsAppAccountRepository) updateAccountTable(ctx context.Context, id string, fields map[string]any) (bool, error) {
|
||||||
|
query := r.db.NewUpdate().Table("whatsapp_account").Where("id = ?", id)
|
||||||
|
for column, value := range fields {
|
||||||
|
query = query.Set(column+" = ?", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := query.Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return rowsAffected > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionRepository provides session-specific operations
|
// SessionRepository provides session-specific operations
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
|
|
||||||
// Account management (with auth)
|
// Account management (with auth)
|
||||||
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
||||||
|
mux.HandleFunc("/api/accounts/status", h.Auth(h.AccountStatuses))
|
||||||
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
|
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
|
||||||
mux.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount))
|
mux.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount))
|
||||||
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
|
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
"go.mau.fi/whatsmeow/types"
|
"go.mau.fi/whatsmeow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +117,9 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
|
|||||||
// Initialize message cache
|
// Initialize message cache
|
||||||
cacheConfig := cache.Config{
|
cacheConfig := cache.Config{
|
||||||
Enabled: cfg.MessageCache.Enabled,
|
Enabled: cfg.MessageCache.Enabled,
|
||||||
|
Storage: cfg.MessageCache.Storage,
|
||||||
DataPath: cfg.MessageCache.DataPath,
|
DataPath: cfg.MessageCache.DataPath,
|
||||||
|
DBType: cfg.Database.Type,
|
||||||
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
|
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
|
||||||
MaxEvents: cfg.MessageCache.MaxEvents,
|
MaxEvents: cfg.MessageCache.MaxEvents,
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,10 @@ func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
|
|||||||
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
||||||
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
||||||
// Continue connecting to other accounts even if one fails
|
// Continue connecting to other accounts even if one fails
|
||||||
|
} else {
|
||||||
|
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "connecting"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to connecting", "account_id", waCfg.ID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -219,16 +226,30 @@ func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
|
|||||||
|
|
||||||
// connectFromConfig loads and connects WhatsApp accounts from config file (legacy)
|
// connectFromConfig loads and connects WhatsApp accounts from config file (legacy)
|
||||||
func (wh *WhatsHooked) connectFromConfig(ctx context.Context) error {
|
func (wh *WhatsHooked) connectFromConfig(ctx context.Context) error {
|
||||||
|
var accountRepo *storage.WhatsAppAccountRepository
|
||||||
|
if db := storage.GetDB(); db != nil {
|
||||||
|
accountRepo = storage.NewWhatsAppAccountRepository(db)
|
||||||
|
}
|
||||||
|
|
||||||
for _, waCfg := range wh.config.WhatsApp {
|
for _, waCfg := range wh.config.WhatsApp {
|
||||||
// Skip disabled accounts
|
// Skip disabled accounts
|
||||||
if waCfg.Disabled {
|
if waCfg.Disabled {
|
||||||
logging.Info("Skipping disabled account", "account_id", waCfg.ID)
|
logging.Info("Skipping disabled account", "account_id", waCfg.ID)
|
||||||
|
if accountRepo != nil {
|
||||||
|
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "disconnected"); err != nil {
|
||||||
|
logging.Warn("Failed to set disabled account status", "account_id", waCfg.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
||||||
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
||||||
// Continue connecting to other accounts even if one fails
|
// Continue connecting to other accounts even if one fails
|
||||||
|
} else if accountRepo != nil {
|
||||||
|
if err := accountRepo.UpdateStatus(ctx, waCfg.ID, "connecting"); err != nil {
|
||||||
|
logging.Warn("Failed to set account status to connecting", "account_id", waCfg.ID, "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -374,9 +395,6 @@ func (wh *WhatsHooked) syncConfigToDatabase(ctx context.Context) error {
|
|||||||
|
|
||||||
// --- Sync WhatsApp accounts ---
|
// --- Sync WhatsApp accounts ---
|
||||||
if len(wh.config.WhatsApp) > 0 {
|
if len(wh.config.WhatsApp) > 0 {
|
||||||
accountRepo := storage.NewWhatsAppAccountRepository(db)
|
|
||||||
_ = accountRepo // used via db directly for upsert
|
|
||||||
|
|
||||||
for _, wa := range wh.config.WhatsApp {
|
for _, wa := range wh.config.WhatsApp {
|
||||||
if wa.ID == "" {
|
if wa.ID == "" {
|
||||||
logging.Warn("Skipping config WhatsApp account with no ID", "phone", wa.PhoneNumber)
|
logging.Warn("Skipping config WhatsApp account with no ID", "phone", wa.PhoneNumber)
|
||||||
@@ -402,25 +420,27 @@ func (wh *WhatsHooked) syncConfigToDatabase(ctx context.Context) error {
|
|||||||
SessionPath: resolvespec_common.NewSqlString(wa.SessionPath),
|
SessionPath: resolvespec_common.NewSqlString(wa.SessionPath),
|
||||||
Config: resolvespec_common.NewSqlString(cfgJSON),
|
Config: resolvespec_common.NewSqlString(cfgJSON),
|
||||||
Active: !wa.Disabled,
|
Active: !wa.Disabled,
|
||||||
|
Status: resolvespec_common.NewSqlString("disconnected"),
|
||||||
UserID: resolvespec_common.NewSqlString(adminID),
|
UserID: resolvespec_common.NewSqlString(adminID),
|
||||||
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
||||||
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.NewInsert().
|
result, err := db.NewInsert().
|
||||||
Model(&row).
|
Model(&row).
|
||||||
On("CONFLICT (id) DO UPDATE").
|
// Config should only prime missing accounts, never mutate existing DB rows.
|
||||||
Set("account_type = EXCLUDED.account_type").
|
On("CONFLICT (id) DO NOTHING").
|
||||||
Set("phone_number = EXCLUDED.phone_number").
|
|
||||||
Set("session_path = EXCLUDED.session_path").
|
|
||||||
Set("config = EXCLUDED.config").
|
|
||||||
Set("active = EXCLUDED.active").
|
|
||||||
Set("updated_at = EXCLUDED.updated_at").
|
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to sync WhatsApp account from config", "account_id", wa.ID, "error", err)
|
logging.Error("Failed to sync WhatsApp account from config", "account_id", wa.ID, "error", err)
|
||||||
} else {
|
} else {
|
||||||
logging.Info("Synced WhatsApp account from config to database", "account_id", wa.ID, "phone", wa.PhoneNumber)
|
if result != nil {
|
||||||
|
if rows, rowsErr := result.RowsAffected(); rowsErr == nil && rows == 0 {
|
||||||
|
logging.Debug("Skipped existing WhatsApp account during config prime", "account_id", wa.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging.Info("Primed WhatsApp account from config into database", "account_id", wa.ID, "phone", wa.PhoneNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,6 +511,13 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind message cache to database storage once DB is initialized.
|
||||||
|
if wh.messageCache != nil {
|
||||||
|
if err := wh.messageCache.ConfigureDatabase(db); err != nil {
|
||||||
|
logging.Error("Failed to configure message cache database storage", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Seed initial data (creates admin user if not exists)
|
// Seed initial data (creates admin user if not exists)
|
||||||
logging.Info("Seeding initial data")
|
logging.Info("Seeding initial data")
|
||||||
if err := storage.SeedData(ctx); err != nil {
|
if err := storage.SeedData(ctx); err != nil {
|
||||||
@@ -500,6 +527,9 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
|
|||||||
// Mark database as ready for account/hook loading
|
// Mark database as ready for account/hook loading
|
||||||
wh.dbReady = true
|
wh.dbReady = true
|
||||||
|
|
||||||
|
// Keep whatsapp_account.status synchronized with runtime events.
|
||||||
|
wh.subscribeAccountStatusEvents(db)
|
||||||
|
|
||||||
// Persist hook events to the event_log table
|
// Persist hook events to the event_log table
|
||||||
eventLogRepo := storage.NewEventLogRepository(db)
|
eventLogRepo := storage.NewEventLogRepository(db)
|
||||||
wh.eventBus.Subscribe(events.EventHookFailed, func(event events.Event) {
|
wh.eventBus.Subscribe(events.EventHookFailed, func(event events.Event) {
|
||||||
@@ -542,6 +572,41 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (wh *WhatsHooked) subscribeAccountStatusEvents(db *bun.DB) {
|
||||||
|
accountRepo := storage.NewWhatsAppAccountRepository(db)
|
||||||
|
updateStatus := func(event events.Event, status string) {
|
||||||
|
accountID, _ := event.Data["account_id"].(string)
|
||||||
|
if accountID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := accountRepo.UpdateStatus(context.Background(), accountID, status); err != nil {
|
||||||
|
logging.Warn("Failed to sync account status from event", "account_id", accountID, "status", status, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppConnected, func(event events.Event) {
|
||||||
|
updateStatus(event, "connected")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppDisconnected, func(event events.Event) {
|
||||||
|
updateStatus(event, "disconnected")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppQRCode, func(event events.Event) {
|
||||||
|
updateStatus(event, "pairing")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppQRTimeout, func(event events.Event) {
|
||||||
|
updateStatus(event, "connecting")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppQRError, func(event events.Event) {
|
||||||
|
updateStatus(event, "disconnected")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppPairFailed, func(event events.Event) {
|
||||||
|
updateStatus(event, "disconnected")
|
||||||
|
})
|
||||||
|
wh.eventBus.Subscribe(events.EventWhatsAppPairSuccess, func(event events.Event) {
|
||||||
|
updateStatus(event, "connected")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StopAPIServer stops the ResolveSpec server
|
// StopAPIServer stops the ResolveSpec server
|
||||||
func (wh *WhatsHooked) StopAPIServer(ctx context.Context) error {
|
func (wh *WhatsHooked) StopAPIServer(ctx context.Context) error {
|
||||||
if wh.apiServer != nil {
|
if wh.apiServer != nil {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
DROP TABLE IF EXISTS public.message_cache CASCADE;
|
DROP TABLE IF EXISTS public.message_cache CASCADE;
|
||||||
DROP TABLE IF EXISTS public.sessions CASCADE;
|
DROP TABLE IF EXISTS public.sessions CASCADE;
|
||||||
DROP TABLE IF EXISTS public.event_logs CASCADE;
|
DROP TABLE IF EXISTS public.event_logs CASCADE;
|
||||||
DROP TABLE IF EXISTS public.whatsapp_accounts CASCADE;
|
DROP TABLE IF EXISTS public.whatsapp_account CASCADE;
|
||||||
DROP TABLE IF EXISTS public.hooks CASCADE;
|
DROP TABLE IF EXISTS public.hooks CASCADE;
|
||||||
DROP TABLE IF EXISTS public.api_keys CASCADE;
|
DROP TABLE IF EXISTS public.api_keys CASCADE;
|
||||||
DROP TABLE IF EXISTS public.users CASCADE;
|
DROP TABLE IF EXISTS public.users CASCADE;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS public.hooks (
|
|||||||
user_id varchar(36) NOT NULL
|
user_id varchar(36) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.whatsapp_accounts (
|
CREATE TABLE IF NOT EXISTS public.whatsapp_account (
|
||||||
account_type varchar(50) NOT NULL,
|
account_type varchar(50) NOT NULL,
|
||||||
active boolean NOT NULL DEFAULT true,
|
active boolean NOT NULL DEFAULT true,
|
||||||
config text,
|
config text,
|
||||||
@@ -94,15 +94,18 @@ CREATE TABLE IF NOT EXISTS public.sessions (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.message_cache (
|
CREATE TABLE IF NOT EXISTS public.message_cache (
|
||||||
account_id varchar(36) NOT NULL,
|
id varchar(128) NOT NULL,
|
||||||
chat_id varchar(255) NOT NULL,
|
account_id varchar(64) NOT NULL DEFAULT '',
|
||||||
content text NOT NULL,
|
event_type varchar(100) NOT NULL,
|
||||||
created_at timestamp NOT NULL DEFAULT now(),
|
event_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
from_me boolean NOT NULL,
|
message_id varchar(255) NOT NULL DEFAULT '',
|
||||||
id varchar(36) NOT NULL,
|
from_number varchar(64) NOT NULL DEFAULT '',
|
||||||
message_id varchar(255) NOT NULL,
|
to_number varchar(64) NOT NULL DEFAULT '',
|
||||||
message_type varchar(50) NOT NULL,
|
reason text NOT NULL DEFAULT '',
|
||||||
timestamp timestamp NOT NULL
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
timestamp timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_attempt timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Add missing columns for schema: public
|
-- Add missing columns for schema: public
|
||||||
@@ -592,10 +595,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'account_type'
|
AND column_name = 'account_type'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN account_type varchar(50) NOT NULL;
|
ALTER TABLE public.whatsapp_account ADD COLUMN account_type varchar(50) NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -605,10 +608,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'active'
|
AND column_name = 'active'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN active boolean NOT NULL DEFAULT true;
|
ALTER TABLE public.whatsapp_account ADD COLUMN active boolean NOT NULL DEFAULT true;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -618,10 +621,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'config'
|
AND column_name = 'config'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN config text;
|
ALTER TABLE public.whatsapp_account ADD COLUMN config text;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -631,10 +634,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'created_at'
|
AND column_name = 'created_at'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN created_at timestamp NOT NULL DEFAULT now();
|
ALTER TABLE public.whatsapp_account ADD COLUMN created_at timestamp NOT NULL DEFAULT now();
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -644,10 +647,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'deleted_at'
|
AND column_name = 'deleted_at'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN deleted_at timestamp;
|
ALTER TABLE public.whatsapp_account ADD COLUMN deleted_at timestamp;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -657,10 +660,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'display_name'
|
AND column_name = 'display_name'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN display_name varchar(255);
|
ALTER TABLE public.whatsapp_account ADD COLUMN display_name varchar(255);
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -670,10 +673,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'id'
|
AND column_name = 'id'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN id varchar(36) NOT NULL;
|
ALTER TABLE public.whatsapp_account ADD COLUMN id varchar(36) NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -683,10 +686,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'last_connected_at'
|
AND column_name = 'last_connected_at'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN last_connected_at timestamp;
|
ALTER TABLE public.whatsapp_account ADD COLUMN last_connected_at timestamp;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -696,10 +699,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'phone_number'
|
AND column_name = 'phone_number'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN phone_number varchar(50) NOT NULL;
|
ALTER TABLE public.whatsapp_account ADD COLUMN phone_number varchar(50) NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -709,10 +712,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'session_path'
|
AND column_name = 'session_path'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN session_path text;
|
ALTER TABLE public.whatsapp_account ADD COLUMN session_path text;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -722,10 +725,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'status'
|
AND column_name = 'status'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN status varchar(50) NOT NULL DEFAULT 'disconnected';
|
ALTER TABLE public.whatsapp_account ADD COLUMN status varchar(50) NOT NULL DEFAULT 'disconnected';
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -735,10 +738,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'updated_at'
|
AND column_name = 'updated_at'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN updated_at timestamp NOT NULL DEFAULT now();
|
ALTER TABLE public.whatsapp_account ADD COLUMN updated_at timestamp NOT NULL DEFAULT now();
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -748,10 +751,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND column_name = 'user_id'
|
AND column_name = 'user_id'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD COLUMN user_id varchar(36) NOT NULL;
|
ALTER TABLE public.whatsapp_account ADD COLUMN user_id varchar(36) NOT NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1016,71 +1019,6 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND column_name = 'account_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD COLUMN account_id varchar(36) NOT NULL;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND column_name = 'chat_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD COLUMN chat_id varchar(255) NOT NULL;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND column_name = 'content'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD COLUMN content text NOT NULL;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND column_name = 'created_at'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD COLUMN created_at timestamp NOT NULL DEFAULT now();
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND column_name = 'from_me'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD COLUMN from_me boolean NOT NULL;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -1089,7 +1027,46 @@ BEGIN
|
|||||||
AND table_name = 'message_cache'
|
AND table_name = 'message_cache'
|
||||||
AND column_name = 'id'
|
AND column_name = 'id'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.message_cache ADD COLUMN id varchar(36) NOT NULL;
|
ALTER TABLE public.message_cache ADD COLUMN id varchar(128) NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'account_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN account_id varchar(64) NOT NULL DEFAULT '';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'event_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN event_type varchar(100) NOT NULL DEFAULT '';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'event_data'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN event_data jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1102,7 +1079,7 @@ BEGIN
|
|||||||
AND table_name = 'message_cache'
|
AND table_name = 'message_cache'
|
||||||
AND column_name = 'message_id'
|
AND column_name = 'message_id'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.message_cache ADD COLUMN message_id varchar(255) NOT NULL;
|
ALTER TABLE public.message_cache ADD COLUMN message_id varchar(255) NOT NULL DEFAULT '';
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1113,9 +1090,48 @@ BEGIN
|
|||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'message_cache'
|
AND table_name = 'message_cache'
|
||||||
AND column_name = 'message_type'
|
AND column_name = 'from_number'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.message_cache ADD COLUMN message_type varchar(50) NOT NULL;
|
ALTER TABLE public.message_cache ADD COLUMN from_number varchar(64) NOT NULL DEFAULT '';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'to_number'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN to_number varchar(64) NOT NULL DEFAULT '';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'reason'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN reason text NOT NULL DEFAULT '';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'attempts'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN attempts integer NOT NULL DEFAULT 0;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1128,7 +1144,33 @@ BEGIN
|
|||||||
AND table_name = 'message_cache'
|
AND table_name = 'message_cache'
|
||||||
AND column_name = 'timestamp'
|
AND column_name = 'timestamp'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.message_cache ADD COLUMN timestamp timestamp NOT NULL;
|
ALTER TABLE public.message_cache ADD COLUMN timestamp timestamptz NOT NULL DEFAULT now();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'last_attempt'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN last_attempt timestamptz;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'message_cache'
|
||||||
|
AND column_name = 'created_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.message_cache ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1226,22 +1268,22 @@ BEGIN
|
|||||||
SELECT constraint_name INTO auto_pk_name
|
SELECT constraint_name INTO auto_pk_name
|
||||||
FROM information_schema.table_constraints
|
FROM information_schema.table_constraints
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND constraint_type = 'PRIMARY KEY'
|
AND constraint_type = 'PRIMARY KEY'
|
||||||
AND constraint_name IN ('whatsapp_accounts_pkey', 'public_whatsapp_accounts_pkey');
|
AND constraint_name IN ('whatsapp_account_pkey', 'public_whatsapp_account_pkey');
|
||||||
|
|
||||||
IF auto_pk_name IS NOT NULL THEN
|
IF auto_pk_name IS NOT NULL THEN
|
||||||
EXECUTE 'ALTER TABLE public.whatsapp_accounts DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
EXECUTE 'ALTER TABLE public.whatsapp_account DROP CONSTRAINT ' || quote_ident(auto_pk_name);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Add named primary key if it doesn't exist
|
-- Add named primary key if it doesn't exist
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND constraint_name = 'pk_public_whatsapp_accounts'
|
AND constraint_name = 'pk_public_whatsapp_account'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD CONSTRAINT pk_public_whatsapp_accounts PRIMARY KEY (id);
|
ALTER TABLE public.whatsapp_account ADD CONSTRAINT pk_public_whatsapp_account PRIMARY KEY (id);
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1346,11 +1388,11 @@ CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at
|
|||||||
CREATE INDEX IF NOT EXISTS idx_hooks_user_id
|
CREATE INDEX IF NOT EXISTS idx_hooks_user_id
|
||||||
ON public.hooks USING btree (user_id);
|
ON public.hooks USING btree (user_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at
|
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at
|
||||||
ON public.whatsapp_accounts USING btree (deleted_at);
|
ON public.whatsapp_account USING btree (deleted_at);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id
|
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id
|
||||||
ON public.whatsapp_accounts USING btree (user_id);
|
ON public.whatsapp_account USING btree (user_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_event_logs_created_at
|
CREATE INDEX IF NOT EXISTS idx_event_logs_created_at
|
||||||
ON public.event_logs USING btree (created_at);
|
ON public.event_logs USING btree (created_at);
|
||||||
@@ -1373,17 +1415,11 @@ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
||||||
ON public.sessions USING btree (user_id);
|
ON public.sessions USING btree (user_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_account_id
|
|
||||||
ON public.message_cache USING btree (account_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_chat_id
|
|
||||||
ON public.message_cache USING btree (chat_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_from_me
|
|
||||||
ON public.message_cache USING btree (from_me);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp
|
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp
|
||||||
ON public.message_cache USING btree (timestamp);
|
ON public.message_cache USING btree (timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_cache_event_type
|
||||||
|
ON public.message_cache USING btree (event_type);
|
||||||
|
|
||||||
-- Unique constraints for schema: public
|
-- Unique constraints for schema: public
|
||||||
DO $$
|
DO $$
|
||||||
@@ -1430,10 +1466,10 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND constraint_name = 'ukey_whatsapp_accounts_phone_number'
|
AND constraint_name = 'ukey_whatsapp_account_phone_number'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts ADD CONSTRAINT ukey_whatsapp_accounts_phone_number UNIQUE (phone_number);
|
ALTER TABLE public.whatsapp_account ADD CONSTRAINT ukey_whatsapp_account_phone_number UNIQUE (phone_number);
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
@@ -1451,19 +1487,6 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'message_cache'
|
|
||||||
AND constraint_name = 'ukey_message_cache_message_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.message_cache ADD CONSTRAINT ukey_message_cache_message_id UNIQUE (message_id);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Check constraints for schema: public
|
-- Check constraints for schema: public
|
||||||
-- Foreign keys for schema: public
|
-- Foreign keys for schema: public
|
||||||
DO $$
|
DO $$
|
||||||
@@ -1503,11 +1526,11 @@ BEGIN
|
|||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = 'whatsapp_accounts'
|
AND table_name = 'whatsapp_account'
|
||||||
AND constraint_name = 'fk_whatsapp_accounts_user_id'
|
AND constraint_name = 'fk_whatsapp_account_user_id'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE public.whatsapp_accounts
|
ALTER TABLE public.whatsapp_account
|
||||||
ADD CONSTRAINT fk_whatsapp_accounts_user_id
|
ADD CONSTRAINT fk_whatsapp_account_user_id
|
||||||
FOREIGN KEY (user_id)
|
FOREIGN KEY (user_id)
|
||||||
REFERENCES public.users (id)
|
REFERENCES public.users (id)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
@@ -1554,4 +1577,3 @@ $$;-- Set sequence values for schema: public
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ Table whatsapp_account {
|
|||||||
deleted_at timestamp [null]
|
deleted_at timestamp [null]
|
||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
(user_id) [name: 'idx_whatsapp_accounts_user_id']
|
(user_id) [name: 'idx_whatsapp_account_user_id']
|
||||||
(deleted_at) [name: 'idx_whatsapp_accounts_deleted_at']
|
(deleted_at) [name: 'idx_whatsapp_account_deleted_at']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,26 +123,27 @@ Table session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Table message_cache {
|
Table message_cache {
|
||||||
id varchar(36) [primary key, note: 'UUID']
|
id varchar(128) [primary key]
|
||||||
account_id varchar(36) [not null]
|
account_id varchar(64) [not null, default: '']
|
||||||
message_id varchar(255) [unique, not null]
|
event_type varchar(100) [not null]
|
||||||
chat_id varchar(255) [not null]
|
event_data text [not null, note: 'JSON encoded event payload']
|
||||||
from_me boolean [not null]
|
message_id varchar(255) [not null, default: '']
|
||||||
timestamp timestamp [not null]
|
from_number varchar(64) [not null, default: '']
|
||||||
message_type varchar(50) [not null, note: 'text, image, video, etc.']
|
to_number varchar(64) [not null, default: '']
|
||||||
content text [not null, note: 'JSON encoded message content']
|
reason text [not null, default: '']
|
||||||
|
attempts integer [not null, default: 0]
|
||||||
|
timestamp timestamp [not null, default: `now()`]
|
||||||
|
last_attempt timestamp [null]
|
||||||
created_at timestamp [not null, default: `now()`]
|
created_at timestamp [not null, default: `now()`]
|
||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
(account_id) [name: 'idx_message_cache_account_id']
|
|
||||||
(chat_id) [name: 'idx_message_cache_chat_id']
|
|
||||||
(from_me) [name: 'idx_message_cache_from_me']
|
|
||||||
(timestamp) [name: 'idx_message_cache_timestamp']
|
(timestamp) [name: 'idx_message_cache_timestamp']
|
||||||
|
(event_type) [name: 'idx_message_cache_event_type']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reference documentation
|
// Reference documentation
|
||||||
Ref: api_keys.user_id > users.id [delete: cascade]
|
Ref: api_keys.user_id > users.id [delete: cascade]
|
||||||
Ref: hooks.user_id > users.id [delete: cascade]
|
Ref: hooks.user_id > users.id [delete: cascade]
|
||||||
Ref: whatsapp_accounts.user_id > users.id [delete: cascade]
|
Ref: whatsapp_account.user_id > users.id [delete: cascade]
|
||||||
Ref: sessions.user_id > users.id [delete: cascade]
|
Ref: sessions.user_id > users.id [delete: cascade]
|
||||||
|
|||||||
@@ -62,10 +62,9 @@ CREATE INDEX IF NOT EXISTS idx_hooks_user_id ON hooks(user_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at ON hooks(deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_hooks_deleted_at ON hooks(deleted_at);
|
||||||
|
|
||||||
-- WhatsApp Accounts table
|
-- WhatsApp Accounts table
|
||||||
CREATE TABLE IF NOT EXISTS whatsapp_accounts (
|
CREATE TABLE IF NOT EXISTS whatsapp_account (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
user_id VARCHAR(36) NOT NULL,
|
user_id VARCHAR(36) NOT NULL,
|
||||||
account_id VARCHAR(100) UNIQUE,
|
|
||||||
phone_number VARCHAR(50) NOT NULL UNIQUE,
|
phone_number VARCHAR(50) NOT NULL UNIQUE,
|
||||||
display_name VARCHAR(255),
|
display_name VARCHAR(255),
|
||||||
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
|
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
|
||||||
@@ -80,9 +79,8 @@ CREATE TABLE IF NOT EXISTS whatsapp_accounts (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_user_id ON whatsapp_accounts(user_id);
|
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_user_id ON whatsapp_account(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_accounts_deleted_at ON whatsapp_accounts(deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_whatsapp_account_deleted_at ON whatsapp_account(deleted_at);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_whatsapp_accounts_account_id ON whatsapp_accounts(account_id);
|
|
||||||
|
|
||||||
-- Event Logs table
|
-- Event Logs table
|
||||||
CREATE TABLE IF NOT EXISTS event_logs (
|
CREATE TABLE IF NOT EXISTS event_logs (
|
||||||
@@ -125,18 +123,19 @@ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
|||||||
|
|
||||||
-- Message Cache table
|
-- Message Cache table
|
||||||
CREATE TABLE IF NOT EXISTS message_cache (
|
CREATE TABLE IF NOT EXISTS message_cache (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(128) PRIMARY KEY,
|
||||||
account_id VARCHAR(36) NOT NULL,
|
account_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
message_id VARCHAR(255) NOT NULL UNIQUE,
|
event_type VARCHAR(100) NOT NULL,
|
||||||
chat_id VARCHAR(255) NOT NULL,
|
event_data TEXT NOT NULL,
|
||||||
message_type VARCHAR(50) NOT NULL,
|
message_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
content TEXT NOT NULL,
|
from_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
from_me BOOLEAN NOT NULL,
|
to_number VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
timestamp TIMESTAMP NOT NULL,
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_attempt TIMESTAMP,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_account_id ON message_cache(account_id);
|
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_chat_id ON message_cache(chat_id);
|
CREATE INDEX IF NOT EXISTS idx_message_cache_event_type ON message_cache(event_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_from_me ON message_cache(from_me);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_cache_timestamp ON message_cache(timestamp);
|
|
||||||
|
|||||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.gocache/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>web</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -19,12 +19,13 @@
|
|||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@warkypublic/oranguru": "^0.0.49",
|
"@warkypublic/oranguru": "^0.0.49",
|
||||||
|
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
"swagger-ui-react": "^5.32.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -41,4 +42,4 @@
|
|||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1455
web/pnpm-lock.yaml
generated
1455
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
4
web/public/swagger-icon.svg
Normal file
4
web/public/swagger-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Swagger icon">
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#85EA2D"/>
|
||||||
|
<path d="M17 24c0-4.97 4.03-9 9-9h12v8H26a1 1 0 0 0 0 2h12c4.97 0 9 4.03 9 9s-4.03 9-9 9H26v6h-8V40h20a1 1 0 0 0 0-2H26c-4.97 0-9-4.03-9-9 0-2.2.79-4.22 2.1-5.8A8.94 8.94 0 0 0 17 24z" fill="#173647"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>WhatsHooked API</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
||||||
<style>
|
|
||||||
body { margin: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
||||||
<script>
|
|
||||||
SwaggerUIBundle({
|
|
||||||
url: "../api.json",
|
|
||||||
dom_id: "#swagger-ui",
|
|
||||||
presets: [
|
|
||||||
SwaggerUIBundle.presets.apis,
|
|
||||||
SwaggerUIBundle.SwaggerUIStandalonePreset,
|
|
||||||
],
|
|
||||||
layout: "BaseLayout",
|
|
||||||
deepLinking: true,
|
|
||||||
tryItOutEnabled: true,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import { Notifications } from '@mantine/notifications';
|
import { Notifications } from '@mantine/notifications';
|
||||||
@@ -11,11 +11,13 @@ import UsersPage from './pages/UsersPage';
|
|||||||
import HooksPage from './pages/HooksPage';
|
import HooksPage from './pages/HooksPage';
|
||||||
import AccountsPage from './pages/AccountsPage';
|
import AccountsPage from './pages/AccountsPage';
|
||||||
import EventLogsPage from './pages/EventLogsPage';
|
import EventLogsPage from './pages/EventLogsPage';
|
||||||
|
import MessageCachePage from './pages/MessageCachePage';
|
||||||
import SendMessagePage from './pages/SendMessagePage';
|
import SendMessagePage from './pages/SendMessagePage';
|
||||||
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
|
import WhatsAppBusinessPage from './pages/WhatsAppBusinessPage';
|
||||||
import TemplateManagementPage from './pages/TemplateManagementPage';
|
import TemplateManagementPage from './pages/TemplateManagementPage';
|
||||||
import CatalogManagementPage from './pages/CatalogManagementPage';
|
import CatalogManagementPage from './pages/CatalogManagementPage';
|
||||||
import FlowManagementPage from './pages/FlowManagementPage';
|
import FlowManagementPage from './pages/FlowManagementPage';
|
||||||
|
const SwaggerPage = lazy(() => import('./pages/SwaggerPage'));
|
||||||
|
|
||||||
// Import Mantine styles
|
// Import Mantine styles
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
@@ -55,6 +57,12 @@ function App() {
|
|||||||
<Route path="flows" element={<FlowManagementPage />} />
|
<Route path="flows" element={<FlowManagementPage />} />
|
||||||
<Route path="send-message" element={<SendMessagePage />} />
|
<Route path="send-message" element={<SendMessagePage />} />
|
||||||
<Route path="event-logs" element={<EventLogsPage />} />
|
<Route path="event-logs" element={<EventLogsPage />} />
|
||||||
|
<Route path="message-cache" element={<MessageCachePage />} />
|
||||||
|
<Route path="sw" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SwaggerPage />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Avatar,
|
Avatar,
|
||||||
Stack,
|
Stack,
|
||||||
Badge,
|
Image,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
IconCategory,
|
IconCategory,
|
||||||
IconArrowsShuffle,
|
IconArrowsShuffle,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
|
IconDatabase,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
@@ -40,6 +41,14 @@ export default function DashboardLayout() {
|
|||||||
const isActive = (path: string) => location.pathname === path;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
const isAnyActive = (paths: string[]) =>
|
const isAnyActive = (paths: string[]) =>
|
||||||
paths.some((path) => location.pathname === path);
|
paths.some((path) => location.pathname === path);
|
||||||
|
const displayName =
|
||||||
|
user?.username?.trim() ||
|
||||||
|
user?.full_name?.trim() ||
|
||||||
|
user?.email?.trim() ||
|
||||||
|
"User";
|
||||||
|
const displayInitial = displayName[0]?.toUpperCase() || "U";
|
||||||
|
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
|
||||||
|
const swaggerIconSrc = `${import.meta.env.BASE_URL}swagger-icon.svg`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -60,19 +69,17 @@ export default function DashboardLayout() {
|
|||||||
hiddenFrom="sm"
|
hiddenFrom="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<Image src={logoSrc} alt="WhatsHooked logo" w={24} h={24} fit="contain" />
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700}>
|
||||||
WhatsHooked
|
WhatsHooked
|
||||||
</Text>
|
</Text>
|
||||||
<Badge color="blue" variant="light">
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{user?.username || "User"}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Avatar color="blue" radius="xl" size="sm">
|
<Avatar color="blue" radius="xl" size="sm">
|
||||||
{user?.username?.[0]?.toUpperCase() || "U"}
|
{displayInitial}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -222,6 +229,32 @@ export default function DashboardLayout() {
|
|||||||
if (opened) toggle();
|
if (opened) toggle();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
href="/message-cache"
|
||||||
|
label="Message Cache"
|
||||||
|
leftSection={
|
||||||
|
<IconDatabase size={20} stroke={1.5} color="indigo" />
|
||||||
|
}
|
||||||
|
active={isActive("/message-cache")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/message-cache");
|
||||||
|
if (opened) toggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
href="/sw"
|
||||||
|
label="Swagger"
|
||||||
|
leftSection={
|
||||||
|
<Image src={swaggerIconSrc} alt="Swagger" w={18} h={18} fit="contain" />
|
||||||
|
}
|
||||||
|
active={isActive("/sw")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate("/sw");
|
||||||
|
if (opened) toggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
|
||||||
@@ -230,7 +263,7 @@ export default function DashboardLayout() {
|
|||||||
<Group justify="space-between" px="sm">
|
<Group justify="space-between" px="sm">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{user?.username || "User"}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{user?.role || "user"}
|
{user?.role || "user"}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import type {
|
|||||||
APIKey,
|
APIKey,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
MessageCacheListResponse,
|
||||||
|
MessageCacheStats,
|
||||||
|
SystemStats,
|
||||||
|
WhatsAppAccountRuntimeStatus,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
function getApiBaseUrl(): string {
|
function getApiBaseUrl(): string {
|
||||||
@@ -26,6 +30,47 @@ function getApiBaseUrl(): string {
|
|||||||
|
|
||||||
const API_BASE_URL = getApiBaseUrl();
|
const API_BASE_URL = getApiBaseUrl();
|
||||||
|
|
||||||
|
function normalizeUser(raw: unknown): User | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const value = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const resolvedUsername =
|
||||||
|
(typeof value.username === "string" && value.username) ||
|
||||||
|
(typeof value.user_name === "string" && value.user_name) ||
|
||||||
|
(typeof value.full_name === "string" && value.full_name) ||
|
||||||
|
(typeof value.email === "string" && value.email.split("@")[0]) ||
|
||||||
|
"User";
|
||||||
|
|
||||||
|
const resolvedRole =
|
||||||
|
(typeof value.role === "string" && value.role) ||
|
||||||
|
(Array.isArray(value.roles) && typeof value.roles[0] === "string" && value.roles[0]) ||
|
||||||
|
"user";
|
||||||
|
|
||||||
|
const claims =
|
||||||
|
value.claims && typeof value.claims === "object"
|
||||||
|
? (value.claims as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resolvedID =
|
||||||
|
(typeof value.id === "string" && value.id) ||
|
||||||
|
(typeof value.user_id === "string" && value.user_id) ||
|
||||||
|
(claims && typeof claims.user_id === "string" && claims.user_id) ||
|
||||||
|
"0";
|
||||||
|
|
||||||
|
const normalized: User = {
|
||||||
|
id: resolvedID,
|
||||||
|
username: resolvedUsername,
|
||||||
|
email: typeof value.email === "string" ? value.email : "",
|
||||||
|
full_name: typeof value.full_name === "string" ? value.full_name : undefined,
|
||||||
|
role: resolvedRole === "admin" ? "admin" : "user",
|
||||||
|
active: typeof value.active === "boolean" ? value.active : true,
|
||||||
|
created_at: typeof value.created_at === "string" ? value.created_at : "",
|
||||||
|
updated_at: typeof value.updated_at === "string" ? value.updated_at : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private client: AxiosInstance;
|
private client: AxiosInstance;
|
||||||
|
|
||||||
@@ -82,7 +127,11 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
this.setToken(data.token);
|
this.setToken(data.token);
|
||||||
localStorage.setItem("user", JSON.stringify(data.user));
|
const normalizedUser = normalizeUser(data.user);
|
||||||
|
if (normalizedUser) {
|
||||||
|
localStorage.setItem("user", JSON.stringify(normalizedUser));
|
||||||
|
data.user = normalizedUser;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -97,7 +146,12 @@ class ApiClient {
|
|||||||
|
|
||||||
getCurrentUser(): User | null {
|
getCurrentUser(): User | null {
|
||||||
const userStr = localStorage.getItem("user");
|
const userStr = localStorage.getItem("user");
|
||||||
return userStr ? JSON.parse(userStr) : null;
|
if (!userStr) return null;
|
||||||
|
try {
|
||||||
|
return normalizeUser(JSON.parse(userStr));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
@@ -213,6 +267,13 @@ class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> {
|
||||||
|
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>(
|
||||||
|
"/api/accounts/status",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async addAccountConfig(
|
async addAccountConfig(
|
||||||
account: WhatsAppAccountConfig,
|
account: WhatsAppAccountConfig,
|
||||||
): Promise<{ status: string; account_id: string }> {
|
): Promise<{ status: string; account_id: string }> {
|
||||||
@@ -377,6 +438,73 @@ class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message cache API
|
||||||
|
async getMessageCacheEvents(params?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
eventType?: string;
|
||||||
|
}): Promise<MessageCacheListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.limit) searchParams.set("limit", String(params.limit));
|
||||||
|
if (params?.offset !== undefined) searchParams.set("offset", String(params.offset));
|
||||||
|
if (params?.eventType) searchParams.set("event_type", params.eventType);
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
const url = query ? `/api/cache?${query}` : "/api/cache";
|
||||||
|
const { data } = await this.client.get<MessageCacheListResponse>(url);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageCacheStats(): Promise<MessageCacheStats> {
|
||||||
|
const { data } = await this.client.get<MessageCacheStats>("/api/cache/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async replayCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
|
||||||
|
const { data } = await this.client.post<{ success: boolean; event_id: string; message: string }>(
|
||||||
|
`/api/cache/event/replay?id=${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
|
||||||
|
const { data } = await this.client.delete<{ success: boolean; event_id: string; message: string }>(
|
||||||
|
`/api/cache/event/delete?id=${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async replayAllCachedEvents(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
replayed: number;
|
||||||
|
delivered: number;
|
||||||
|
failed: number;
|
||||||
|
remaining_cached: number;
|
||||||
|
}> {
|
||||||
|
const { data } = await this.client.post<{
|
||||||
|
success: boolean;
|
||||||
|
replayed: number;
|
||||||
|
delivered: number;
|
||||||
|
failed: number;
|
||||||
|
remaining_cached: number;
|
||||||
|
}>("/api/cache/replay");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearMessageCache(): Promise<{ success: boolean; cleared: number; message: string }> {
|
||||||
|
const { data } = await this.client.delete<{ success: boolean; cleared: number; message: string }>(
|
||||||
|
"/api/cache/clear?confirm=true",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQRCode(accountId: string): Promise<Blob> {
|
||||||
|
const { data } = await this.client.get<Blob>(`/api/qr/${encodeURIComponent(accountId)}`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// API Keys API
|
// API Keys API
|
||||||
async getAPIKeys(): Promise<APIKey[]> {
|
async getAPIKeys(): Promise<APIKey[]> {
|
||||||
const { data } = await this.client.get<APIKey[]>("/api/v1/api_keys");
|
const { data } = await this.client.get<APIKey[]>("/api/v1/api_keys");
|
||||||
@@ -397,6 +525,11 @@ class ApiClient {
|
|||||||
const { data } = await this.client.get<{ status: string }>("/health");
|
const { data } = await this.client.get<{ status: string }>("/health");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSystemStats(): Promise<SystemStats> {
|
||||||
|
const { data } = await this.client.get<SystemStats>("/api/v1/system/stats");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -10,74 +10,127 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
TextInput,
|
||||||
Select,
|
Select,
|
||||||
Textarea,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
Alert,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
ActionIcon
|
ActionIcon,
|
||||||
|
Code,
|
||||||
|
Anchor
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
|
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp, IconQrcode } from '@tabler/icons-react';
|
||||||
import { apiClient } from '../lib/api';
|
import { apiClient } from '../lib/api';
|
||||||
import type { WhatsAppAccount, WhatsAppAccountConfig } from '../types';
|
import type { BusinessAPIConfig, WhatsAppAccount, WhatsAppAccountConfig } from '../types';
|
||||||
|
|
||||||
function buildSessionPath(accountId: string) {
|
function buildSessionPath(accountId: string) {
|
||||||
return `./sessions/${accountId}`;
|
return `./sessions/${accountId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPrettyJSON(value: unknown) {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
|
function sortAccountsAlphabetically(accounts: WhatsAppAccount[]): WhatsAppAccount[] {
|
||||||
return [...accounts].sort((a, b) =>
|
return [...accounts].sort((a, b) =>
|
||||||
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
|
(a.account_id || a.id).localeCompare((b.account_id || b.id), undefined, { sensitivity: 'base' }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeAccounts(
|
function mapConfigToAccount(configuredAccount: WhatsAppAccountConfig): WhatsAppAccount {
|
||||||
configuredAccounts: WhatsAppAccountConfig[],
|
return {
|
||||||
databaseAccounts: WhatsAppAccount[],
|
id: configuredAccount.id,
|
||||||
): WhatsAppAccount[] {
|
account_id: configuredAccount.id,
|
||||||
const databaseAccountsById = new Map(
|
user_id: '',
|
||||||
databaseAccounts.map((account) => [account.id, account]),
|
phone_number: configuredAccount.phone_number || '',
|
||||||
);
|
display_name: '',
|
||||||
|
account_type: configuredAccount.type || 'whatsmeow',
|
||||||
|
status: configuredAccount.status || 'disconnected',
|
||||||
|
show_qr: configuredAccount.show_qr,
|
||||||
|
business_api: configuredAccount.business_api,
|
||||||
|
config: configuredAccount.business_api ? JSON.stringify(configuredAccount.business_api, null, 2) : '',
|
||||||
|
session_path: configuredAccount.session_path || buildSessionPath(configuredAccount.id),
|
||||||
|
last_connected_at: undefined,
|
||||||
|
active: !configuredAccount.disabled,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const mergedAccounts = configuredAccounts.map((configuredAccount) => {
|
type BusinessAPIFormData = {
|
||||||
const databaseAccount = databaseAccountsById.get(configuredAccount.id);
|
phone_number_id: string;
|
||||||
|
access_token: string;
|
||||||
|
waba_id: string;
|
||||||
|
business_account_id: string;
|
||||||
|
api_version: string;
|
||||||
|
webhook_path: string;
|
||||||
|
verify_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
function emptyBusinessAPIFormData(): BusinessAPIFormData {
|
||||||
...databaseAccount,
|
return {
|
||||||
id: configuredAccount.id,
|
phone_number_id: '',
|
||||||
account_id: configuredAccount.id,
|
access_token: '',
|
||||||
user_id: databaseAccount?.user_id || '',
|
waba_id: '',
|
||||||
phone_number: configuredAccount.phone_number || databaseAccount?.phone_number || '',
|
business_account_id: '',
|
||||||
display_name: databaseAccount?.display_name || '',
|
api_version: 'v21.0',
|
||||||
account_type: configuredAccount.type || databaseAccount?.account_type || 'whatsmeow',
|
webhook_path: '',
|
||||||
status: databaseAccount?.status || 'disconnected',
|
verify_token: '',
|
||||||
config: configuredAccount.business_api
|
};
|
||||||
? toPrettyJSON(configuredAccount.business_api)
|
}
|
||||||
: (databaseAccount?.config || ''),
|
|
||||||
session_path: configuredAccount.session_path || databaseAccount?.session_path || buildSessionPath(configuredAccount.id),
|
|
||||||
last_connected_at: databaseAccount?.last_connected_at,
|
|
||||||
active: !configuredAccount.disabled,
|
|
||||||
created_at: databaseAccount?.created_at || '',
|
|
||||||
updated_at: databaseAccount?.updated_at || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const configuredIds = new Set(configuredAccounts.map((account) => account.id));
|
function toBusinessAPIFormData(config?: BusinessAPIConfig): BusinessAPIFormData {
|
||||||
const orphanedDatabaseAccounts = databaseAccounts
|
return {
|
||||||
.filter((account) => !configuredIds.has(account.id))
|
phone_number_id: typeof config?.phone_number_id === 'string' ? config.phone_number_id : '',
|
||||||
.map((account) => ({
|
access_token: typeof config?.access_token === 'string' ? config.access_token : '',
|
||||||
...account,
|
waba_id: typeof config?.waba_id === 'string' ? config.waba_id : '',
|
||||||
account_id: account.account_id || account.id,
|
business_account_id: typeof config?.business_account_id === 'string' ? config.business_account_id : '',
|
||||||
}));
|
api_version: typeof config?.api_version === 'string' && config.api_version ? config.api_version : 'v21.0',
|
||||||
|
webhook_path: typeof config?.webhook_path === 'string' ? config.webhook_path : '',
|
||||||
|
verify_token: typeof config?.verify_token === 'string' ? config.verify_token : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return sortAccountsAlphabetically([...mergedAccounts, ...orphanedDatabaseAccounts]);
|
function fromBusinessAPIFormData(form: BusinessAPIFormData): BusinessAPIConfig {
|
||||||
|
const payload: BusinessAPIConfig = {};
|
||||||
|
|
||||||
|
const phoneNumberID = form.phone_number_id.trim();
|
||||||
|
const accessToken = form.access_token.trim();
|
||||||
|
const wabaID = form.waba_id.trim();
|
||||||
|
const businessAccountID = form.business_account_id.trim();
|
||||||
|
const apiVersion = form.api_version.trim();
|
||||||
|
const webhookPath = form.webhook_path.trim();
|
||||||
|
const verifyToken = form.verify_token.trim();
|
||||||
|
|
||||||
|
if (phoneNumberID) payload.phone_number_id = phoneNumberID;
|
||||||
|
if (accessToken) payload.access_token = accessToken;
|
||||||
|
if (wabaID) payload.waba_id = wabaID;
|
||||||
|
if (businessAccountID) payload.business_account_id = businessAccountID;
|
||||||
|
if (apiVersion) payload.api_version = apiVersion;
|
||||||
|
if (webhookPath) payload.webhook_path = webhookPath;
|
||||||
|
if (verifyToken) payload.verify_token = verifyToken;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyBusinessAPIConfig(configJSON?: string): BusinessAPIConfig | undefined {
|
||||||
|
if (!configJSON) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configJSON);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed as BusinessAPIConfig;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionStatus(
|
||||||
|
account: WhatsAppAccount,
|
||||||
|
): string {
|
||||||
|
return account.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountsPage() {
|
export default function AccountsPage() {
|
||||||
@@ -85,58 +138,72 @@ export default function AccountsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [qrModalOpened, { open: openQRModal, close: closeQRModal }] = useDisclosure(false);
|
||||||
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
|
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
|
||||||
|
const [selectedQRAccount, setSelectedQRAccount] = useState<WhatsAppAccount | null>(null);
|
||||||
|
const [qrImageError, setQRImageError] = useState<string | null>(null);
|
||||||
|
const [qrRefreshTick, setQRRefreshTick] = useState(0);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
account_id: '',
|
account_id: '',
|
||||||
phone_number: '',
|
phone_number: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
|
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
|
||||||
config: '',
|
business_api: emptyBusinessAPIFormData(),
|
||||||
active: true
|
active: true
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const loadAccounts = useCallback(async (showLoader = true) => {
|
||||||
loadAccounts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (showLoader) {
|
||||||
const [configuredAccounts, databaseAccounts] = await Promise.all([
|
setLoading(true);
|
||||||
apiClient.getAccountConfigs(),
|
}
|
||||||
apiClient.getAccounts(),
|
const configuredAccounts = await apiClient.getAccountConfigs();
|
||||||
]);
|
setAccounts(sortAccountsAlphabetically((configuredAccounts || []).map(mapConfigToAccount)));
|
||||||
setAccounts(mergeAccounts(configuredAccounts || [], databaseAccounts || []));
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load accounts');
|
setError('Failed to load accounts');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoader) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAccounts();
|
||||||
|
}, [loadAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void loadAccounts(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadAccounts]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
account_id: '',
|
account_id: '',
|
||||||
phone_number: '',
|
phone_number: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
account_type: 'whatsmeow',
|
account_type: 'whatsmeow',
|
||||||
config: '',
|
business_api: emptyBusinessAPIFormData(),
|
||||||
active: true
|
active: true
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (account: WhatsAppAccount) => {
|
const handleEdit = (account: WhatsAppAccount) => {
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
|
const accountBusinessAPI = account.business_api || parseLegacyBusinessAPIConfig(account.config);
|
||||||
setFormData({
|
setFormData({
|
||||||
account_id: account.account_id || account.id || '',
|
account_id: account.account_id || account.id || '',
|
||||||
phone_number: account.phone_number,
|
phone_number: account.phone_number,
|
||||||
display_name: account.display_name || '',
|
display_name: account.display_name || '',
|
||||||
account_type: account.account_type,
|
account_type: account.account_type,
|
||||||
config: account.config || '',
|
business_api: toBusinessAPIFormData(accountBusinessAPI),
|
||||||
active: account.active
|
active: account.active
|
||||||
});
|
});
|
||||||
open();
|
open();
|
||||||
@@ -164,22 +231,50 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getQRCodePath = (accountId: string) => `/api/qr/${encodeURIComponent(accountId)}`;
|
||||||
|
|
||||||
|
const getQRCodeUrl = (accountId: string) => `${window.location.origin}${getQRCodePath(accountId)}`;
|
||||||
|
|
||||||
|
const handleOpenQRModal = (account: WhatsAppAccount) => {
|
||||||
|
setSelectedQRAccount(account);
|
||||||
|
setQRImageError(null);
|
||||||
|
setQRRefreshTick(0);
|
||||||
|
openQRModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseQRModal = () => {
|
||||||
|
setSelectedQRAccount(null);
|
||||||
|
setQRImageError(null);
|
||||||
|
setQRRefreshTick(0);
|
||||||
|
closeQRModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qrModalOpened || !selectedQRAccount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshInterval = setInterval(() => {
|
||||||
|
setQRRefreshTick((previous) => previous + 1);
|
||||||
|
setQRImageError(null);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => clearInterval(refreshInterval);
|
||||||
|
}, [qrModalOpened, selectedQRAccount]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const accountId = (editingAccount?.id || formData.account_id).trim();
|
const accountId = (editingAccount?.id || formData.account_id).trim();
|
||||||
const parsedConfig = formData.config ? (() => {
|
const businessAPIPayload = fromBusinessAPIFormData(formData.business_api);
|
||||||
try {
|
|
||||||
return JSON.parse(formData.config);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})() : null;
|
|
||||||
|
|
||||||
if (formData.config && parsedConfig === null) {
|
if (
|
||||||
|
formData.account_type === 'business-api' &&
|
||||||
|
(!businessAPIPayload.phone_number_id || !businessAPIPayload.access_token)
|
||||||
|
) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Config must be valid JSON',
|
message: 'Phone Number ID and Access Token are required for Business API accounts',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -194,8 +289,8 @@ export default function AccountsPage() {
|
|||||||
disabled: !formData.active,
|
disabled: !formData.active,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formData.account_type === 'business-api' && parsedConfig) {
|
if (formData.account_type === 'business-api') {
|
||||||
payload.business_api = parsedConfig;
|
payload.business_api = businessAPIPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingAccount) {
|
if (editingAccount) {
|
||||||
@@ -229,6 +324,7 @@ export default function AccountsPage() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'connected': return 'green';
|
case 'connected': return 'green';
|
||||||
case 'connecting': return 'yellow';
|
case 'connecting': return 'yellow';
|
||||||
|
case 'pairing': return 'yellow';
|
||||||
case 'disconnected': return 'red';
|
case 'disconnected': return 'red';
|
||||||
default: return 'gray';
|
default: return 'gray';
|
||||||
}
|
}
|
||||||
@@ -250,7 +346,7 @@ export default function AccountsPage() {
|
|||||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
<Button onClick={loadAccounts}>Retry</Button>
|
<Button onClick={() => loadAccounts()}>Retry</Button>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,19 +389,21 @@ export default function AccountsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : (
|
) : (
|
||||||
accounts.map((account) => (
|
accounts.map((account) => {
|
||||||
|
const connectionStatus = getConnectionStatus(account);
|
||||||
|
return (
|
||||||
<Table.Tr key={account.id}>
|
<Table.Tr key={account.id}>
|
||||||
<Table.Td fw={500}>{account.account_id || '-'}</Table.Td>
|
<Table.Td fw={500}>{account.account_id || '-'}</Table.Td>
|
||||||
<Table.Td>{account.phone_number || '-'}</Table.Td>
|
<Table.Td>{account.phone_number || '-'}</Table.Td>
|
||||||
<Table.Td>{account.display_name || '-'}</Table.Td>
|
<Table.Td>{account.display_name || '-'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
|
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
|
||||||
{account.account_type === 'whatsmeow' ? 'WhatsApp' : 'Business API'}
|
{account.account_type === 'whatsmeow' ? 'Whatsapp' : 'Meta Business API'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={getStatusColor(account.status)} variant="light">
|
<Badge color={getStatusColor(connectionStatus)} variant="light">
|
||||||
{account.status}
|
{connectionStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -320,6 +418,16 @@ export default function AccountsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
{account.account_type === 'whatsmeow' && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
onClick={() => handleOpenQRModal(account)}
|
||||||
|
title="View QR code"
|
||||||
|
>
|
||||||
|
<IconQrcode size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -337,7 +445,8 @@ export default function AccountsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -385,28 +494,106 @@ export default function AccountsPage() {
|
|||||||
value={formData.account_type}
|
value={formData.account_type}
|
||||||
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
|
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' },
|
{ value: 'whatsmeow', label: 'Whatsapp' },
|
||||||
{ value: 'business-api', label: 'Business API' }
|
{ value: 'business-api', label: 'Meta Business API' }
|
||||||
]}
|
]}
|
||||||
required
|
required
|
||||||
disabled={!!editingAccount}
|
disabled={!!editingAccount}
|
||||||
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
|
description="Whatsapp: Personal/WhatsApp Business app connection. Meta Business API: Official WhatsApp Business API"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{formData.account_type === 'business-api' && (
|
{formData.account_type === 'business-api' && (
|
||||||
<Textarea
|
<>
|
||||||
label="Business API Config (JSON)"
|
<TextInput
|
||||||
placeholder={`{
|
label="Phone Number ID"
|
||||||
"api_key": "your-api-key",
|
placeholder="123456789012345"
|
||||||
"api_url": "https://api.whatsapp.com",
|
value={formData.business_api.phone_number_id}
|
||||||
"phone_number_id": "123456"
|
onChange={(e) =>
|
||||||
}`}
|
setFormData({
|
||||||
value={formData.config}
|
...formData,
|
||||||
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
|
business_api: { ...formData.business_api, phone_number_id: e.target.value },
|
||||||
rows={6}
|
})
|
||||||
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
|
}
|
||||||
description="Business API credentials and configuration"
|
required
|
||||||
/>
|
description="Required Meta phone number identifier"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Access Token"
|
||||||
|
placeholder="EAAG..."
|
||||||
|
type="password"
|
||||||
|
value={formData.business_api.access_token}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, access_token: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
description="Required WhatsApp Business API token"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="WABA ID"
|
||||||
|
placeholder="Optional (resolved automatically when omitted)"
|
||||||
|
value={formData.business_api.waba_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, waba_id: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Business Account ID"
|
||||||
|
placeholder="Optional Facebook Business Manager ID"
|
||||||
|
value={formData.business_api.business_account_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, business_account_id: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="API Version"
|
||||||
|
placeholder="v21.0"
|
||||||
|
value={formData.business_api.api_version}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, api_version: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
description="Defaults to v21.0 if empty"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Webhook Path"
|
||||||
|
placeholder="/webhooks/whatsapp/{account}"
|
||||||
|
value={formData.business_api.webhook_path}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, webhook_path: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Verify Token"
|
||||||
|
placeholder="Optional webhook verification token"
|
||||||
|
value={formData.business_api.verify_token}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
business_api: { ...formData.business_api, verify_token: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -422,6 +609,43 @@ export default function AccountsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={qrModalOpened}
|
||||||
|
onClose={handleCloseQRModal}
|
||||||
|
title={`QR Code: ${selectedQRAccount?.account_id || selectedQRAccount?.id || ''}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{selectedQRAccount && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">QR image URL</Text>
|
||||||
|
<Code block>{getQRCodeUrl(selectedQRAccount.id)}</Code>
|
||||||
|
<Anchor
|
||||||
|
href={getQRCodePath(selectedQRAccount.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open QR image in new tab
|
||||||
|
</Anchor>
|
||||||
|
{qrImageError ? (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" title="QR unavailable">
|
||||||
|
{qrImageError}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={`${getQRCodePath(selectedQRAccount.id)}?t=${qrRefreshTick}`}
|
||||||
|
alt={`QR code for account ${selectedQRAccount.account_id || selectedQRAccount.id}`}
|
||||||
|
style={{ width: '100%', maxWidth: 420, alignSelf: 'center', borderRadius: 8 }}
|
||||||
|
onError={() =>
|
||||||
|
setQRImageError('No QR code available. The account may already be connected or pairing has not started yet.')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
@@ -9,33 +9,48 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Stack
|
Stack,
|
||||||
|
Image,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconWebhook,
|
IconWebhook,
|
||||||
IconBrandWhatsapp,
|
IconBrandWhatsapp,
|
||||||
IconFileText
|
IconFileText,
|
||||||
|
IconDatabase,
|
||||||
|
IconCpu,
|
||||||
|
IconDeviceDesktopAnalytics,
|
||||||
|
IconNetwork,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { apiClient } from '../lib/api';
|
import { apiClient } from '../lib/api';
|
||||||
|
|
||||||
interface Stats {
|
interface DashboardStats {
|
||||||
users: number;
|
users: number;
|
||||||
hooks: number;
|
hooks: number;
|
||||||
accounts: number;
|
accounts: number;
|
||||||
eventLogs: number;
|
eventLogs: number;
|
||||||
|
messageCacheEnabled: boolean;
|
||||||
|
messageCacheCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({
|
interface RuntimeStats {
|
||||||
|
goMemoryMB: number;
|
||||||
|
goCPUPercent: number;
|
||||||
|
networkBytesPerSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard = memo(function StatCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
color
|
color,
|
||||||
|
valueColor,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number | string;
|
||||||
icon: any;
|
icon: any;
|
||||||
color: string;
|
color: string;
|
||||||
|
valueColor?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
@@ -44,8 +59,8 @@ function StatCard({
|
|||||||
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
|
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} fz="xl" mt="md">
|
<Text fw={700} fz="xl" mt="md" c={valueColor}>
|
||||||
{value.toLocaleString()}
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
@@ -59,51 +74,74 @@ function StatCard({
|
|||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState<Stats>({
|
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
users: 0,
|
users: 0,
|
||||||
hooks: 0,
|
hooks: 0,
|
||||||
accounts: 0,
|
accounts: 0,
|
||||||
eventLogs: 0
|
eventLogs: 0,
|
||||||
|
messageCacheEnabled: false,
|
||||||
|
messageCacheCount: 0,
|
||||||
|
});
|
||||||
|
const [runtimeStats, setRuntimeStats] = useState<RuntimeStats>({
|
||||||
|
goMemoryMB: 0,
|
||||||
|
goCPUPercent: 0,
|
||||||
|
networkBytesPerSec: 0,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats();
|
loadDashboardStats();
|
||||||
|
const intervalID = window.setInterval(loadDashboardStats, 60000);
|
||||||
|
return () => window.clearInterval(intervalID);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadStats = async () => {
|
useEffect(() => {
|
||||||
|
loadRuntimeStats();
|
||||||
|
const intervalID = window.setInterval(loadRuntimeStats, 5000);
|
||||||
|
return () => window.clearInterval(intervalID);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDashboardStats = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [usersResult, hooksResult, accountsResult, eventLogsResult] = await Promise.allSettled([
|
const [usersResult, hooksResult, accountsResult, eventLogsResult, messageCacheResult] = await Promise.allSettled([
|
||||||
apiClient.getUsers(),
|
apiClient.getUsers(),
|
||||||
apiClient.getHooks(),
|
apiClient.getHooks(),
|
||||||
apiClient.getAccounts(),
|
apiClient.getAccounts(),
|
||||||
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' })
|
apiClient.getEventLogs({ limit: 1, offset: 0, sort: '-created_at' }),
|
||||||
|
apiClient.getMessageCacheStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const users = usersResult.status === 'fulfilled' ? usersResult.value : [];
|
const users = usersResult.status === 'fulfilled' ? usersResult.value : [];
|
||||||
const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
|
const hooks = hooksResult.status === 'fulfilled' ? hooksResult.value : [];
|
||||||
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
|
const accounts = accountsResult.status === 'fulfilled' ? accountsResult.value : [];
|
||||||
const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null;
|
const eventLogs = eventLogsResult.status === 'fulfilled' ? eventLogsResult.value : null;
|
||||||
|
const messageCache = messageCacheResult.status === 'fulfilled' ? messageCacheResult.value : null;
|
||||||
|
|
||||||
const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0;
|
const eventLogCount = eventLogs?.meta?.total ?? eventLogs?.data?.length ?? 0;
|
||||||
|
const messageCacheEnabled = !!messageCache?.enabled;
|
||||||
|
const messageCacheCount = messageCache?.total_count ?? messageCache?.count ?? 0;
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
users: users?.length || 0,
|
users: users?.length || 0,
|
||||||
hooks: hooks?.length || 0,
|
hooks: hooks?.length || 0,
|
||||||
accounts: accounts?.length || 0,
|
accounts: accounts?.length || 0,
|
||||||
eventLogs: eventLogCount
|
eventLogs: eventLogCount,
|
||||||
|
messageCacheEnabled,
|
||||||
|
messageCacheCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected') {
|
if (usersResult.status === 'rejected' || hooksResult.status === 'rejected' || accountsResult.status === 'rejected' || eventLogsResult.status === 'rejected' || messageCacheResult.status === 'rejected') {
|
||||||
console.error('One or more dashboard stats failed to load', {
|
console.error('One or more dashboard stats failed to load', {
|
||||||
users: usersResult.status === 'rejected' ? usersResult.reason : null,
|
users: usersResult.status === 'rejected' ? usersResult.reason : null,
|
||||||
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
|
hooks: hooksResult.status === 'rejected' ? hooksResult.reason : null,
|
||||||
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
|
accounts: accountsResult.status === 'rejected' ? accountsResult.reason : null,
|
||||||
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
|
eventLogs: eventLogsResult.status === 'rejected' ? eventLogsResult.reason : null,
|
||||||
|
messageCache: messageCacheResult.status === 'rejected' ? messageCacheResult.reason : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -113,6 +151,19 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadRuntimeStats = async () => {
|
||||||
|
try {
|
||||||
|
const systemStats = await apiClient.getSystemStats();
|
||||||
|
setRuntimeStats({
|
||||||
|
goMemoryMB: Number(systemStats?.go_memory_mb ?? 0),
|
||||||
|
goCPUPercent: Number(systemStats?.go_cpu_percent ?? 0),
|
||||||
|
networkBytesPerSec: Number(systemStats?.network_bytes_per_sec ?? 0),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load runtime stats:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="xl">
|
<Container size="xl" py="xl">
|
||||||
@@ -127,11 +178,12 @@ export default function DashboardPage() {
|
|||||||
<Container size="xl" py="xl">
|
<Container size="xl" py="xl">
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<div>
|
<div>
|
||||||
|
<Image src={logoSrc} alt="WhatsHooked logo" w={120} h={120} fit="contain" mb="sm" />
|
||||||
<Title order={2}>Dashboard</Title>
|
<Title order={2}>Dashboard</Title>
|
||||||
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
|
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }}>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Users"
|
title="Total Users"
|
||||||
value={stats.users}
|
value={stats.users}
|
||||||
@@ -156,6 +208,31 @@ export default function DashboardPage() {
|
|||||||
icon={IconFileText}
|
icon={IconFileText}
|
||||||
color="violet"
|
color="violet"
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Message Cache"
|
||||||
|
value={stats.messageCacheEnabled ? stats.messageCacheCount : 'Disabled'}
|
||||||
|
icon={IconDatabase}
|
||||||
|
color={stats.messageCacheEnabled ? 'green' : 'gray'}
|
||||||
|
valueColor={stats.messageCacheEnabled ? undefined : 'dimmed'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Go Memory"
|
||||||
|
value={`${runtimeStats.goMemoryMB.toFixed(2)} MB`}
|
||||||
|
icon={IconDeviceDesktopAnalytics}
|
||||||
|
color="cyan"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Go CPU"
|
||||||
|
value={`${runtimeStats.goCPUPercent.toFixed(2)}%`}
|
||||||
|
icon={IconCpu}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Network Throughput"
|
||||||
|
value={`${(runtimeStats.networkBytesPerSec / 1024).toFixed(2)} KB/s`}
|
||||||
|
icon={IconNetwork}
|
||||||
|
color="indigo"
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
444
web/src/pages/MessageCachePage.tsx
Normal file
444
web/src/pages/MessageCachePage.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Table,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Modal,
|
||||||
|
Code,
|
||||||
|
Button,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconDatabase,
|
||||||
|
IconSearch,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconTrash,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrashX,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { apiClient } from '../lib/api';
|
||||||
|
import type { MessageCacheEvent, MessageCacheStats } from '../types';
|
||||||
|
import type { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
|
||||||
|
function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
const axiosErr = err as AxiosError<unknown>;
|
||||||
|
const responseData = axiosErr?.response?.data;
|
||||||
|
|
||||||
|
if (typeof responseData === 'string' && responseData.trim() !== '') {
|
||||||
|
return responseData.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData && typeof responseData === 'object') {
|
||||||
|
const maybeMessage = (responseData as { message?: unknown }).message;
|
||||||
|
const maybeError = (responseData as { error?: unknown }).error;
|
||||||
|
if (typeof maybeMessage === 'string' && maybeMessage.trim() !== '') {
|
||||||
|
return maybeMessage.trim();
|
||||||
|
}
|
||||||
|
if (typeof maybeError === 'string' && maybeError.trim() !== '') {
|
||||||
|
return maybeError.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error && err.message.trim() !== '') {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageCachePage() {
|
||||||
|
const [cachedEvents, setCachedEvents] = useState<MessageCacheEvent[]>([]);
|
||||||
|
const [stats, setStats] = useState<MessageCacheStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [totalFilteredCount, setTotalFilteredCount] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [eventTypeQuery, setEventTypeQuery] = useState('');
|
||||||
|
const [debouncedEventTypeQuery, setDebouncedEventTypeQuery] = useState('');
|
||||||
|
const [modalTitle, setModalTitle] = useState('Cached Event Data');
|
||||||
|
const [modalContent, setModalContent] = useState('');
|
||||||
|
const [dataModalOpened, { open: openDataModal, close: closeDataModal }] = useDisclosure(false);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedEventTypeQuery(eventTypeQuery.trim()), 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [eventTypeQuery]);
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cacheStats = await apiClient.getMessageCacheStats();
|
||||||
|
setStats(cacheStats);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCachePage = useCallback(async (targetOffset: number, reset: boolean) => {
|
||||||
|
try {
|
||||||
|
if (reset) {
|
||||||
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiClient.getMessageCacheEvents({
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
offset: targetOffset,
|
||||||
|
eventType: debouncedEventTypeQuery || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageData = result.cached_events || [];
|
||||||
|
const nextOffset = targetOffset + pageData.length;
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
setCachedEvents(pageData);
|
||||||
|
} else {
|
||||||
|
setCachedEvents((previousEvents) => [...previousEvents, ...pageData]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOffset(nextOffset);
|
||||||
|
setTotalFilteredCount(result.filtered_count);
|
||||||
|
setHasMore(nextOffset < result.filtered_count);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, 'Failed to load cached events'));
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
if (reset) {
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [debouncedEventTypeQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCachedEvents([]);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(false);
|
||||||
|
setTotalFilteredCount(null);
|
||||||
|
loadCachePage(0, true);
|
||||||
|
loadStats();
|
||||||
|
}, [debouncedEventTypeQuery, loadCachePage, loadStats]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sentinelRef.current || loading || loadingMore || !hasMore || error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
loadCachePage(offset, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '250px' },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinelRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loading, loadingMore, hasMore, error, offset, loadCachePage]);
|
||||||
|
|
||||||
|
const refreshPage = async () => {
|
||||||
|
await Promise.all([loadCachePage(0, true), loadStats()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetailsModal = (event: MessageCacheEvent) => {
|
||||||
|
setModalTitle(`Cached Event: ${event.event.type}`);
|
||||||
|
setModalContent(JSON.stringify(event, null, 2));
|
||||||
|
openDataModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplayEvent = async (id: string) => {
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await apiClient.replayCachedEvent(id);
|
||||||
|
notifications.show({ title: 'Success', message: 'Cached event replayed', color: 'green' });
|
||||||
|
await refreshPage();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({ title: 'Error', message: 'Failed to replay cached event', color: 'red' });
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async (id: string) => {
|
||||||
|
if (!confirm('Delete this cached event?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
await apiClient.deleteCachedEvent(id);
|
||||||
|
notifications.show({ title: 'Success', message: 'Cached event deleted', color: 'green' });
|
||||||
|
await refreshPage();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({ title: 'Error', message: 'Failed to delete cached event', color: 'red' });
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplayAll = async () => {
|
||||||
|
if (!confirm('Replay all cached events now?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const result = await apiClient.replayAllCachedEvents();
|
||||||
|
notifications.show({
|
||||||
|
title: 'Replay complete',
|
||||||
|
message: `Replayed ${result.replayed} events (${result.delivered} delivered, ${result.failed} failed)`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
await refreshPage();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({ title: 'Error', message: 'Failed to replay cached events', color: 'red' });
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
if (!confirm('Clear all cached events? This cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setActionLoading(true);
|
||||||
|
const result = await apiClient.clearMessageCache();
|
||||||
|
notifications.show({ title: 'Success', message: `Cleared ${result.cleared} cached events`, color: 'green' });
|
||||||
|
await refreshPage();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({ title: 'Error', message: 'Failed to clear message cache', color: 'red' });
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Center h={400}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Center>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={refreshPage}>Retry</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Group justify="space-between" mb="xl" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Message Cache</Title>
|
||||||
|
<Text c="dimmed" size="sm">Browse and manage cached webhook events with paged loading</Text>
|
||||||
|
<Text c="dimmed" size="sm" mt={4}>
|
||||||
|
Cache status: {stats?.enabled ? 'enabled' : 'disabled'}
|
||||||
|
{typeof stats?.total_count === 'number' ? ` • Total: ${stats.total_count}` : ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconRefresh size={16} />}
|
||||||
|
onClick={refreshPage}
|
||||||
|
loading={actionLoading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={handleReplayAll}
|
||||||
|
loading={actionLoading}
|
||||||
|
disabled={cachedEvents.length === 0}
|
||||||
|
>
|
||||||
|
Replay All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconTrashX size={16} />}
|
||||||
|
onClick={handleClearAll}
|
||||||
|
loading={actionLoading}
|
||||||
|
disabled={cachedEvents.length === 0}
|
||||||
|
>
|
||||||
|
Clear Cache
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mb="md">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Filter by event type (e.g. message.received)"
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={eventTypeQuery}
|
||||||
|
onChange={(e) => setEventTypeQuery(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Cached At</Table.Th>
|
||||||
|
<Table.Th>Event Type</Table.Th>
|
||||||
|
<Table.Th>Reason</Table.Th>
|
||||||
|
<Table.Th>Attempts</Table.Th>
|
||||||
|
<Table.Th>Last Attempt</Table.Th>
|
||||||
|
<Table.Th>Details</Table.Th>
|
||||||
|
<Table.Th>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{cachedEvents.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={7}>
|
||||||
|
<Center h={200}>
|
||||||
|
<Stack align="center">
|
||||||
|
<IconDatabase size={48} stroke={1.5} color="gray" />
|
||||||
|
<Text c="dimmed">
|
||||||
|
{debouncedEventTypeQuery ? 'No cached events match this filter' : 'No cached events'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
cachedEvents.map((cachedEvent) => (
|
||||||
|
<Table.Tr key={cachedEvent.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(cachedEvent.timestamp).toLocaleString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light">{cachedEvent.event.type}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{cachedEvent.reason || '-'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={cachedEvent.attempts > 0 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{cachedEvent.attempts}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">
|
||||||
|
{cachedEvent.last_attempt
|
||||||
|
? new Date(cachedEvent.last_attempt).toLocaleString()
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Code
|
||||||
|
component="button"
|
||||||
|
onClick={() => openDetailsModal(cachedEvent)}
|
||||||
|
style={{ cursor: 'pointer', border: 'none' }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Code>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Tooltip label="Replay event">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
onClick={() => handleReplayEvent(cachedEvent.id)}
|
||||||
|
loading={actionLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete event">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDeleteEvent(cachedEvent.id)}
|
||||||
|
loading={actionLoading}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
|
||||||
|
{loadingMore && (
|
||||||
|
<Center mt="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{totalFilteredCount !== null
|
||||||
|
? `Showing ${cachedEvents.length} of ${totalFilteredCount} cached events`
|
||||||
|
: `Showing ${cachedEvents.length} cached events`}
|
||||||
|
</Text>
|
||||||
|
{debouncedEventTypeQuery && (
|
||||||
|
<Text size="sm" c="dimmed">Filtered by: "{debouncedEventTypeQuery}"</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={dataModalOpened}
|
||||||
|
onClose={closeDataModal}
|
||||||
|
title={modalTitle}
|
||||||
|
fullScreen
|
||||||
|
>
|
||||||
|
<Code
|
||||||
|
component="pre"
|
||||||
|
block
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
minHeight: '90vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modalContent}
|
||||||
|
</Code>
|
||||||
|
</Modal>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
web/src/pages/SwaggerPage.tsx
Normal file
21
web/src/pages/SwaggerPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Container, Stack, Title, Text } from "@mantine/core";
|
||||||
|
import SwaggerUI from "swagger-ui-react";
|
||||||
|
import "swagger-ui-react/swagger-ui.css";
|
||||||
|
|
||||||
|
export default function SwaggerPage() {
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack gap="sm" mb="md">
|
||||||
|
<Title order={2}>Swagger</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
API documentation and live request testing.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<SwaggerUI
|
||||||
|
url={`${import.meta.env.BASE_URL}openapi.json`}
|
||||||
|
deepLinking
|
||||||
|
tryItOutEnabled
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export interface WhatsAppAccount {
|
|||||||
phone_number: string;
|
phone_number: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
account_type: 'whatsmeow' | 'business-api';
|
account_type: 'whatsmeow' | 'business-api';
|
||||||
status: 'connected' | 'disconnected' | 'connecting';
|
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||||
|
show_qr?: boolean;
|
||||||
|
business_api?: BusinessAPIConfig;
|
||||||
config?: string; // JSON string
|
config?: string; // JSON string
|
||||||
session_path?: string;
|
session_path?: string;
|
||||||
last_connected_at?: string;
|
last_connected_at?: string;
|
||||||
@@ -197,9 +199,20 @@ export interface WhatsAppAccountConfig {
|
|||||||
session_path?: string;
|
session_path?: string;
|
||||||
show_qr?: boolean;
|
show_qr?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
status?: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||||
|
connected?: boolean;
|
||||||
|
qr_available?: boolean;
|
||||||
business_api?: BusinessAPIConfig;
|
business_api?: BusinessAPIConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WhatsAppAccountRuntimeStatus {
|
||||||
|
account_id: string;
|
||||||
|
type: string;
|
||||||
|
status: 'connected' | 'disconnected' | 'connecting' | 'pairing';
|
||||||
|
connected: boolean;
|
||||||
|
qr_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventLog {
|
export interface EventLog {
|
||||||
id: string;
|
id: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
@@ -215,6 +228,50 @@ export interface EventLog {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageCacheEventPayload {
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageCacheEvent {
|
||||||
|
id: string;
|
||||||
|
event: MessageCacheEventPayload;
|
||||||
|
timestamp: string;
|
||||||
|
reason: string;
|
||||||
|
attempts: number;
|
||||||
|
last_attempt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageCacheListResponse {
|
||||||
|
cached_events: MessageCacheEvent[];
|
||||||
|
count: number;
|
||||||
|
filtered_count: number;
|
||||||
|
returned_count: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageCacheStats {
|
||||||
|
enabled: boolean;
|
||||||
|
count?: number;
|
||||||
|
total_count?: number;
|
||||||
|
by_event_type?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
go_memory_bytes: number;
|
||||||
|
go_memory_mb: number;
|
||||||
|
go_sys_memory_bytes: number;
|
||||||
|
go_sys_memory_mb: number;
|
||||||
|
go_goroutines: number;
|
||||||
|
go_cpu_percent: number;
|
||||||
|
network_rx_bytes: number;
|
||||||
|
network_tx_bytes: number;
|
||||||
|
network_total_bytes: number;
|
||||||
|
network_bytes_per_sec: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface APIKey {
|
export interface APIKey {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
1
web/src/types/swagger-ui-react.d.ts
vendored
Normal file
1
web/src/types/swagger-ui-react.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "swagger-ui-react";
|
||||||
Reference in New Issue
Block a user