refactor(UI): 🏗️ Ui changes and API changes
Some checks failed
CI / Test (1.23) (push) Failing after -22m40s
CI / Test (1.22) (push) Failing after -22m36s
CI / Build (push) Failing after -23m32s
CI / Lint (push) Failing after -23m7s

This commit is contained in:
Hein
2026-02-06 17:03:28 +02:00
parent 8b1eed6c42
commit 35a548e7e2
10 changed files with 648 additions and 20 deletions

View File

@@ -16,6 +16,7 @@ Created comprehensive documentation for all libraries and frameworks:
Complete database abstraction with GORM support: Complete database abstraction with GORM support:
**Models** (storage/models.go): **Models** (storage/models.go):
- User: Authentication and user management - User: Authentication and user management
- APIKey: API key-based authentication - APIKey: API key-based authentication
- Hook: Webhook registrations with user ownership - Hook: Webhook registrations with user ownership
@@ -25,12 +26,14 @@ Complete database abstraction with GORM support:
- MessageCache: WhatsApp message caching - MessageCache: WhatsApp message caching
**Database Management** (storage/db.go): **Database Management** (storage/db.go):
- PostgreSQL and SQLite support - PostgreSQL and SQLite support
- Connection pooling - Connection pooling
- Auto-migration support - Auto-migration support
- Health check functionality - Health check functionality
**Repository Pattern** (storage/repository.go): **Repository Pattern** (storage/repository.go):
- Generic repository with CRUD operations - Generic repository with CRUD operations
- Specialized repositories for each model - Specialized repositories for each model
- User-specific queries (by username, email) - User-specific queries (by username, email)
@@ -39,6 +42,7 @@ Complete database abstraction with GORM support:
- Event log queries with time-based filtering - Event log queries with time-based filtering
**Seed Data** (storage/seed.go): **Seed Data** (storage/seed.go):
- Creates default admin user (username: admin, password: admin123) - Creates default admin user (username: admin, password: admin123)
- Safe to run multiple times - Safe to run multiple times
@@ -47,6 +51,7 @@ Complete database abstraction with GORM support:
Full-featured authentication system: Full-featured authentication system:
**Core Auth** (auth/auth.go): **Core Auth** (auth/auth.go):
- JWT token generation and validation - JWT token generation and validation
- API key authentication - API key authentication
- Password hashing with bcrypt - Password hashing with bcrypt
@@ -55,6 +60,7 @@ Full-featured authentication system:
- Permission checking based on roles (admin, user, viewer) - Permission checking based on roles (admin, user, viewer)
**Middleware** (auth/middleware.go): **Middleware** (auth/middleware.go):
- AuthMiddleware: Requires authentication (JWT or API key) - AuthMiddleware: Requires authentication (JWT or API key)
- OptionalAuthMiddleware: Extracts user if present - OptionalAuthMiddleware: Extracts user if present
- RoleMiddleware: Enforces role-based access control - RoleMiddleware: Enforces role-based access control
@@ -65,6 +71,7 @@ Full-featured authentication system:
Complete REST API server with authentication: Complete REST API server with authentication:
**Server Setup** (webserver/server.go): **Server Setup** (webserver/server.go):
- Gorilla Mux router integration - Gorilla Mux router integration
- Authentication middleware - Authentication middleware
- Role-based route protection - Role-based route protection
@@ -72,6 +79,7 @@ Complete REST API server with authentication:
- Admin-only routes - Admin-only routes
**Core Handlers** (webserver/handlers.go): **Core Handlers** (webserver/handlers.go):
- Health check endpoint - Health check endpoint
- User login with JWT - User login with JWT
- Get current user profile - Get current user profile
@@ -82,6 +90,7 @@ Complete REST API server with authentication:
- Create user (admin) - Create user (admin)
**CRUD Handlers** (webserver/handlers_crud.go): **CRUD Handlers** (webserver/handlers_crud.go):
- Hook management (list, create, get, update, delete) - Hook management (list, create, get, update, delete)
- WhatsApp account management (list, create, get, update, delete) - WhatsApp account management (list, create, get, update, delete)
- API key listing - API key listing
@@ -93,19 +102,23 @@ Complete REST API server with authentication:
Complete RESTful API: Complete RESTful API:
**Public Endpoints**: **Public Endpoints**:
- `GET /health` - Health check - `GET /health` - Health check
- `POST /api/v1/auth/login` - User login - `POST /api/v1/auth/login` - User login
**Authenticated Endpoints**: **Authenticated Endpoints**:
- `GET /api/v1/users/me` - Get current user - `GET /api/v1/users/me` - Get current user
- `PUT /api/v1/users/me/password` - Change password - `PUT /api/v1/users/me/password` - Change password
**API Keys**: **API Keys**:
- `GET /api/v1/api-keys` - List user's API keys - `GET /api/v1/api-keys` - List user's API keys
- `POST /api/v1/api-keys` - Create API key - `POST /api/v1/api-keys` - Create API key
- `POST /api/v1/api-keys/{id}/revoke` - Revoke API key - `POST /api/v1/api-keys/{id}/revoke` - Revoke API key
**Hooks**: **Hooks**:
- `GET /api/v1/hooks` - List user's hooks - `GET /api/v1/hooks` - List user's hooks
- `POST /api/v1/hooks` - Create hook - `POST /api/v1/hooks` - Create hook
- `GET /api/v1/hooks/{id}` - Get hook details - `GET /api/v1/hooks/{id}` - Get hook details
@@ -113,6 +126,7 @@ Complete RESTful API:
- `DELETE /api/v1/hooks/{id}` - Delete hook - `DELETE /api/v1/hooks/{id}` - Delete hook
**WhatsApp Accounts**: **WhatsApp Accounts**:
- `GET /api/v1/whatsapp-accounts` - List user's accounts - `GET /api/v1/whatsapp-accounts` - List user's accounts
- `POST /api/v1/whatsapp-accounts` - Create account - `POST /api/v1/whatsapp-accounts` - Create account
- `GET /api/v1/whatsapp-accounts/{id}` - Get account details - `GET /api/v1/whatsapp-accounts/{id}` - Get account details
@@ -120,6 +134,7 @@ Complete RESTful API:
- `DELETE /api/v1/whatsapp-accounts/{id}` - Delete account - `DELETE /api/v1/whatsapp-accounts/{id}` - Delete account
**Admin Endpoints**: **Admin Endpoints**:
- `GET /api/v1/admin/users` - List all users - `GET /api/v1/admin/users` - List all users
- `POST /api/v1/admin/users` - Create user - `POST /api/v1/admin/users` - Create user
- `GET /api/v1/admin/users/{id}` - Get user - `GET /api/v1/admin/users/{id}` - Get user
@@ -203,6 +218,7 @@ log.Fatal(server.Start(":8825"))
### 3. API Usage Examples ### 3. API Usage Examples
**Login**: **Login**:
```bash ```bash
curl -X POST http://localhost:8825/api/v1/auth/login \ curl -X POST http://localhost:8825/api/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -210,6 +226,7 @@ curl -X POST http://localhost:8825/api/v1/auth/login \
``` ```
**Create Hook** (with JWT): **Create Hook** (with JWT):
```bash ```bash
curl -X POST http://localhost:8825/api/v1/hooks \ curl -X POST http://localhost:8825/api/v1/hooks \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Authorization: Bearer YOUR_JWT_TOKEN" \
@@ -224,6 +241,7 @@ curl -X POST http://localhost:8825/api/v1/hooks \
``` ```
**Create API Key**: **Create API Key**:
```bash ```bash
curl -X POST http://localhost:8825/api/v1/api-keys \ curl -X POST http://localhost:8825/api/v1/api-keys \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \ -H "Authorization: Bearer YOUR_JWT_TOKEN" \
@@ -232,6 +250,7 @@ curl -X POST http://localhost:8825/api/v1/api-keys \
``` ```
**Use API Key**: **Use API Key**:
```bash ```bash
curl http://localhost:8825/api/v1/hooks \ curl http://localhost:8825/api/v1/hooks \
-H "Authorization: ApiKey YOUR_API_KEY" -H "Authorization: ApiKey YOUR_API_KEY"
@@ -252,6 +271,7 @@ curl http://localhost:8825/api/v1/hooks \
To complete Phase 2, implement the frontend: To complete Phase 2, implement the frontend:
1. **Initialize Frontend Project**: 1. **Initialize Frontend Project**:
```bash ```bash
npm create @tanstack/start@latest npm create @tanstack/start@latest
cd frontend cd frontend
@@ -279,6 +299,267 @@ To complete Phase 2, implement the frontend:
- Serve static files through Go server - Serve static files through Go server
- Configure reverse proxy if needed - Configure reverse proxy if needed
## Using Oranguru
1. import { Gridler } from '../Gridler';
- Gridler is the grid component
- GlidlerAPIAdaptorForGoLangv2 is used to connect Gridler to RestHeadSpecAPI
```ts
const GridExample = () => {
const columns: GridlerColumns = [
{
Cell: (row) => {
const process = `${
row?.cql2?.length > 0
? '🔖'
: row?.cql1?.length > 0
? '📕'
: row?.status === 1
? '💡'
: row?.status === 2
? '🔒'
: '⚙️'
} ${String(row?.id_process ?? '0')}`;
return {
data: process,
displayData: process,
status: row?.status,
} as any;
},
id: 'id_process',
title: 'RID',
width: 100,
},
{
id: 'process',
title: 'Process',
tooltip: (buffer) => {
return `Process: ${buffer?.process}\nType: ${buffer?.processtype}\nStatus: ${buffer?.status}`;
},
width: 200,
},
{
id: 'processtype',
title: 'Type',
},
{
disableSort: true,
id: 'status',
title: 'Status',
width: 100,
},
];
return (<Gridler
columns={columns}
height="100%"
keyField="id_process"
onChange={(v) => {
//console.log('GridlerGoAPIExampleEventlog onChange', v);
setValues(v);
}}
ref={ref}
scrollToRowKey={selectRow ? parseInt(selectRow, 10) : undefined}
searchStr={search}
sections={{ ...sections, rightElementDisabled: false }}
selectFirstRowOnMount={true}
selectMode="row"
title="Go API Example"
uniqueid="gridtest"
values={values}
>
<GlidlerAPIAdaptorForGoLangv2
authtoken={apiKey}
options={[{ type: 'preload', value: 'PRO' }]}
//options={[{ type: 'fieldfilter', name: 'process', value: 'test' }]}
url={`${apiUrl}/public/process`}
/>
<Gridler.FormAdaptor
changeOnActiveClick={true}
descriptionField={'process'}
onRequestForm={(request, data) => {
console.log('Form requested', request, data);
//Show form insert,update,delete
}}
/>
</Gridler>
)
}
```
2. Former is a form wrapper that uses react-hook-form to handle forms.
```ts
import { TextInput } from '@mantine/core';
import { useUncontrolled } from '@mantine/hooks';
import { url } from 'inspector';
import { Controller } from 'react-hook-form';
import { TextInputCtrl, NativeSelectCtrl } from '../../FormerControllers';
import { InlineWrapper } from '../../FormerControllers/Inputs/InlineWrapper';
import NumberInputCtrl from '../../FormerControllers/Inputs/NumberInputCtrl';
import { Former } from '../Former';
import { FormerRestHeadSpecAPI } from '../FormerRestHeadSpecAPI';
export const ApiFormData = (props: {
onChange?: (values: Record<string, unknown>) => void;
primeData?: Record<string, unknown>;
values?: Record<string, unknown>;
}) => {
const [values, setValues] = useUncontrolled<Record<string, unknown>>({
defaultValue: { authToken: '', url: '', ...props.primeData },
finalValue: { authToken: '', url: '', ...props.primeData },
onChange: props.onChange,
value: props.values,
});
return (
<Former
disableHTMlForm
id="api-form-data"
layout={{ saveButtonTitle: 'Save URL Parameters' }}
onAPICall={FormerRestHeadSpecAPI({
authToken: authToken,
url: url,
})}
onChange={setValues}
primeData={props.primeData}
request="update"
uniqueKeyField="id"
values={values}
>
<TextInputCtrl label="Test" name="test" />
<NumberInputCtrl label="AgeTest" name="age" />
<InlineWrapper label="Select One" promptWidth={200}>
<NativeSelectCtrl data={['One', 'Two', 'Three']} name="option1" />
</InlineWrapper>
{/* Controllers can be use but we prefer to use the build TextInputCtrl and such for better integration with Former's state management and validation. However, you can also use the Controller component from react-hook-form to integrate custom inputs like this: */}
<Controller
name="url"
render={({ field }) => <TextInput label="URL" type="url" {...field} />}
/>
<Controller
name="authToken"
render={({ field }) => <TextInput label="Auth Token" type="password" {...field} />}
/>
</Former>
);
};
```
3. Controls TextInputCtrl,NumberInputCtrl,NativeSelectCtrl and InlineWrapper
- InlineWrapper is used to display the title on the left and control to the right.
```ts
const Renderable = () => {
return (
<Former>
<Stack h="100%" mih="400px" miw="400px" w="100%">
<TextInputCtrl label="Test" name="test" />
<NumberInputCtrl label="AgeTest" name="age" />
<InlineWrapper label="Select One" promptWidth={200}>
<NativeSelectCtrl data={["One","Two","Three"]} name="option1"/>
</InlineWrapper>
</Stack>
</Former>
);
};
```
4. Boxer is an advanced multi select control with infinite query lookap features for an API.
```ts
// Server-Side Example (Simulated)
export const ServerSide: Story = {
render: () => {
const [value, setValue] = useState<null | string>(null);
// Simulate server-side API call
const handleAPICall = async (params: {
page: number;
pageSize: number;
search?: string;
}): Promise<{ data: Array<BoxerItem>; total: number }> => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
// Filter based on search
let filteredData = [...sampleData];
if (params.search) {
filteredData = filteredData.filter((item) =>
item.label.toLowerCase().includes(params.search!.toLowerCase())
);
}
// Paginate
const start = params.page * params.pageSize;
const end = start + params.pageSize;
const paginatedData = filteredData.slice(start, end);
return {
data: paginatedData,
total: filteredData.length,
};
};
return (
<div style={{ width: 300 }}>
<Boxer
clearable
dataSource="server"
label="Favorite Fruit (Server-side)"
onAPICall={handleAPICall}
onChange={setValue}
pageSize={10}
placeholder="Select a fruit (Server-side)"
searchable
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Value:</strong> {value ?? 'None'}
</div>
</div>
);
},
};
// Multi-Select Example
export const MultiSelect: Story = {
render: () => {
const [value, setValue] = useState<Array<string>>([]);
return (
<div style={{ width: 300 }}>
<Boxer
clearable
data={sampleData}
dataSource="local"
label="Favorite Fruits"
multiSelect
onChange={setValue}
placeholder="Select fruits"
searchable
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Values:</strong>{' '}
{value.length > 0 ? value.join(', ') : 'None'}
</div>
</div>
);
},
};
```
## Database Schema ## Database Schema
``` ```
@@ -354,6 +635,7 @@ sessions
## Summary ## Summary
Phase 2 backend is **100% complete** with: Phase 2 backend is **100% complete** with:
- ✅ Comprehensive tool documentation - ✅ Comprehensive tool documentation
- ✅ Complete database layer with models and repositories - ✅ Complete database layer with models and repositories
- ✅ Full authentication system (JWT + API keys) - ✅ Full authentication system (JWT + API keys)

