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:
|
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)
|
||||||
|
|||||||
6
PLAN.md
6
PLAN.md
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 { 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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user