refactor(UI): 🏗️ Ui changes and API changes
This commit is contained in:
@@ -16,6 +16,7 @@ Created comprehensive documentation for all libraries and frameworks:
|
||||
Complete database abstraction with GORM support:
|
||||
|
||||
**Models** (storage/models.go):
|
||||
|
||||
- User: Authentication and user management
|
||||
- APIKey: API key-based authentication
|
||||
- Hook: Webhook registrations with user ownership
|
||||
@@ -25,12 +26,14 @@ Complete database abstraction with GORM support:
|
||||
- MessageCache: WhatsApp message caching
|
||||
|
||||
**Database Management** (storage/db.go):
|
||||
|
||||
- PostgreSQL and SQLite support
|
||||
- Connection pooling
|
||||
- Auto-migration support
|
||||
- Health check functionality
|
||||
|
||||
**Repository Pattern** (storage/repository.go):
|
||||
|
||||
- Generic repository with CRUD operations
|
||||
- Specialized repositories for each model
|
||||
- User-specific queries (by username, email)
|
||||
@@ -39,6 +42,7 @@ Complete database abstraction with GORM support:
|
||||
- Event log queries with time-based filtering
|
||||
|
||||
**Seed Data** (storage/seed.go):
|
||||
|
||||
- Creates default admin user (username: admin, password: admin123)
|
||||
- Safe to run multiple times
|
||||
|
||||
@@ -47,6 +51,7 @@ Complete database abstraction with GORM support:
|
||||
Full-featured authentication system:
|
||||
|
||||
**Core Auth** (auth/auth.go):
|
||||
|
||||
- JWT token generation and validation
|
||||
- API key authentication
|
||||
- Password hashing with bcrypt
|
||||
@@ -55,6 +60,7 @@ Full-featured authentication system:
|
||||
- Permission checking based on roles (admin, user, viewer)
|
||||
|
||||
**Middleware** (auth/middleware.go):
|
||||
|
||||
- AuthMiddleware: Requires authentication (JWT or API key)
|
||||
- OptionalAuthMiddleware: Extracts user if present
|
||||
- RoleMiddleware: Enforces role-based access control
|
||||
@@ -65,6 +71,7 @@ Full-featured authentication system:
|
||||
Complete REST API server with authentication:
|
||||
|
||||
**Server Setup** (webserver/server.go):
|
||||
|
||||
- Gorilla Mux router integration
|
||||
- Authentication middleware
|
||||
- Role-based route protection
|
||||
@@ -72,6 +79,7 @@ Complete REST API server with authentication:
|
||||
- Admin-only routes
|
||||
|
||||
**Core Handlers** (webserver/handlers.go):
|
||||
|
||||
- Health check endpoint
|
||||
- User login with JWT
|
||||
- Get current user profile
|
||||
@@ -82,6 +90,7 @@ Complete REST API server with authentication:
|
||||
- Create user (admin)
|
||||
|
||||
**CRUD Handlers** (webserver/handlers_crud.go):
|
||||
|
||||
- Hook management (list, create, get, update, delete)
|
||||
- WhatsApp account management (list, create, get, update, delete)
|
||||
- API key listing
|
||||
@@ -93,19 +102,23 @@ Complete REST API server with authentication:
|
||||
Complete RESTful API:
|
||||
|
||||
**Public Endpoints**:
|
||||
|
||||
- `GET /health` - Health check
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
|
||||
**Authenticated Endpoints**:
|
||||
|
||||
- `GET /api/v1/users/me` - Get current user
|
||||
- `PUT /api/v1/users/me/password` - Change password
|
||||
|
||||
**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/{id}/revoke` - Revoke API key
|
||||
|
||||
**Hooks**:
|
||||
|
||||
- `GET /api/v1/hooks` - List user's hooks
|
||||
- `POST /api/v1/hooks` - Create hook
|
||||
- `GET /api/v1/hooks/{id}` - Get hook details
|
||||
@@ -113,6 +126,7 @@ Complete RESTful API:
|
||||
- `DELETE /api/v1/hooks/{id}` - Delete hook
|
||||
|
||||
**WhatsApp Accounts**:
|
||||
|
||||
- `GET /api/v1/whatsapp-accounts` - List user's accounts
|
||||
- `POST /api/v1/whatsapp-accounts` - Create account
|
||||
- `GET /api/v1/whatsapp-accounts/{id}` - Get account details
|
||||
@@ -120,6 +134,7 @@ Complete RESTful API:
|
||||
- `DELETE /api/v1/whatsapp-accounts/{id}` - Delete account
|
||||
|
||||
**Admin Endpoints**:
|
||||
|
||||
- `GET /api/v1/admin/users` - List all users
|
||||
- `POST /api/v1/admin/users` - Create user
|
||||
- `GET /api/v1/admin/users/{id}` - Get user
|
||||
@@ -203,6 +218,7 @@ log.Fatal(server.Start(":8825"))
|
||||
### 3. API Usage Examples
|
||||
|
||||
**Login**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8825/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -210,6 +226,7 @@ curl -X POST http://localhost:8825/api/v1/auth/login \
|
||||
```
|
||||
|
||||
**Create Hook** (with JWT):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8825/api/v1/hooks \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
@@ -224,6 +241,7 @@ curl -X POST http://localhost:8825/api/v1/hooks \
|
||||
```
|
||||
|
||||
**Create API Key**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8825/api/v1/api-keys \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
@@ -232,6 +250,7 @@ curl -X POST http://localhost:8825/api/v1/api-keys \
|
||||
```
|
||||
|
||||
**Use API Key**:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8825/api/v1/hooks \
|
||||
-H "Authorization: ApiKey YOUR_API_KEY"
|
||||
@@ -252,6 +271,7 @@ curl http://localhost:8825/api/v1/hooks \
|
||||
To complete Phase 2, implement the frontend:
|
||||
|
||||
1. **Initialize Frontend Project**:
|
||||
|
||||
```bash
|
||||
npm create @tanstack/start@latest
|
||||
cd frontend
|
||||
@@ -279,6 +299,267 @@ To complete Phase 2, implement the frontend:
|
||||
- Serve static files through Go server
|
||||
- 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
|
||||
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
6
PLAN.md
6
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
141
sql/sqlite/001_init_schema.sql
Normal file
141
sql/sqlite/001_init_schema.sql
Normal 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);
|
||||
@@ -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<WhatsAppAccount | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
account_id: '',
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
|
||||
@@ -45,7 +46,7 @@ export default function AccountsPage() {
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getAccounts();
|
||||
const data = await listRecords<WhatsAppAccount>('whatsapp_accounts');
|
||||
setAccounts(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -59,6 +60,7 @@ export default function AccountsPage() {
|
||||
const handleCreate = () => {
|
||||
setEditingAccount(null);
|
||||
setFormData({
|
||||
account_id: '',
|
||||
phone_number: '',
|
||||
display_name: '',
|
||||
account_type: 'whatsmeow',
|
||||
@@ -71,6 +73,7 @@ export default function AccountsPage() {
|
||||
const handleEdit = (account: WhatsAppAccount) => {
|
||||
setEditingAccount(account);
|
||||
setFormData({
|
||||
account_id: account.account_id || '',
|
||||
phone_number: account.phone_number,
|
||||
display_name: account.display_name || '',
|
||||
account_type: account.account_type,
|
||||
@@ -85,7 +88,7 @@ export default function AccountsPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.deleteAccount(id);
|
||||
await deleteRecord('whatsapp_accounts', id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account deleted successfully',
|
||||
@@ -121,14 +124,14 @@ export default function AccountsPage() {
|
||||
|
||||
try {
|
||||
if (editingAccount) {
|
||||
await apiClient.updateAccount(editingAccount.id, formData);
|
||||
await updateRecord('whatsapp_accounts', editingAccount.id, formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await apiClient.createAccount(formData);
|
||||
await createRecord('whatsapp_accounts', formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Account created successfully',
|
||||
@@ -192,6 +195,7 @@ export default function AccountsPage() {
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account ID</Table.Th>
|
||||
<Table.Th>Phone Number</Table.Th>
|
||||
<Table.Th>Display Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
@@ -204,7 +208,7 @@ export default function AccountsPage() {
|
||||
<Table.Tbody>
|
||||
{accounts.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Table.Td colSpan={8}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconBrandWhatsapp size={48} stroke={1.5} color="gray" />
|
||||
@@ -216,7 +220,8 @@ export default function AccountsPage() {
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<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>
|
||||
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
|
||||
@@ -270,6 +275,15 @@ export default function AccountsPage() {
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<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
|
||||
label="Phone Number"
|
||||
placeholder="+1234567890"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import { listRecords } from '../lib/query';
|
||||
import type { EventLog } from '../types';
|
||||
|
||||
export default function EventLogsPage() {
|
||||
@@ -36,7 +36,7 @@ export default function EventLogsPage() {
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getEventLogs({ limit: 1000, offset: 0 });
|
||||
const data = await listRecords<EventLog>('event_logs');
|
||||
setLogs(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
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';
|
||||
|
||||
export default function HooksPage() {
|
||||
@@ -53,7 +53,7 @@ export default function HooksPage() {
|
||||
const loadHooks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getHooks();
|
||||
const data = await listRecords<Hook>('hooks');
|
||||
setHooks(data || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -103,7 +103,7 @@ export default function HooksPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.deleteHook(id);
|
||||
await deleteRecord('hooks', id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook deleted successfully',
|
||||
@@ -163,14 +163,14 @@ export default function HooksPage() {
|
||||
|
||||
try {
|
||||
if (editingHook) {
|
||||
await apiClient.updateHook(editingHook.id, formData);
|
||||
await updateRecord('hooks', editingHook.id, formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await apiClient.createHook(formData);
|
||||
await createRecord('hooks', formData);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Hook created successfully',
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface Hook {
|
||||
export interface WhatsAppAccount {
|
||||
id: string;
|
||||
user_id: string;
|
||||
account_id?: string; // User-friendly unique identifier
|
||||
phone_number: string;
|
||||
display_name?: string;
|
||||
account_type: 'whatsmeow' | 'business-api';
|
||||
|
||||
Reference in New Issue
Block a user