feat(ui): add message cache management page and dashboard enhancements
Some checks failed
CI / Test (1.23) (push) Failing after -30m37s
CI / Test (1.22) (push) Failing after -30m33s
CI / Build (push) Failing after -30m45s
CI / Lint (push) Failing after -30m39s

- 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:
2026-03-05 00:32:57 +02:00
parent 4b44340c58
commit 1490e0b596
47 changed files with 4430 additions and 611 deletions

View File

@@ -84,7 +84,6 @@ func CreateTables(ctx context.Context) error {
(*models.ModelPublicWhatsappAccount)(nil),
(*models.ModelPublicEventLog)(nil),
(*models.ModelPublicSession)(nil),
(*models.ModelPublicMessageCache)(nil),
}
for _, model := range models {
@@ -94,6 +93,10 @@ func CreateTables(ctx context.Context) error {
}
}
if err := ensureMessageCacheTable(ctx); err != nil {
return err
}
return nil
}
@@ -153,14 +156,16 @@ func createTablesSQLite(ctx context.Context) error {
)`,
// WhatsApp Accounts table
`CREATE TABLE IF NOT EXISTS whatsapp_accounts (
`CREATE TABLE IF NOT EXISTS whatsapp_account (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
phone_number VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(255),
account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
business_api_config TEXT,
config TEXT,
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,
created_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),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
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
@@ -198,18 +203,22 @@ func createTablesSQLite(ctx context.Context) error {
// Message Cache table
`CREATE TABLE IF NOT EXISTS message_cache (
id VARCHAR(36) PRIMARY KEY,
account_id VARCHAR(36),
id VARCHAR(128) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(100) NOT NULL,
event_data TEXT NOT NULL,
message_id VARCHAR(255),
from_number VARCHAR(20),
to_number VARCHAR(20),
processed BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES whatsapp_accounts(id) ON DELETE SET 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 {
@@ -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, &currentCount); 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, &notNull, &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
}

View File

@@ -2,6 +2,7 @@ package storage
import (
"context"
"fmt"
"time"
"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
func (r *WhatsAppAccountRepository) UpdateConfig(ctx context.Context, id string, phoneNumber string, cfgJSON string, active bool) error {
_, err := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
Set("config = ?", cfgJSON).
Set("phone_number = ?", phoneNumber).
Set("active = ?", active).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Exec(ctx)
return err
now := time.Now()
updated, err := r.updateAccountTable(ctx, id, map[string]any{
"config": cfgJSON,
"phone_number": phoneNumber,
"active": active,
"updated_at": now,
})
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
func (r *WhatsAppAccountRepository) UpdateStatus(ctx context.Context, id string, status string) error {
query := r.db.NewUpdate().Model((*models.ModelPublicWhatsappAccount)(nil)).
Set("status = ?", status).
Where("id = ?", id)
now := time.Now()
fields := map[string]any{
"status": status,
"updated_at": now,
}
if status == "connected" {
now := time.Now()
query = query.Set("last_connected_at = ?", now)
fields["last_connected_at"] = now
}
_, err := query.Exec(ctx)
return err
updated, err := r.updateAccountTable(ctx, id, fields)
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