+
+
+ Selected Values:{' '}
+ {value.length > 0 ? value.join(', ') : 'None'}
+
+
+ );
+ },
+};
+
+```
+
## Database Schema
```
@@ -354,6 +635,7 @@ sessions
## Summary
Phase 2 backend is **100% complete** with:
+
- ✅ Comprehensive tool documentation
- ✅ Complete database layer with models and repositories
- ✅ Full authentication system (JWT + API keys)
diff --git a/PLAN.md b/PLAN.md
index 3a06eb2..dbfb22d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -68,7 +68,7 @@ CURRENT REQUIREMENTS:
TODO:
-- ⏳ Refactor UsersPage to use Mantine Table and oranguru DataGrid
+- ⏳ Refactor UsersPage to use Oranguru Gridler instead of Mantine Table and Refactor Forms to use Oranguru Former and TextInputCtrl controllers.
- ⏳ Refactor HooksPage to use Mantine components
- ⏳ Refactor AccountsPage to use Mantine components
- ⏳ EventLogsPage with filtering and pagination using Mantine + oranguru
@@ -80,7 +80,7 @@ TODO:
ARCHITECTURE NOTES:
-- Unified server on single port (8080, configurable)
+- Unified server on single port ( configurable)
- No more Phase 1/Phase 2 separation - single ResolveSpec server
- Combined authentication: JWT (new) + API key/basic auth (legacy backward compatibility)
- **Frontend served at /ui/ route** (not root) - built to web/dist/ and served by Go server
@@ -105,5 +105,5 @@ KEY CHANGES FROM ORIGINAL PLAN:
- Using React + Vite (not Tanstack Start)
- **NOW USING Mantine UI** (REQUIRED - installed and being integrated)
-- **NOW USING oranguru** (REQUIRED - installed with --legacy-peer-deps)
+- **NOW USING oranguru** (REQUIRED - installed)
- **Admin UI at /ui/ route instead of root** (REQUIRED)
diff --git a/pkg/api/server.go b/pkg/api/server.go
index f8eae2f..c6d381b 100644
--- a/pkg/api/server.go
+++ b/pkg/api/server.go
@@ -7,6 +7,8 @@ import (
"net/http"
"time"
+ "github.com/google/uuid"
+
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/handlers"
"git.warky.dev/wdevs/whatshooked/pkg/models"
@@ -397,6 +399,60 @@ func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
return
}
+ // Initialize data map if needed
+ if req.Data == nil {
+ req.Data = make(map[string]interface{})
+ }
+
+ // Auto-generate UUID for id field if not provided
+ generatedID := ""
+ if _, exists := req.Data["id"]; !exists {
+ generatedID = uuid.New().String()
+ req.Data["id"] = generatedID
+ }
+
+ // Auto-inject user_id for tables that need it
+ if userCtx != nil && userCtx.Claims != nil {
+ // Get user_id from claims (it's stored as UUID string)
+ if userIDClaim, ok := userCtx.Claims["user_id"]; ok {
+ if userID, ok := userIDClaim.(string); ok && userID != "" {
+ // Add user_id to data if the table requires it and it's not already set
+ tablesWithUserID := map[string]bool{
+ "hooks": true,
+ "whatsapp_accounts": true,
+ "api_keys": true,
+ "event_logs": true,
+ }
+
+ if tablesWithUserID[req.Table] {
+ // Only set user_id if not already provided
+ if _, exists := req.Data["user_id"]; !exists {
+ req.Data["user_id"] = userID
+ }
+ }
+ }
+ }
+ }
+
+ // Auto-generate session_path for WhatsApp accounts if not provided
+ if req.Table == "whatsapp_accounts" {
+ // Set session_path if not already provided
+ if _, exists := req.Data["session_path"]; !exists {
+ // Use account_id if provided, otherwise use generated id
+ sessionID := ""
+ if accountID, ok := req.Data["account_id"].(string); ok && accountID != "" {
+ sessionID = accountID
+ } else if generatedID != "" {
+ sessionID = generatedID
+ } else if id, ok := req.Data["id"].(string); ok && id != "" {
+ sessionID = id
+ }
+ if sessionID != "" {
+ req.Data["session_path"] = fmt.Sprintf("./sessions/%s", sessionID)
+ }
+ }
+ }
+
// Convert data map to model using JSON marshaling
dataJSON, err := json.Marshal(req.Data)
if err != nil {
@@ -409,6 +465,22 @@ func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
return
}
+ // Ensure ID is set after unmarshaling by using model-specific handling
+ if generatedID != "" {
+ switch m := model.(type) {
+ case *models.ModelPublicWhatsappAccount:
+ m.ID.FromString(generatedID)
+ case *models.ModelPublicHook:
+ m.ID.FromString(generatedID)
+ case *models.ModelPublicAPIKey:
+ m.ID.FromString(generatedID)
+ case *models.ModelPublicEventLog:
+ m.ID.FromString(generatedID)
+ case *models.ModelPublicUser:
+ m.ID.FromString(generatedID)
+ }
+ }
+
// Insert into database
_, err = db.NewInsert().Model(model).Exec(r.Context())
if err != nil {
diff --git a/pkg/models/sql_public_whatsapp_accounts.go b/pkg/models/sql_public_whatsapp_accounts.go
index ed414f6..a99e679 100644
--- a/pkg/models/sql_public_whatsapp_accounts.go
+++ b/pkg/models/sql_public_whatsapp_accounts.go
@@ -9,8 +9,9 @@ import (
type ModelPublicWhatsappAccount struct {
bun.BaseModel `bun:"table:whatsapp_accounts,alias:whatsapp_accounts"`
- ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID
- AccountType resolvespec_common.SqlString `bun:"account_type,type:varchar(50),notnull," json:"account_type"` // whatsmeow or business-api
+ ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID
+ AccountID resolvespec_common.SqlString `bun:"account_id,type:varchar(100),unique,nullzero," json:"account_id"` // User-friendly unique identifier
+ AccountType resolvespec_common.SqlString `bun:"account_type,type:varchar(50),notnull," json:"account_type"` // whatsmeow or business-api
Active bool `bun:"active,type:boolean,default:true,notnull," json:"active"`
Config resolvespec_common.SqlString `bun:"config,type:text,nullzero," json:"config"` // JSON encoded additional config
CreatedAt resolvespec_common.SqlTime `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`
diff --git a/pkg/whatshooked/whatshooked.go b/pkg/whatshooked/whatshooked.go
index 6292a68..9355f24 100644
--- a/pkg/whatshooked/whatshooked.go
+++ b/pkg/whatshooked/whatshooked.go
@@ -2,6 +2,7 @@ package whatshooked
import (
"context"
+ "encoding/json"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/api"
@@ -29,6 +30,7 @@ type WhatsHooked struct {
messageCache *cache.MessageCache
handlers *handlers.Handlers
apiServer *api.Server // ResolveSpec unified server
+ dbReady bool // Flag to indicate if database is ready
}
// NewFromFile creates a WhatsHooked instance from a config file
@@ -109,7 +111,7 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
// Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus, wh.messageCache)
- wh.hookMgr.LoadHooks(cfg.Hooks)
+ // Don't load hooks here - will be loaded from database after it's initialized
wh.hookMgr.Start()
// Initialize event logger if enabled
@@ -134,6 +136,59 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
// ConnectAll connects to all configured WhatsApp accounts
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
+ // If database is ready, load accounts from database
+ if wh.dbReady {
+ return wh.connectFromDatabase(ctx)
+ }
+
+ // Otherwise, fall back to config file (legacy)
+ return wh.connectFromConfig(ctx)
+}
+
+// connectFromDatabase loads and connects WhatsApp accounts from the database
+func (wh *WhatsHooked) connectFromDatabase(ctx context.Context) error {
+ db := storage.GetDB()
+ if db == nil {
+ logging.Warn("Database not available, skipping account connections")
+ return nil
+ }
+
+ // Load active WhatsApp accounts from database
+ accountRepo := storage.NewWhatsAppAccountRepository(db)
+ accounts, err := accountRepo.List(ctx, map[string]interface{}{"active": true})
+ if err != nil {
+ logging.Error("Failed to load WhatsApp accounts from database", "error", err)
+ return err
+ }
+
+ logging.Info("Loading WhatsApp accounts from database", "count", len(accounts))
+
+ for _, account := range accounts {
+ // Skip if account_id is not set
+ accountID := account.AccountID.String()
+ if accountID == "" {
+ accountID = account.ID.String() // Fall back to UUID if account_id not set
+ }
+
+ // Convert database model to config format
+ waCfg := config.WhatsAppConfig{
+ ID: accountID,
+ PhoneNumber: account.PhoneNumber.String(),
+ Type: account.AccountType.String(),
+ SessionPath: account.SessionPath.String(),
+ Disabled: !account.Active,
+ }
+
+ if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
+ logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
+ // Continue connecting to other accounts even if one fails
+ }
+ }
+ return nil
+}
+
+// connectFromConfig loads and connects WhatsApp accounts from config file (legacy)
+func (wh *WhatsHooked) connectFromConfig(ctx context.Context) error {
for _, waCfg := range wh.config.WhatsApp {
// Skip disabled accounts
if waCfg.Disabled {
@@ -149,6 +204,59 @@ func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
return nil
}
+// loadHooksFromDatabase loads webhooks from the database
+func (wh *WhatsHooked) loadHooksFromDatabase(ctx context.Context) error {
+ db := storage.GetDB()
+ if db == nil {
+ logging.Warn("Database not available, skipping hook loading")
+ return nil
+ }
+
+ // Load active hooks from database
+ hookRepo := storage.NewHookRepository(db)
+ dbHooks, err := hookRepo.List(ctx, map[string]interface{}{"active": true})
+ if err != nil {
+ logging.Error("Failed to load hooks from database", "error", err)
+ return err
+ }
+
+ logging.Info("Loading hooks from database", "count", len(dbHooks))
+
+ // Convert database models to config format
+ configHooks := make([]config.Hook, 0, len(dbHooks))
+ for _, dbHook := range dbHooks {
+ hook := config.Hook{
+ ID: dbHook.ID.String(),
+ Name: dbHook.Name.String(),
+ URL: dbHook.URL.String(),
+ Method: dbHook.Method.String(),
+ Description: dbHook.Description.String(),
+ Active: dbHook.Active,
+ }
+
+ // Parse headers JSON if present
+ if headersStr := dbHook.Headers.String(); headersStr != "" {
+ hook.Headers = make(map[string]string)
+ if err := json.Unmarshal([]byte(headersStr), &hook.Headers); err != nil {
+ logging.Warn("Failed to parse hook headers", "hook_id", hook.ID, "error", err)
+ }
+ }
+
+ // Parse events JSON if present
+ if eventsStr := dbHook.Events.String(); eventsStr != "" {
+ if err := json.Unmarshal([]byte(eventsStr), &hook.Events); err != nil {
+ logging.Warn("Failed to parse hook events", "hook_id", hook.ID, "error", err)
+ }
+ }
+
+ configHooks = append(configHooks, hook)
+ }
+
+ // Load hooks into the hook manager
+ wh.hookMgr.LoadHooks(configHooks)
+ return nil
+}
+
// Handlers returns the HTTP handlers instance
func (wh *WhatsHooked) Handlers() *handlers.Handlers {
return wh.handlers
@@ -218,6 +326,15 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
return err
}
+ // Mark database as ready for account/hook loading
+ wh.dbReady = true
+
+ // Load hooks from database
+ if err := wh.loadHooksFromDatabase(ctx); err != nil {
+ logging.Error("Failed to load hooks from database", "error", err)
+ // Continue anyway, hooks can be added later
+ }
+
// Create unified server
logging.Info("Creating ResolveSpec server", "host", wh.config.Server.Host, "port", wh.config.Server.Port)
apiServer, err := api.NewServer(wh.config, db, wh)
diff --git a/sql/sqlite/001_init_schema.sql b/sql/sqlite/001_init_schema.sql
new file mode 100644
index 0000000..cd2a17d
--- /dev/null
+++ b/sql/sqlite/001_init_schema.sql
@@ -0,0 +1,141 @@
+-- SQLite Database Schema
+-- Adapted from PostgreSQL schema for Phase 2
+
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id VARCHAR(36) PRIMARY KEY,
+ username VARCHAR(255) NOT NULL UNIQUE,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ password VARCHAR(255) NOT NULL,
+ full_name VARCHAR(255),
+ role VARCHAR(50) NOT NULL DEFAULT 'user',
+ active BOOLEAN NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
+
+-- API Keys table
+CREATE TABLE IF NOT EXISTS api_keys (
+ id VARCHAR(36) PRIMARY KEY,
+ user_id VARCHAR(36) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ key VARCHAR(255) NOT NULL UNIQUE,
+ key_prefix VARCHAR(20),
+ permissions TEXT,
+ active BOOLEAN NOT NULL DEFAULT 1,
+ expires_at TIMESTAMP,
+ last_used_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
+);
+
+CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
+CREATE INDEX IF NOT EXISTS idx_api_keys_deleted_at ON api_keys(deleted_at);
+
+-- Hooks table
+CREATE TABLE IF NOT EXISTS hooks (
+ id VARCHAR(36) PRIMARY KEY,
+ user_id VARCHAR(36) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ url TEXT NOT NULL,
+ method VARCHAR(10) NOT NULL DEFAULT 'POST',
+ description TEXT,
+ secret VARCHAR(255),
+ headers TEXT,
+ events TEXT,
+ retry_count INTEGER NOT NULL DEFAULT 3,
+ timeout INTEGER NOT NULL DEFAULT 30,
+ active BOOLEAN NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
+);
+
+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);
+
+-- WhatsApp Accounts table
+CREATE TABLE IF NOT EXISTS whatsapp_accounts (
+ id VARCHAR(36) PRIMARY KEY,
+ user_id VARCHAR(36) NOT NULL,
+ account_id VARCHAR(100) UNIQUE,
+ phone_number VARCHAR(50) NOT NULL UNIQUE,
+ display_name VARCHAR(255),
+ account_type VARCHAR(50) NOT NULL DEFAULT 'whatsmeow',
+ config TEXT,
+ session_path TEXT,
+ status VARCHAR(50) NOT NULL DEFAULT 'disconnected',
+ active BOOLEAN NOT NULL DEFAULT 1,
+ last_connected_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP,
+ 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_accounts_deleted_at ON whatsapp_accounts(deleted_at);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_whatsapp_accounts_account_id ON whatsapp_accounts(account_id);
+
+-- Event Logs table
+CREATE TABLE IF NOT EXISTS event_logs (
+ id VARCHAR(36) PRIMARY KEY,
+ event_type VARCHAR(100) NOT NULL,
+ action VARCHAR(50),
+ entity_type VARCHAR(100),
+ entity_id VARCHAR(36),
+ user_id VARCHAR(36),
+ data TEXT,
+ error TEXT,
+ success BOOLEAN NOT NULL DEFAULT 1,
+ ip_address VARCHAR(50),
+ user_agent TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
+);
+
+CREATE INDEX IF NOT EXISTS idx_event_logs_event_type ON event_logs(event_type);
+CREATE INDEX IF NOT EXISTS idx_event_logs_entity_type ON event_logs(entity_type);
+CREATE INDEX IF NOT EXISTS idx_event_logs_entity_id ON event_logs(entity_id);
+CREATE INDEX IF NOT EXISTS idx_event_logs_user_id ON event_logs(user_id);
+CREATE INDEX IF NOT EXISTS idx_event_logs_created_at ON event_logs(created_at);
+
+-- Sessions table
+CREATE TABLE IF NOT EXISTS sessions (
+ id VARCHAR(36) PRIMARY KEY,
+ user_id VARCHAR(36) NOT NULL,
+ token VARCHAR(255) NOT NULL UNIQUE,
+ ip_address VARCHAR(50),
+ user_agent TEXT,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE NO ACTION
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+
+-- Message Cache table
+CREATE TABLE IF NOT EXISTS message_cache (
+ id VARCHAR(36) PRIMARY KEY,
+ account_id VARCHAR(36) NOT NULL,
+ message_id VARCHAR(255) NOT NULL UNIQUE,
+ chat_id VARCHAR(255) NOT NULL,
+ message_type VARCHAR(50) NOT NULL,
+ content TEXT NOT NULL,
+ from_me BOOLEAN NOT NULL,
+ timestamp TIMESTAMP NOT NULL,
+ 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_chat_id ON message_cache(chat_id);
+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);
diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx
index 3fba851..b35fe33 100644
--- a/web/src/pages/AccountsPage.tsx
+++ b/web/src/pages/AccountsPage.tsx
@@ -21,7 +21,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
-import { apiClient } from '../lib/api';
+import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { WhatsAppAccount } from '../types';
export default function AccountsPage() {
@@ -31,6 +31,7 @@ export default function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editingAccount, setEditingAccount] = useState