View File

@@ -68,7 +68,7 @@ CURRENT REQUIREMENTS:
TODO: 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 HooksPage to use Mantine components
- ⏳ Refactor AccountsPage to use Mantine components - ⏳ Refactor AccountsPage to use Mantine components
- ⏳ EventLogsPage with filtering and pagination using Mantine + oranguru - ⏳ EventLogsPage with filtering and pagination using Mantine + oranguru
@@ -80,7 +80,7 @@ TODO:
ARCHITECTURE NOTES: 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 - No more Phase 1/Phase 2 separation - single ResolveSpec server
- Combined authentication: JWT (new) + API key/basic auth (legacy backward compatibility) - 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 - **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) - Using React + Vite (not Tanstack Start)
- **NOW USING Mantine UI** (REQUIRED - installed and being integrated) - **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) - **Admin UI at /ui/ route instead of root** (REQUIRED)

View File

@@ -7,6 +7,8 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/google/uuid"
"git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/handlers" "git.warky.dev/wdevs/whatshooked/pkg/handlers"
"git.warky.dev/wdevs/whatshooked/pkg/models" "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 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 // Convert data map to model using JSON marshaling
dataJSON, err := json.Marshal(req.Data) dataJSON, err := json.Marshal(req.Data)
if err != nil { if err != nil {
@@ -409,6 +465,22 @@ func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req Q
return 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 // Insert into database
_, err = db.NewInsert().Model(model).Exec(r.Context()) _, err = db.NewInsert().Model(model).Exec(r.Context())
if err != nil { if err != nil {

View File

@@ -9,8 +9,9 @@ import (
type ModelPublicWhatsappAccount struct { type ModelPublicWhatsappAccount struct {
bun.BaseModel `bun:"table:whatsapp_accounts,alias:whatsapp_accounts"` bun.BaseModel `bun:"table:whatsapp_accounts,alias:whatsapp_accounts"`
ID resolvespec_common.SqlString `bun:"id,type:varchar(36),pk," json:"id"` // UUID 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 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"` 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 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"` CreatedAt resolvespec_common.SqlTime `bun:"created_at,type:timestamp,default:now(),notnull," json:"created_at"`

View File

@@ -2,6 +2,7 @@ package whatshooked
import ( import (
"context" "context"
"encoding/json"
"time" "time"
"git.warky.dev/wdevs/whatshooked/pkg/api" "git.warky.dev/wdevs/whatshooked/pkg/api"
@@ -29,6 +30,7 @@ type WhatsHooked struct {
messageCache *cache.MessageCache messageCache *cache.MessageCache
handlers *handlers.Handlers handlers *handlers.Handlers
apiServer *api.Server // ResolveSpec unified server apiServer *api.Server // ResolveSpec unified server
dbReady bool // Flag to indicate if database is ready
} }
// NewFromFile creates a WhatsHooked instance from a config file // 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 // Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus, wh.messageCache) 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() wh.hookMgr.Start()
// Initialize event logger if enabled // 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 // ConnectAll connects to all configured WhatsApp accounts
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error { 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 { for _, waCfg := range wh.config.WhatsApp {
// Skip disabled accounts // Skip disabled accounts
if waCfg.Disabled { if waCfg.Disabled {
@@ -149,6 +204,59 @@ func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
return nil 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 // Handlers returns the HTTP handlers instance
func (wh *WhatsHooked) Handlers() *handlers.Handlers { func (wh *WhatsHooked) Handlers() *handlers.Handlers {
return wh.handlers return wh.handlers
@@ -218,6 +326,15 @@ func (wh *WhatsHooked) StartAPIServer(ctx context.Context) error {
return err 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 // Create unified server
logging.Info("Creating ResolveSpec server", "host", wh.config.Server.Host, "port", wh.config.Server.Port) logging.Info("Creating ResolveSpec server", "host", wh.config.Server.Host, "port", wh.config.Server.Port)
apiServer, err := api.NewServer(wh.config, db, wh) apiServer, err := api.NewServer(wh.config, db, wh)

View File

@@ -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);

View File

@@ -21,7 +21,7 @@ import {
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 } from '@tabler/icons-react';
import { apiClient } from '../lib/api'; import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { WhatsAppAccount } from '../types'; import type { WhatsAppAccount } from '../types';
export default function AccountsPage() { export default function AccountsPage() {
@@ -31,6 +31,7 @@ export default function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null); const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
account_id: '',
phone_number: '', phone_number: '',
display_name: '', display_name: '',
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api', account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
@@ -45,7 +46,7 @@ export default function AccountsPage() {
const loadAccounts = async () => { const loadAccounts = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await apiClient.getAccounts(); const data = await listRecords<WhatsAppAccount>('whatsapp_accounts');
setAccounts(data || []); setAccounts(data || []);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -59,6 +60,7 @@ export default function AccountsPage() {
const handleCreate = () => { const handleCreate = () => {
setEditingAccount(null); setEditingAccount(null);
setFormData({ setFormData({
account_id: '',
phone_number: '', phone_number: '',
display_name: '', display_name: '',
account_type: 'whatsmeow', account_type: 'whatsmeow',
@@ -71,6 +73,7 @@ export default function AccountsPage() {
const handleEdit = (account: WhatsAppAccount) => { const handleEdit = (account: WhatsAppAccount) => {
setEditingAccount(account); setEditingAccount(account);
setFormData({ setFormData({
account_id: account.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,
@@ -85,7 +88,7 @@ export default function AccountsPage() {
return; return;
} }
try { try {
await apiClient.deleteAccount(id); await deleteRecord('whatsapp_accounts', id);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Account deleted successfully', message: 'Account deleted successfully',
@@ -121,14 +124,14 @@ export default function AccountsPage() {
try { try {
if (editingAccount) { if (editingAccount) {
await apiClient.updateAccount(editingAccount.id, formData); await updateRecord('whatsapp_accounts', editingAccount.id, formData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Account updated successfully', message: 'Account updated successfully',
color: 'green', color: 'green',
}); });
} else { } else {
await apiClient.createAccount(formData); await createRecord('whatsapp_accounts', formData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Account created successfully', message: 'Account created successfully',
@@ -192,6 +195,7 @@ export default function AccountsPage() {
<Table highlightOnHover withTableBorder withColumnBorders> <Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Account ID</Table.Th>
<Table.Th>Phone Number</Table.Th> <Table.Th>Phone Number</Table.Th>
<Table.Th>Display Name</Table.Th> <Table.Th>Display Name</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>Type</Table.Th>
@@ -204,7 +208,7 @@ export default function AccountsPage() {
<Table.Tbody> <Table.Tbody>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={7}> <Table.Td colSpan={8}>
<Center h={200}> <Center h={200}>
<Stack align="center"> <Stack align="center">
<IconBrandWhatsapp size={48} stroke={1.5} color="gray" /> <IconBrandWhatsapp size={48} stroke={1.5} color="gray" />
@@ -216,7 +220,8 @@ export default function AccountsPage() {
) : ( ) : (
accounts.map((account) => ( accounts.map((account) => (
<Table.Tr key={account.id}> <Table.Tr key={account.id}>
<Table.Td fw={500}>{account.phone_number || '-'}</Table.Td> <Table.Td fw={500}>{account.account_id || '-'}</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">
@@ -270,6 +275,15 @@ export default function AccountsPage() {
> >
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack> <Stack>
<TextInput
label="Account ID"
placeholder="my-business-account"
value={formData.account_id}
onChange={(e) => setFormData({ ...formData, account_id: e.target.value })}
required
description="Unique identifier for this account (lowercase, alphanumeric, hyphens allowed)"
/>
<TextInput <TextInput
label="Phone Number" label="Phone Number"
placeholder="+1234567890" placeholder="+1234567890"

View File

@@ -17,7 +17,7 @@ import {
Tooltip Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react'; import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
import { apiClient } from '../lib/api'; import { listRecords } from '../lib/query';
import type { EventLog } from '../types'; import type { EventLog } from '../types';
export default function EventLogsPage() { export default function EventLogsPage() {
@@ -36,7 +36,7 @@ export default function EventLogsPage() {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await apiClient.getEventLogs({ limit: 1000, offset: 0 }); const data = await listRecords<EventLog>('event_logs');
setLogs(data || []); setLogs(data || []);
setError(null); setError(null);
} catch (err) { } catch (err) {

View File

@@ -24,7 +24,7 @@ import {
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, IconWebhook } from '@tabler/icons-react'; import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconWebhook } from '@tabler/icons-react';
import { apiClient } from '../lib/api'; import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { Hook } from '../types'; import type { Hook } from '../types';
export default function HooksPage() { export default function HooksPage() {
@@ -53,7 +53,7 @@ export default function HooksPage() {
const loadHooks = async () => { const loadHooks = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await apiClient.getHooks(); const data = await listRecords<Hook>('hooks');
setHooks(data || []); setHooks(data || []);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -103,7 +103,7 @@ export default function HooksPage() {
return; return;
} }
try { try {
await apiClient.deleteHook(id); await deleteRecord('hooks', id);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Hook deleted successfully', message: 'Hook deleted successfully',
@@ -163,14 +163,14 @@ export default function HooksPage() {
try { try {
if (editingHook) { if (editingHook) {
await apiClient.updateHook(editingHook.id, formData); await updateRecord('hooks', editingHook.id, formData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Hook updated successfully', message: 'Hook updated successfully',
color: 'green', color: 'green',
}); });
} else { } else {
await apiClient.createHook(formData); await createRecord('hooks', formData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Hook created successfully', message: 'Hook created successfully',

View File

@@ -30,6 +30,7 @@ export interface Hook {
export interface WhatsAppAccount { export interface WhatsAppAccount {
id: string; id: string;
user_id: string; user_id: string;
account_id?: string; // User-friendly unique identifier
phone_number: string; phone_number: string;
display_name?: string; display_name?: string;
account_type: 'whatsmeow' | 'business-api'; account_type: 'whatsmeow' | 'business-api';