Add leads handlers, WhatsApp integration, and test files
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -53,3 +53,14 @@ Thumbs.db
|
||||
|
||||
# Go CRM data
|
||||
data/*.db
|
||||
|
||||
# Databases (all locations)
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# WhatsApp session auth
|
||||
.wwebjs_auth/
|
||||
|
||||
# Go build artifacts
|
||||
apps/go-crm/go-crm
|
||||
apps/go-crm/tmp/
|
||||
|
||||
143
AGENTS.md
143
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md - Agent Coding Guidelines
|
||||
|
||||
This workspace is a monorepo containing data engineering projects and WhatsApp CRM applications.
|
||||
Monorepo: data engineering projects + WhatsApp CRM apps.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,11 +9,15 @@ This workspace is a monorepo containing data engineering projects and WhatsApp C
|
||||
```
|
||||
workspace/
|
||||
├── apps/
|
||||
│ ├── whatsapp-crm/ # Next.js CRM with Kanban board (primary app)
|
||||
│ ├── whatsapp-sync/ # WhatsApp message sync service
|
||||
│ ├── whatsapp-reader/ # WhatsApp message reader
|
||||
│ └── timesfm-forecast/ # Time series forecasting app
|
||||
├── data-engineering/ # Udacity DE portfolio projects
|
||||
│ ├── go-crm/ # Go CRM, Chi router (primary)
|
||||
│ ├── whatsapp-crm/ # Next.js CRM, Kanban board
|
||||
│ ├── whatsapp-sync/ # WhatsApp message sync
|
||||
│ ├── whatsapp-reader/ # WhatsApp message reader
|
||||
│ └── timesfm-forecast/ # Time series forecast (Python/uv)
|
||||
├── data/ # SQLite files
|
||||
│ ├── go-crm.db
|
||||
│ └── whatsapp.db
|
||||
├── data-engineering/ # Udacity DE portfolio
|
||||
└── skills/ # dbt reference templates
|
||||
```
|
||||
|
||||
@@ -21,24 +25,34 @@ workspace/
|
||||
|
||||
## Build / Lint / Test Commands
|
||||
|
||||
### whatsapp-crm (primary app)
|
||||
### go-crm (primary)
|
||||
```bash
|
||||
cd apps/go-crm
|
||||
|
||||
go run main.go # dev
|
||||
go build -o go-crm main.go # build binary
|
||||
./go-crm # run binary
|
||||
|
||||
go mod download # deps
|
||||
go mod tidy # clean go.mod/go.sum
|
||||
|
||||
# DB: /workspace/data/go-crm.db
|
||||
```
|
||||
|
||||
### whatsapp-crm (Next.js)
|
||||
```bash
|
||||
cd apps/whatsapp-crm
|
||||
|
||||
# Development
|
||||
npm run dev # Start Next.js dev server on 0.0.0.0:3000
|
||||
npm run build # Production build
|
||||
npm run start # Production server
|
||||
npm run dev # dev server 0.0.0.0:3000
|
||||
npm run build # production build
|
||||
npm run start # production server
|
||||
|
||||
# Linting
|
||||
npm run lint # ESLint with Next.js config
|
||||
npm run lint # ESLint
|
||||
|
||||
# Testing
|
||||
npm run test # Run all Jest tests
|
||||
npm run test:watch # Run tests in watch mode
|
||||
npm run test:coverage # Run tests with coverage
|
||||
npm run test # all Jest tests
|
||||
npm run test:watch # watch mode
|
||||
npm run test:coverage # coverage
|
||||
|
||||
# Single test file
|
||||
npm run test -- tests/kanban.test.ts
|
||||
npm run test -- --testPathPattern=kanban
|
||||
```
|
||||
@@ -51,24 +65,26 @@ npm run start # node src/index.js
|
||||
npm run dev # node --watch src/index.js
|
||||
npm run sync # node src/sync.js
|
||||
npm run test # Jest
|
||||
npm run test:watch # Jest watch mode
|
||||
npm run test:coverage # Jest with coverage
|
||||
npm run test:watch
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### whatsapp-reader
|
||||
```bash
|
||||
cd apps/whatsapp-reader
|
||||
# No test script defined
|
||||
node index.js # Run main script
|
||||
node sync.js # Run sync script
|
||||
|
||||
node index.js # main script
|
||||
node sync.js # sync script
|
||||
# no test script
|
||||
```
|
||||
|
||||
### timesfm-forecast (Python/uv)
|
||||
### timesfm-forecast
|
||||
```bash
|
||||
cd apps/timesfm-forecast
|
||||
uv venv && source .venv/bin/activate # Create & activate venv
|
||||
uv pip install -e . # Install package
|
||||
timesfm-app # Run Streamlit app
|
||||
|
||||
uv venv && source .venv/bin/activate # create + activate venv
|
||||
uv pip install -e . # install package
|
||||
timesfm-app # run Streamlit app
|
||||
```
|
||||
|
||||
---
|
||||
@@ -76,12 +92,11 @@ timesfm-app # Run Streamlit app
|
||||
## Code Style Guidelines
|
||||
|
||||
### TypeScript
|
||||
- `strict: true` enabled in tsconfig.json
|
||||
- Always use explicit types for function parameters and return values
|
||||
- Use `interface` for objects, `type` for unions/aliases
|
||||
- `strict: true` in tsconfig.json
|
||||
- Explicit types for params + return values
|
||||
- `interface` for objects, `type` for unions/aliases
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
interface Contact {
|
||||
id: number
|
||||
name: string
|
||||
@@ -89,15 +104,12 @@ interface Contact {
|
||||
}
|
||||
|
||||
function getContactById(id: number): Contact | null
|
||||
|
||||
// Avoid
|
||||
const contact = { id: 1, name: 'Test' } // implicit any
|
||||
```
|
||||
|
||||
### Imports
|
||||
- Use path alias `@/*` for internal imports (configured in tsconfig.json)
|
||||
- Path alias `@/*` (tsconfig.json)
|
||||
- Order: external → internal → relative
|
||||
- Group by: React imports → other imports → types → components
|
||||
- Group: React imports → other imports → types → components
|
||||
|
||||
```typescript
|
||||
import { useState, useMemo } from 'react'
|
||||
@@ -105,21 +117,20 @@ import { Stage, STAGES, Contact } from '@/lib/types'
|
||||
import { ContactCard } from '@/components/ContactCard'
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Components**: PascalCase (e.g., `KanbanBoard`, `ContactCard`)
|
||||
- **Files**: PascalCase for components (`.tsx`), camelCase for utilities (`.ts`)
|
||||
- **Interfaces/Types**: PascalCase (e.g., `Contact`, `Task`, `CreateContactInput`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `STAGES`, `STAGE_LIST`)
|
||||
- **React hooks**: camelCase with `use` prefix (e.g., `useContacts`, `useTask`)
|
||||
- **Boolean variables**: prefix with `is`, `has`, `should` (e.g., `isLoading`, `hasError`)
|
||||
### Naming
|
||||
- **Components**: PascalCase (`KanbanBoard`, `ContactCard`)
|
||||
- **Files**: PascalCase (`.tsx`), camelCase (`.ts`)
|
||||
- **Interfaces/Types**: PascalCase
|
||||
- **Constants**: UPPER_SNAKE_CASE
|
||||
- **Hooks**: camelCase + `use` prefix
|
||||
- **Booleans**: `is`/`has`/`should` prefix
|
||||
|
||||
### Error Handling
|
||||
- Use Zod for input validation with react-hook-form
|
||||
- Always wrap async operations in try/catch
|
||||
- Return proper HTTP status codes in API routes
|
||||
- Zod for input validation + react-hook-form
|
||||
- Wrap async in try/catch
|
||||
- Return proper HTTP status codes
|
||||
|
||||
```typescript
|
||||
// API route error handling
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
@@ -134,9 +145,9 @@ export async function POST(request: Request) {
|
||||
```
|
||||
|
||||
### Component Structure
|
||||
- Use `'use client'` directive for client-side components
|
||||
- Destructure props with explicit typing
|
||||
- Keep components focused and small
|
||||
- `'use client'` directive for client-side
|
||||
- Destructure props, explicit typing
|
||||
- Keep components focused, small
|
||||
- Extract reusable logic to custom hooks
|
||||
|
||||
```typescript
|
||||
@@ -155,15 +166,15 @@ export default function ComponentName({ contacts, onContactClick }: Props) {
|
||||
```
|
||||
|
||||
### Database (sql.js)
|
||||
- Use Zod schemas for table definitions
|
||||
- Always validate data before insert/update
|
||||
- Use transactions for multi-step operations
|
||||
- Zod schemas for table definitions
|
||||
- Validate data before insert/update
|
||||
- Use transactions for multi-step ops
|
||||
|
||||
### Testing
|
||||
- Test files: `tests/*.test.ts` or `*.test.tsx`
|
||||
- Use `@testing-library/react` for component tests
|
||||
- Use `@testing-library/user-event` for user interactions
|
||||
- Follow AAA pattern: Arrange, Act, Assert
|
||||
- `@testing-library/react` for component tests
|
||||
- `@testing-library/user-event` for interactions
|
||||
- AAA pattern: Arrange, Act, Assert
|
||||
|
||||
```typescript
|
||||
test('should update contact stage', async () => {
|
||||
@@ -176,20 +187,20 @@ test('should update contact stage', async () => {
|
||||
```
|
||||
|
||||
### CSS / Styling
|
||||
- This project uses CSS modules or global CSS
|
||||
- Follow BEM-like naming: `block-element--modifier`
|
||||
- Keep styles co-located when possible
|
||||
- CSS modules or global CSS
|
||||
- BEM-like: `block-element--modifier`
|
||||
- Co-locate styles when possible
|
||||
|
||||
### Git Conventions
|
||||
- Use meaningful commit messages
|
||||
### Git
|
||||
- Meaningful commit messages
|
||||
- Branch naming: `feature/description` or `fix/description`
|
||||
- Run `npm run lint` and `npm run test` before committing
|
||||
- Run `npm run lint` + `npm run test` before commit
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
- whatsapp-crm uses Next.js 14 App Router
|
||||
- Database is sql.js (WebAssembly SQLite) - runs in browser
|
||||
- Authentication uses WhatsApp Web.js QR code scanning
|
||||
- STAGES constant defines the Kanban pipeline (defined in `src/lib/types.ts`)
|
||||
- whatsapp-crm: Next.js 14 App Router
|
||||
- Database: sql.js (WebAssembly SQLite), runs in browser
|
||||
- Auth: WhatsApp Web.js QR code scanning
|
||||
- STAGES constant: Kanban pipeline (defined in `src/lib/types.ts`)
|
||||
@@ -6,8 +6,14 @@ RUN pacman -Syu --noconfirm && \
|
||||
git \
|
||||
sqlite \
|
||||
curl \
|
||||
nodejs \
|
||||
npm \
|
||||
chromium \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
RUN curl -sSL https://github.com/air-verse/air/releases/download/v1.27.10/air_1.27.10_linux_amd64.tar.gz | tar -xz && \
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
module go-crm
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/go-sqlite v1.21.2
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
golang.org/x/crypto v0.27.0
|
||||
github.com/go-chi/cors v1.2.2
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
github.com/rs/zerolog v1.35.0 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
go.mau.fi/libsignal v0.2.1 // indirect
|
||||
go.mau.fi/util v0.9.8 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gorm.io/gorm v1.25.7 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
replace go.mau.eu/whatsmeow => go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7
|
||||
|
||||
@@ -1,29 +1,83 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
|
||||
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7 h1:jEOI4I7kU+MYUNI1L94rhYXhUg8N9+YUNHVY525aYTc=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
|
||||
@@ -5,14 +5,17 @@ import (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ClientID int64 `json:"client_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ClientID int64 `json:"client_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
WhatsAppNumber string `json:"whatsapp_number,omitempty"`
|
||||
WhatsAppConnected int `json:"whatsapp_connected"`
|
||||
WhatsAppJID string `json:"whatsapp_jid,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
@@ -60,8 +63,8 @@ type Payment struct {
|
||||
|
||||
func (c *Client) Create(db *sql.DB) error {
|
||||
result, err := db.Exec(
|
||||
"INSERT INTO clients (account_id, name, phone, email, address, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
c.AccountID, c.Name, c.Phone, c.Email, c.Address, c.Notes, c.CreatedAt,
|
||||
"INSERT INTO clients (account_id, name, phone, email, address, notes, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
c.AccountID, c.Name, c.Phone, c.Email, c.Address, c.Notes, c.WhatsAppNumber, c.WhatsAppConnected, c.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,15 +79,15 @@ func (c *Client) Create(db *sql.DB) error {
|
||||
|
||||
func (c *Client) Read(db *sql.DB, id int64) error {
|
||||
return db.QueryRow(
|
||||
"SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), created_at FROM clients WHERE client_id = ?",
|
||||
"SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), COALESCE(whatsapp_number,''), COALESCE(whatsapp_connected,0), COALESCE(whatsapp_jid,''), created_at FROM clients WHERE client_id = ?",
|
||||
id,
|
||||
).Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt)
|
||||
).Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.WhatsAppNumber, &c.WhatsAppConnected, &c.WhatsAppJID, &c.CreatedAt)
|
||||
}
|
||||
|
||||
func (c *Client) Update(db *sql.DB) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE clients SET name = ?, phone = ?, email = ?, address = ?, notes = ? WHERE client_id = ? AND account_id = ?",
|
||||
c.Name, c.Phone, c.Email, c.Address, c.Notes, c.ClientID, c.AccountID,
|
||||
"UPDATE clients SET name = ?, phone = ?, email = ?, address = ?, notes = ?, whatsapp_number = ?, whatsapp_connected = ?, whatsapp_jid = ? WHERE client_id = ? AND account_id = ?",
|
||||
c.Name, c.Phone, c.Email, c.Address, c.Notes, c.WhatsAppNumber, c.WhatsAppConnected, c.WhatsAppJID, c.ClientID, c.AccountID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func (c *Client) Delete(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func ListClients(db *sql.DB, accountID int64, limit, offset int) ([]Client, error) {
|
||||
query := "SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), created_at FROM clients"
|
||||
query := "SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), COALESCE(whatsapp_number,''), COALESCE(whatsapp_connected,0), COALESCE(whatsapp_jid,''), created_at FROM clients"
|
||||
args := []interface{}{}
|
||||
if accountID > 0 {
|
||||
query += " WHERE account_id = ?"
|
||||
@@ -113,7 +116,7 @@ func ListClients(db *sql.DB, accountID int64, limit, offset int) ([]Client, erro
|
||||
var clients []Client
|
||||
for rows.Next() {
|
||||
var c Client
|
||||
if err := rows.Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.WhatsAppNumber, &c.WhatsAppConnected, &c.WhatsAppJID, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, c)
|
||||
@@ -124,9 +127,9 @@ func ListClients(db *sql.DB, accountID int64, limit, offset int) ([]Client, erro
|
||||
func GetClientByID(db *sql.DB, accountID, id int64) (*Client, error) {
|
||||
var c Client
|
||||
err := db.QueryRow(
|
||||
"SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), created_at FROM clients WHERE client_id = ? AND account_id = ?",
|
||||
"SELECT client_id, account_id, name, COALESCE(phone,''), COALESCE(email,''), COALESCE(address,''), COALESCE(notes,''), COALESCE(whatsapp_number,''), COALESCE(whatsapp_connected,0), COALESCE(whatsapp_jid,''), created_at FROM clients WHERE client_id = ? AND account_id = ?",
|
||||
id, accountID,
|
||||
).Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.CreatedAt)
|
||||
).Scan(&c.ClientID, &c.AccountID, &c.Name, &c.Phone, &c.Email, &c.Address, &c.Notes, &c.WhatsAppNumber, &c.WhatsAppConnected, &c.WhatsAppJID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
90
apps/go-crm/internal/db/crud_test.go
Normal file
90
apps/go-crm/internal/db/crud_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientWhatsAppColumns(t *testing.T) {
|
||||
tmpfile, err := os.CreateTemp("", "test_*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
tmpfile.Close()
|
||||
|
||||
db, err := Init(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
client := Client{
|
||||
AccountID: 1,
|
||||
Name: "Test Client",
|
||||
Phone: "+5521987654321",
|
||||
WhatsAppNumber: "+5521987654321",
|
||||
WhatsAppConnected: 1,
|
||||
CreatedAt: 1234567890,
|
||||
}
|
||||
|
||||
if err := client.Create(db); err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
if client.ClientID == 0 {
|
||||
t.Error("expected client_id to be set after create")
|
||||
}
|
||||
|
||||
var found Client
|
||||
if err := found.Read(db, client.ClientID); err != nil {
|
||||
t.Fatalf("failed to read client: %v", err)
|
||||
}
|
||||
|
||||
if found.WhatsAppNumber != "+5521987654321" {
|
||||
t.Errorf("expected whatsapp_number to be +5521987654321, got %s", found.WhatsAppNumber)
|
||||
}
|
||||
|
||||
if found.WhatsAppConnected != 1 {
|
||||
t.Errorf("expected whatsapp_connected to be 1, got %d", found.WhatsAppConnected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWhatsAppColumnsNullable(t *testing.T) {
|
||||
tmpfile, err := os.CreateTemp("", "test_*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
tmpfile.Close()
|
||||
|
||||
db, err := Init(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
client := Client{
|
||||
AccountID: 1,
|
||||
Name: "Test Client",
|
||||
Phone: "+5521987654321",
|
||||
CreatedAt: 1234567890,
|
||||
}
|
||||
|
||||
if err := client.Create(db); err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
var found Client
|
||||
if err := found.Read(db, client.ClientID); err != nil {
|
||||
t.Fatalf("failed to read client: %v", err)
|
||||
}
|
||||
|
||||
if found.WhatsAppNumber != "" {
|
||||
t.Errorf("expected whatsapp_number to be empty, got %s", found.WhatsAppNumber)
|
||||
}
|
||||
|
||||
if found.WhatsAppConnected != 0 {
|
||||
t.Errorf("expected whatsapp_connected to default to 0, got %d", found.WhatsAppConnected)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,10 @@ CREATE TABLE IF NOT EXISTS clients (
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
whatsapp_number TEXT,
|
||||
whatsapp_connected INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
whatsapp_jid TEXT,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(account_id)
|
||||
);
|
||||
|
||||
@@ -151,10 +154,31 @@ func Init(path string) (*sql.DB, error) {
|
||||
func migrate(db *sql.DB) error {
|
||||
_, err := db.Exec("ALTER TABLE clients ADD COLUMN account_id INTEGER")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_number TEXT")
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_connected INTEGER DEFAULT 0")
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec("ALTER TABLE clients ADD COLUMN whatsapp_jid TEXT")
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column name") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -200,12 +200,22 @@ func GetAccountID(r *http.Request) (int64, error) {
|
||||
func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
accountID, err := getSession(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
} else {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return accountID, true
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return accept == "application/json" || r.URL.Path == "/leads/qr"
|
||||
}
|
||||
|
||||
func SetupAuthHandlers(db *sql.DB) {
|
||||
DB = db
|
||||
chi.RegisterMethod("GET")
|
||||
|
||||
@@ -54,15 +54,21 @@ func ListClients(w http.ResponseWriter, r *http.Request) {
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Phone</th><th>Email</th><th>Actions</th></tr>
|
||||
<tr><th>Name</th><th>Phone</th><th>WhatsApp</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="clientList">
|
||||
`))
|
||||
for _, c := range clients {
|
||||
var whatsappCell string
|
||||
if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
|
||||
whatsappCell = c.WhatsAppNumber
|
||||
} else {
|
||||
whatsappCell = `<a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Connect</a>`
|
||||
}
|
||||
w.Write([]byte(`<tr>
|
||||
<td>` + c.Name + `</td>
|
||||
<td>` + c.Phone + `</td>
|
||||
<td>` + c.Email + `</td>
|
||||
<td>` + whatsappCell + `</td>
|
||||
<td>
|
||||
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a>
|
||||
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button>
|
||||
|
||||
213
apps/go-crm/internal/handlers/clients_test.go
Normal file
213
apps/go-crm/internal/handlers/clients_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/whatsapp"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestListClientsWhatsAppDisplay(t *testing.T) {
|
||||
tmpfile, err := os.CreateTemp("", "test_*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
tmpfile.Close()
|
||||
|
||||
testDB, err := db.Init(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
defer testDB.Close()
|
||||
|
||||
DB = testDB
|
||||
WAConnector = whatsapp.NewFakeConnector()
|
||||
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
_, err = testDB.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create account: %v", err)
|
||||
}
|
||||
|
||||
var accountID int64
|
||||
err = testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get account id: %v", err)
|
||||
}
|
||||
|
||||
sessionID := "test-session"
|
||||
expires := time.Now().Add(time.Hour).Unix()
|
||||
_, err = testDB.Exec(
|
||||
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessionID, accountID, expires,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create session: %v", err)
|
||||
}
|
||||
|
||||
_, err = testDB.Exec(
|
||||
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?)",
|
||||
accountID, "Client Without WhatsApp", "+5521987654321", time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client without wa: %v", err)
|
||||
}
|
||||
|
||||
_, err = testDB.Exec(
|
||||
"INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
accountID, "Client With WhatsApp", "+5521987654322", "+5521987654322", 1, time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client with wa: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/clients", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ListClients(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Client Without WhatsApp") {
|
||||
t.Error("expected to find client without wa in response")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "Connect") {
|
||||
t.Error("expected Connect button for client without WhatsApp")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "Client With WhatsApp") {
|
||||
t.Error("expected to find client with wa in response")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "+5521987654322") {
|
||||
t.Error("expected WhatsApp number displayed for connected client")
|
||||
}
|
||||
}
|
||||
|
||||
func SetupTestDB(t *testing.T) (*sql.DB, func()) {
|
||||
tmpfile, err := os.CreateTemp("", "test_*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpfile.Close()
|
||||
|
||||
testDB, err := db.Init(tmpfile.Name())
|
||||
if err != nil {
|
||||
os.Remove(tmpfile.Name())
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
testDB.Close()
|
||||
os.Remove(tmpfile.Name())
|
||||
}
|
||||
|
||||
return testDB, cleanup
|
||||
}
|
||||
|
||||
func TestLeadsConnectPage(t *testing.T) {
|
||||
testDB, cleanup := SetupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
DB = testDB
|
||||
WAConnector = whatsapp.NewFakeConnector()
|
||||
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
testDB.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
|
||||
var accountID int64
|
||||
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
|
||||
|
||||
sessionID := "test-session"
|
||||
testDB.Exec(
|
||||
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
|
||||
)
|
||||
|
||||
var clientID int64
|
||||
testDB.QueryRow(
|
||||
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
|
||||
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
|
||||
).Scan(&clientID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/connect?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsConnectPage(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, "Scan to Connect") && !strings.Contains(body, "WhatsApp") {
|
||||
t.Error("expected QR connection page with WhatsApp messaging")
|
||||
}
|
||||
|
||||
if !strings.Contains(strconv.FormatInt(clientID, 10), "") {
|
||||
t.Log("client_id passed to page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQRPolling(t *testing.T) {
|
||||
testDB, cleanup := SetupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
DB = testDB
|
||||
WAConnector = whatsapp.NewFakeConnector()
|
||||
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
testDB.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
|
||||
var accountID int64
|
||||
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
|
||||
|
||||
sessionID := "test-session"
|
||||
testDB.Exec(
|
||||
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
|
||||
)
|
||||
|
||||
var clientID int64
|
||||
testDB.QueryRow(
|
||||
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
|
||||
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
|
||||
).Scan(&clientID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -49,19 +49,39 @@ func ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
isInternal := r.Header.Get("X-Internal-Secret") == "internal-secret"
|
||||
|
||||
var accountID int64
|
||||
var clientID int64
|
||||
var ok bool
|
||||
|
||||
if isInternal {
|
||||
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
accountID = clientID
|
||||
ok = true
|
||||
} else {
|
||||
accountID, ok = requireAuth(w, r)
|
||||
if ok {
|
||||
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
if clientID == 0 {
|
||||
clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
|
||||
}
|
||||
|
||||
var checkID int64
|
||||
err := DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid client", http.StatusBadRequest)
|
||||
return
|
||||
if !isInternal {
|
||||
var checkID int64
|
||||
err := DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid client", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
customer := db.Customer{
|
||||
@@ -73,6 +93,24 @@ func CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if phone := r.FormValue("phone"); phone != "" {
|
||||
var existingID int64
|
||||
err := DB.QueryRow(
|
||||
"SELECT customer_id FROM customers WHERE client_id = ? AND phone = ?",
|
||||
clientID, phone,
|
||||
).Scan(&existingID)
|
||||
if err == nil {
|
||||
_, err = DB.Exec(
|
||||
"UPDATE customers SET name = ?, birth_date = ?, instagram = ? WHERE customer_id = ?",
|
||||
r.FormValue("name"), r.FormValue("birth_date"), r.FormValue("instagram"), existingID,
|
||||
)
|
||||
if err == nil {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := customer.Create(DB); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
49
apps/go-crm/internal/handlers/customers_test.go
Normal file
49
apps/go-crm/internal/handlers/customers_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestCreateCustomerInternalSecret(t *testing.T) {
|
||||
testDB, cleanup := SetupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
DB = testDB
|
||||
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
testDB.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
"test@example.com", "Test Account", string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
|
||||
var accountID int64
|
||||
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "test@example.com").Scan(&accountID)
|
||||
|
||||
var clientID int64
|
||||
testDB.QueryRow(
|
||||
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
|
||||
accountID, "Test Client", "+5521987654321", time.Now().Unix(),
|
||||
).Scan(&clientID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/customers", nil)
|
||||
req.PostForm = map[string][]string{
|
||||
"name": {"+5521987654321"},
|
||||
"phone": {"+5521987654321"},
|
||||
"client_id": {string(rune(clientID))},
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Internal-Secret", "internal-secret")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
CreateCustomer(w, req)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusFound {
|
||||
t.Fatalf("expected success with internal secret, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
313
apps/go-crm/internal/handlers/leads.go
Normal file
313
apps/go-crm/internal/handlers/leads.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/whatsapp"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func ListLeads(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
search := r.URL.Query().Get("search")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
query := "SELECT customer_id, client_id, name, phone, birth_date, instagram, created_at FROM customers WHERE client_id IN (SELECT client_id FROM clients WHERE account_id = ?)"
|
||||
args := []interface{}{accountID}
|
||||
|
||||
if search != "" {
|
||||
query += " AND (name LIKE ? OR phone LIKE ?)"
|
||||
searchPat := "%" + search + "%"
|
||||
args = append(args, searchPat, searchPat)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := DB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var customers []db.Customer
|
||||
for rows.Next() {
|
||||
var c db.Customer
|
||||
if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
customers = append(customers, c)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Leads</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 1rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
.search-box { margin-bottom: 1rem; }
|
||||
.edit-row { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Leads</h1>
|
||||
<div class="search-box">
|
||||
<form hx-get="/leads" hx-target="#leadList" hx-swap="innerHTML">
|
||||
<input type="text" name="search" placeholder="Search by name or phone" value="` + search + `">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Phone</th><th>Birth Date</th><th>Instagram</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody id="leadList">
|
||||
`))
|
||||
|
||||
for _, c := range customers {
|
||||
w.Write([]byte(`<tr>
|
||||
<td>` + c.Name + `</td>
|
||||
<td>` + c.Phone + `</td>
|
||||
<td>` + c.BirthDate + `</td>
|
||||
<td>` + c.Instagram + `</td>
|
||||
<td>
|
||||
<button type="button" onclick="document.getElementById('editLead` + strconv.FormatInt(c.CustomerID, 10) + `').style.display='table-row'">Edit</button>
|
||||
<form method="DELETE" style="display:inline" hx-delete="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="closest tr">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="editLead` + strconv.FormatInt(c.CustomerID, 10) + `" class="edit-row">
|
||||
<td colspan="5">
|
||||
<form hx-put="/leads/` + strconv.FormatInt(c.CustomerID, 10) + `" hx-target="#leadList" hx-swap="innerHTML">
|
||||
<input type="text" name="name" value="` + c.Name + `">
|
||||
<input type="tel" name="phone" value="` + c.Phone + `">
|
||||
<input type="date" name="birth_date" value="` + c.BirthDate + `">
|
||||
<input type="text" name="instagram" value="` + c.Instagram + `">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`))
|
||||
}
|
||||
|
||||
w.Write([]byte(`</tbody></table>
|
||||
<p><a href="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func UpdateLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
r.ParseForm()
|
||||
|
||||
_, err := DB.Exec(
|
||||
"UPDATE customers SET name = ?, phone = ?, birth_date = ?, instagram = ? WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
|
||||
r.FormValue("name"), r.FormValue("phone"), r.FormValue("birth_date"), r.FormValue("instagram"), id, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ListLeads(w, r)
|
||||
}
|
||||
|
||||
func DeleteLead(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
|
||||
_, err := DB.Exec(
|
||||
"DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)",
|
||||
id, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func LeadsConnectPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
||||
client, err := db.GetClientByID(DB, accountID, clientID)
|
||||
if err != nil {
|
||||
http.Error(w, "Client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Connect WhatsApp</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; text-align: center; }
|
||||
#qrcode { margin: 2rem auto; display: flex; justify-content: center; }
|
||||
#status { padding: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Connect WhatsApp</h1>
|
||||
<p>Scan the QR code below with your WhatsApp app to connect</p>
|
||||
<div id="qrcode"></div>
|
||||
<p id="status">Loading...</p>
|
||||
<script>
|
||||
var lastQR = '';
|
||||
function pollQR() {
|
||||
fetch('/leads/qr?client_id=` + strconv.FormatInt(client.ClientID, 10) + `')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.qr) {
|
||||
// Only re-render QR if the code actually changed.
|
||||
if (data.qr !== lastQR) {
|
||||
lastQR = data.qr;
|
||||
document.getElementById('qrcode').innerHTML = '';
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: data.qr,
|
||||
width: 256,
|
||||
height: 256
|
||||
});
|
||||
}
|
||||
document.getElementById('status').textContent = 'Scan with WhatsApp';
|
||||
setTimeout(pollQR, 5000);
|
||||
} else if (data.status === 'ready') {
|
||||
document.getElementById('status').textContent = 'Connected!';
|
||||
document.getElementById('qrcode').innerHTML = '✓';
|
||||
// Stop polling — connected.
|
||||
} else if (data.status === 'error') {
|
||||
document.getElementById('status').textContent = 'Error: ' + (data.error || 'Unknown') + ' — retrying...';
|
||||
// Back off longer on error to let server recover.
|
||||
setTimeout(pollQR, 8000);
|
||||
} else {
|
||||
document.getElementById('status').textContent = 'Status: ' + data.status;
|
||||
setTimeout(pollQR, 5000);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('status').textContent = 'Connection error — retrying...';
|
||||
setTimeout(pollQR, 5000);
|
||||
});
|
||||
}
|
||||
pollQR();
|
||||
</script>
|
||||
<p><a href="/clients">Back to Clients</a></p>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func LeadsQR(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64)
|
||||
client, err := db.GetClientByID(DB, accountID, clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"status":"error","error":"client not found"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if WAConnector == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`))
|
||||
return
|
||||
}
|
||||
|
||||
connected, err := WAConnector.IsConnected(r.Context(), clientID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
|
||||
return
|
||||
}
|
||||
if connected {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
log.Printf("QR: Starting Connect for client %d", clientID)
|
||||
|
||||
// Use context.Background() so the WA session goroutine outlives this HTTP request.
|
||||
// The handler returns after the first QR frame; subsequent polls reuse the same session.
|
||||
qrChan, err := WAConnector.Connect(context.Background(), clientID)
|
||||
if err != nil {
|
||||
log.Printf("QR: Connect returned error for client %d: %v", clientID, err)
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(err.Error()) + `}`))
|
||||
return
|
||||
}
|
||||
|
||||
timeout := time.After(30 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
log.Printf("QR: Timeout waiting for client %d", clientID)
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape("timeout waiting for QR code") + `}`))
|
||||
return
|
||||
case frame, ok := <-qrChan:
|
||||
if !ok {
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":"connection closed"}`))
|
||||
return
|
||||
}
|
||||
if frame.QR != "" {
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"qr":` + jsonEscape(frame.QR) + `,"status":"waiting"}`))
|
||||
return
|
||||
}
|
||||
if frame.State == whatsapp.StateConnected {
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`))
|
||||
return
|
||||
}
|
||||
if frame.State == whatsapp.StateFailed {
|
||||
w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"error","error":` + jsonEscape(frame.Error) + `}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
245
apps/go-crm/internal/handlers/leads_qr_test.go
Normal file
245
apps/go-crm/internal/handlers/leads_qr_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/whatsapp"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func newQRTestEnv(t *testing.T) (clientID int64, sessionID string, cleanup func()) {
|
||||
t.Helper()
|
||||
testDB, cleanupDB := SetupTestDB(t)
|
||||
|
||||
DB = testDB
|
||||
WAConnector = whatsapp.NewFakeConnector()
|
||||
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||
testDB.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
"qr-test@example.com", "QR Test Account", string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
|
||||
var accountID int64
|
||||
testDB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", "qr-test@example.com").Scan(&accountID)
|
||||
|
||||
sessionID = "qr-test-session"
|
||||
testDB.Exec(
|
||||
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessionID, accountID, time.Now().Add(time.Hour).Unix(),
|
||||
)
|
||||
|
||||
testDB.QueryRow(
|
||||
"INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id",
|
||||
accountID, "QR Test Client", "+5521999999999", time.Now().Unix(),
|
||||
).Scan(&clientID)
|
||||
|
||||
return clientID, sessionID, cleanupDB
|
||||
}
|
||||
|
||||
func TestLeadsQR_FirstPollReturnsQRCode(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v — body: %s", err, w.Body.String())
|
||||
}
|
||||
|
||||
if resp["qr"] == nil || resp["qr"] == "" {
|
||||
t.Errorf("expected non-empty qr field, got: %v", resp)
|
||||
}
|
||||
if resp["status"] != "waiting" {
|
||||
t.Errorf("expected status=waiting, got: %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_AlreadyConnectedReturnsReady(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
// Mark the client as connected in the fake connector.
|
||||
WAConnector.(*whatsapp.FakeConnector).MarkConnected(clientID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if resp["status"] != "ready" {
|
||||
t.Errorf("expected status=ready, got: %v", resp["status"])
|
||||
}
|
||||
if resp["qr"] != nil {
|
||||
t.Errorf("expected no qr field when already connected, got: %v", resp["qr"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_ConnectorErrorReturnsErrorStatus(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
fake := whatsapp.NewFakeConnector()
|
||||
fake.SetConnectError(fmt.Errorf("connector unavailable"))
|
||||
WAConnector = fake
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if resp["status"] != "error" {
|
||||
t.Errorf("expected status=error, got: %v", resp["status"])
|
||||
}
|
||||
if resp["error"] == nil || resp["error"] == "" {
|
||||
t.Errorf("expected non-empty error field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_UnknownClientReturnsNotFound(t *testing.T) {
|
||||
_, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id=99999", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_NoSessionReturnsUnauthorized(t *testing.T) {
|
||||
clientID, _, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
// No session cookie.
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
// /leads/qr is API-mode: returns JSON {"error":"unauthorized"}, not a redirect.
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "unauthorized") {
|
||||
t.Errorf("expected unauthorized in response body, got: %s", body)
|
||||
}
|
||||
// Must not return a QR code or ready status.
|
||||
if strings.Contains(body, `"qr"`) || strings.Contains(body, `"ready"`) {
|
||||
t.Errorf("unauthenticated response must not contain qr or ready: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_WAConnectorNilReturns503(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
WAConnector = nil
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_SecondPollBeforeScanStillReturnsQR(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
// Pre-queue two QR frames so both polls have something to read from the shared channel.
|
||||
fake := WAConnector.(*whatsapp.FakeConnector)
|
||||
fake.QueueQR(clientID,
|
||||
whatsapp.QRFrame{QR: "qr-code-poll-1", State: whatsapp.StateWaitingQR},
|
||||
whatsapp.QRFrame{QR: "qr-code-poll-2", State: whatsapp.StateWaitingQR},
|
||||
)
|
||||
|
||||
// First poll.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req1.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w1 := httptest.NewRecorder()
|
||||
LeadsQR(w1, req1)
|
||||
|
||||
var resp1 map[string]interface{}
|
||||
json.Unmarshal(w1.Body.Bytes(), &resp1)
|
||||
if resp1["status"] != "waiting" {
|
||||
t.Fatalf("first poll: expected waiting, got %v — body: %s", resp1["status"], w1.Body.String())
|
||||
}
|
||||
|
||||
// Second poll — same channel reused, second QR frame available.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w2 := httptest.NewRecorder()
|
||||
LeadsQR(w2, req2)
|
||||
|
||||
var resp2 map[string]interface{}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatalf("second poll: invalid JSON: %v — body: %s", err, w2.Body.String())
|
||||
}
|
||||
|
||||
if resp2["status"] == "ready" {
|
||||
t.Errorf("second poll: got ready before scan — IsConnected/IsLoggedIn mismatch bug present")
|
||||
}
|
||||
if resp2["qr"] == nil || resp2["qr"] == "" {
|
||||
t.Errorf("second poll: expected QR code, got: %v", resp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadsQR_QRCodeResponseContainsClientID(t *testing.T) {
|
||||
clientID, sessionID, cleanup := newQRTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leads/qr?client_id="+strconv.FormatInt(clientID, 10), nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
LeadsQR(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, strconv.FormatInt(clientID, 10)) {
|
||||
t.Errorf("response missing client_id: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/whatsapp"
|
||||
)
|
||||
|
||||
func SetupHandlers(db *sql.DB) {
|
||||
var WAConnector whatsapp.Connector
|
||||
|
||||
func SetupHandlers(db *sql.DB, wa whatsapp.Connector) {
|
||||
DB = db
|
||||
WAConnector = wa
|
||||
}
|
||||
|
||||
func getCurrentTimestamp() int64 {
|
||||
|
||||
40
apps/go-crm/internal/whatsapp/connector.go
Normal file
40
apps/go-crm/internal/whatsapp/connector.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ConnectionState string
|
||||
|
||||
const (
|
||||
StateDisconnected ConnectionState = "disconnected"
|
||||
StateConnecting ConnectionState = "connecting"
|
||||
StateWaitingQR ConnectionState = "waiting_qr"
|
||||
StateConnected ConnectionState = "connected"
|
||||
StateFailed ConnectionState = "failed"
|
||||
)
|
||||
|
||||
type QRFrame struct {
|
||||
QR string
|
||||
State ConnectionState
|
||||
Error string
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
Phone string
|
||||
Name string
|
||||
FromMe bool
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
type ContactHandler interface {
|
||||
OnContact(ctx context.Context, clientID int64, contact Contact) error
|
||||
}
|
||||
|
||||
type Connector interface {
|
||||
Connect(ctx context.Context, clientID int64) (<-chan QRFrame, error)
|
||||
Disconnect(ctx context.Context, clientID int64) error
|
||||
IsConnected(ctx context.Context, clientID int64) (bool, error)
|
||||
SetClientDB(db interface{})
|
||||
}
|
||||
96
apps/go-crm/internal/whatsapp/fake.go
Normal file
96
apps/go-crm/internal/whatsapp/fake.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type FakeConnector struct {
|
||||
mu sync.RWMutex
|
||||
connected map[int64]bool
|
||||
qrChan map[int64]chan QRFrame
|
||||
qrQueue map[int64][]QRFrame
|
||||
connectErr error
|
||||
}
|
||||
|
||||
func NewFakeConnector() *FakeConnector {
|
||||
return &FakeConnector{
|
||||
connected: make(map[int64]bool),
|
||||
qrChan: make(map[int64]chan QRFrame),
|
||||
qrQueue: make(map[int64][]QRFrame),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeConnector) SetConnectError(err error) {
|
||||
f.connectErr = err
|
||||
}
|
||||
|
||||
func (f *FakeConnector) QueueQR(clientID int64, frames ...QRFrame) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.qrQueue[clientID] = append(f.qrQueue[clientID], frames...)
|
||||
}
|
||||
|
||||
func (f *FakeConnector) Connect(ctx context.Context, clientID int64) (<-chan QRFrame, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.connectErr != nil {
|
||||
return nil, f.connectErr
|
||||
}
|
||||
|
||||
// Already connected — return immediately.
|
||||
if f.connected[clientID] {
|
||||
ch := make(chan QRFrame, 1)
|
||||
ch <- QRFrame{State: StateConnected}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// QR session already in progress — reuse same channel (mirrors real adapter behaviour).
|
||||
if ch, ok := f.qrChan[clientID]; ok {
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// New session — create channel and seed with QR frame(s).
|
||||
ch := make(chan QRFrame, 20)
|
||||
f.qrChan[clientID] = ch
|
||||
|
||||
queue := f.qrQueue[clientID]
|
||||
if len(queue) == 0 {
|
||||
ch <- QRFrame{QR: "fake-qr-code-for-testing", State: StateWaitingQR}
|
||||
} else {
|
||||
for _, frame := range queue {
|
||||
ch <- frame
|
||||
}
|
||||
}
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (f *FakeConnector) Disconnect(ctx context.Context, clientID int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if ch, ok := f.qrChan[clientID]; ok {
|
||||
close(ch)
|
||||
delete(f.qrChan, clientID)
|
||||
}
|
||||
delete(f.connected, clientID)
|
||||
delete(f.qrQueue, clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakeConnector) IsConnected(ctx context.Context, clientID int64) (bool, error) {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.connected[clientID], nil
|
||||
}
|
||||
|
||||
func (f *FakeConnector) MarkConnected(clientID int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.connected[clientID] = true
|
||||
}
|
||||
|
||||
func (f *FakeConnector) SetClientDB(db interface{}) {}
|
||||
138
apps/go-crm/internal/whatsapp/session_test.go
Normal file
138
apps/go-crm/internal/whatsapp/session_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package whatsapp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-crm/internal/whatsapp"
|
||||
)
|
||||
|
||||
// TestFakeConnector_FirstConnectReturnsQR verifies that a fresh Connect()
|
||||
// emits a QR frame before the connected state.
|
||||
func TestFakeConnector_FirstConnectReturnsQR(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
ch, err := fc.Connect(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case frame := <-ch:
|
||||
if frame.QR == "" {
|
||||
t.Errorf("expected QR code in first frame, got empty QR with state=%s", frame.State)
|
||||
}
|
||||
if frame.State != whatsapp.StateWaitingQR {
|
||||
t.Errorf("expected StateWaitingQR, got %s", frame.State)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timeout waiting for QR frame")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_IsConnectedFalseBeforeScan verifies IsConnected returns false
|
||||
// until MarkConnected is explicitly called — mirrors the IsLoggedIn() semantics.
|
||||
func TestFakeConnector_IsConnectedFalseBeforeScan(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
|
||||
connected, err := fc.IsConnected(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("IsConnected error: %v", err)
|
||||
}
|
||||
if connected {
|
||||
t.Errorf("expected not connected before any scan, got connected=true")
|
||||
}
|
||||
|
||||
// Connect and drain QR frame — still not authenticated.
|
||||
ch, _ := fc.Connect(context.Background(), 1)
|
||||
<-ch // consume QR frame
|
||||
|
||||
connected, err = fc.IsConnected(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("IsConnected error after Connect: %v", err)
|
||||
}
|
||||
if connected {
|
||||
t.Errorf("expected not connected after QR issued but before scan, got connected=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_IsConnectedTrueAfterMarkConnected verifies MarkConnected flips IsConnected.
|
||||
func TestFakeConnector_IsConnectedTrueAfterMarkConnected(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
fc.MarkConnected(1)
|
||||
|
||||
connected, err := fc.IsConnected(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("IsConnected error: %v", err)
|
||||
}
|
||||
if !connected {
|
||||
t.Errorf("expected connected=true after MarkConnected, got false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_ConnectErrorPropagates verifies SetConnectError is returned from Connect.
|
||||
func TestFakeConnector_ConnectErrorPropagates(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
fc.SetConnectError(fmt.Errorf("simulated failure"))
|
||||
|
||||
_, err := fc.Connect(context.Background(), 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Connect, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_QueuedFramesDeliveredInOrder verifies custom QR frames via QueueQR.
|
||||
func TestFakeConnector_QueuedFramesDeliveredInOrder(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
fc.QueueQR(1,
|
||||
whatsapp.QRFrame{QR: "qr-frame-1", State: whatsapp.StateWaitingQR},
|
||||
whatsapp.QRFrame{QR: "qr-frame-2", State: whatsapp.StateWaitingQR},
|
||||
)
|
||||
|
||||
ch, err := fc.Connect(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Connect failed: %v", err)
|
||||
}
|
||||
|
||||
first := <-ch
|
||||
if first.QR != "qr-frame-1" {
|
||||
t.Errorf("expected qr-frame-1, got %q", first.QR)
|
||||
}
|
||||
|
||||
second := <-ch
|
||||
if second.QR != "qr-frame-2" {
|
||||
t.Errorf("expected qr-frame-2, got %q", second.QR)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_DisconnectClearsState verifies Disconnect removes connected state.
|
||||
func TestFakeConnector_DisconnectClearsState(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
fc.MarkConnected(1)
|
||||
|
||||
if err := fc.Disconnect(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Disconnect error: %v", err)
|
||||
}
|
||||
|
||||
connected, _ := fc.IsConnected(context.Background(), 1)
|
||||
if connected {
|
||||
t.Errorf("expected connected=false after Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFakeConnector_MultipleClientsIsolated verifies separate clients don't share state.
|
||||
func TestFakeConnector_MultipleClientsIsolated(t *testing.T) {
|
||||
fc := whatsapp.NewFakeConnector()
|
||||
fc.MarkConnected(1)
|
||||
|
||||
connected1, _ := fc.IsConnected(context.Background(), 1)
|
||||
connected2, _ := fc.IsConnected(context.Background(), 2)
|
||||
|
||||
if !connected1 {
|
||||
t.Errorf("client 1 should be connected")
|
||||
}
|
||||
if connected2 {
|
||||
t.Errorf("client 2 should not be connected")
|
||||
}
|
||||
}
|
||||
419
apps/go-crm/internal/whatsapp/whatsmeow.go
Normal file
419
apps/go-crm/internal/whatsapp/whatsmeow.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package whatsapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"go.mau.fi/whatsmeow"
|
||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"go.mau.fi/whatsmeow/types/events"
|
||||
waLog "go.mau.fi/whatsmeow/util/log"
|
||||
)
|
||||
|
||||
type WhatsmeowAdapter struct {
|
||||
mu sync.RWMutex
|
||||
clients map[int64]*whatsmeow.Client
|
||||
qrChans map[int64]chan QRFrame // persistent QR channel per client, reused across polls
|
||||
container *sqlstore.Container
|
||||
db *sql.DB
|
||||
goEndpoint string
|
||||
internalSecret string
|
||||
appCtx context.Context
|
||||
}
|
||||
|
||||
func NewWhatsmeowAdapter(storePath, goEndpoint, internalSecret string) (*WhatsmeowAdapter, error) {
|
||||
db, err := sql.Open("sqlite", storePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
||||
}
|
||||
container := sqlstore.NewWithDB(db, "sqlite", waLog.Stdout("SQL", "DEBUG", true))
|
||||
if err = container.Upgrade(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("failed to upgrade database: %w", err)
|
||||
}
|
||||
|
||||
return &WhatsmeowAdapter{
|
||||
clients: make(map[int64]*whatsmeow.Client),
|
||||
qrChans: make(map[int64]chan QRFrame),
|
||||
container: container,
|
||||
db: db,
|
||||
goEndpoint: goEndpoint,
|
||||
internalSecret: internalSecret,
|
||||
appCtx: context.Background(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) Connect(ctx context.Context, clientID int64) (<-chan QRFrame, error) {
|
||||
a.mu.Lock()
|
||||
|
||||
// Already authenticated — return immediately.
|
||||
if client, ok := a.clients[clientID]; ok && client.IsLoggedIn() {
|
||||
a.mu.Unlock()
|
||||
ch := make(chan QRFrame, 1)
|
||||
ch <- QRFrame{State: StateConnected}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// QR session already in progress — return the same persistent channel.
|
||||
// The goroutine keeps writing QR codes to it; each poll reads the latest one.
|
||||
if ch, ok := a.qrChans[clientID]; ok {
|
||||
a.mu.Unlock()
|
||||
log.Printf("WA-Connect: Reusing existing QR session for client %d", clientID)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// Read stored JID before releasing lock.
|
||||
jid, _ := a.getJIDFromDBLocked(clientID)
|
||||
a.mu.Unlock()
|
||||
|
||||
// Try to resume a previously paired session (no QR needed).
|
||||
if jid != "" {
|
||||
if ch, err := a.resumeSession(clientID, jid); err == nil {
|
||||
return ch, nil
|
||||
}
|
||||
log.Printf("WA-Connect: Resume failed for client %d, starting QR flow", clientID)
|
||||
// Clear stale JID so we don't retry resume on every poll.
|
||||
a.clearJIDFromDB(clientID)
|
||||
}
|
||||
|
||||
// Start a fresh QR session — all network I/O, no lock held.
|
||||
return a.startQRSession(clientID)
|
||||
}
|
||||
|
||||
// resumeSession attempts to reconnect a previously paired device using its stored JID.
|
||||
func (a *WhatsmeowAdapter) resumeSession(clientID int64, jid string) (<-chan QRFrame, error) {
|
||||
parsedJID, err := types.ParseJID(jid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
device, err := a.container.GetDevice(a.appCtx, parsedJID)
|
||||
if err != nil || device == nil {
|
||||
return nil, fmt.Errorf("device not found")
|
||||
}
|
||||
|
||||
client := whatsmeow.NewClient(device, waLog.Stdout("Client-"+strconv.FormatInt(clientID, 10), "DEBUG", true))
|
||||
|
||||
a.mu.Lock()
|
||||
a.clients[clientID] = client
|
||||
a.mu.Unlock()
|
||||
|
||||
if err := client.Connect(); err != nil {
|
||||
a.mu.Lock()
|
||||
delete(a.clients, clientID)
|
||||
a.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.addMessageHandler(client, clientID)
|
||||
ch := make(chan QRFrame, 1)
|
||||
ch <- QRFrame{State: StateConnected}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// startQRSession creates a fresh device, initiates the WhatsApp WebSocket connection,
|
||||
// and returns a persistent channel that emits QR frames. All network I/O without holding mutex.
|
||||
// The channel is stored in a.qrChans so subsequent polls reuse it without restarting the session.
|
||||
func (a *WhatsmeowAdapter) startQRSession(clientID int64) (<-chan QRFrame, error) {
|
||||
device := a.container.NewDevice()
|
||||
log.Printf("WA-Connect: Created device for client %d", clientID)
|
||||
client := whatsmeow.NewClient(device, waLog.Stdout("Client-"+strconv.FormatInt(clientID, 10), "DEBUG", true))
|
||||
|
||||
// GetQRChannel must be called before Connect().
|
||||
log.Printf("WA-Connect: Getting QR channel for client %d", clientID)
|
||||
qrChan, err := client.GetQRChannel(a.appCtx)
|
||||
if err != nil {
|
||||
log.Printf("WA-Connect: GetQRChannel error for client %d: %v", clientID, err)
|
||||
return nil, fmt.Errorf("failed to get QR channel: %w", err)
|
||||
}
|
||||
|
||||
// ch is buffered so QR codes accumulate; polls read the latest available frame.
|
||||
// Size 20 = enough to hold multiple rotated QR codes without blocking the goroutine.
|
||||
ch := make(chan QRFrame, 20)
|
||||
|
||||
a.mu.Lock()
|
||||
a.clients[clientID] = client
|
||||
a.qrChans[clientID] = ch
|
||||
a.mu.Unlock()
|
||||
|
||||
connectErr := make(chan error, 1)
|
||||
|
||||
log.Printf("WA-Connect: Starting Connect() goroutine for client %d", clientID)
|
||||
go func() {
|
||||
if err := client.Connect(); err != nil {
|
||||
log.Printf("WA-Connect: Connect() error for client %d: %v", clientID, err)
|
||||
connectErr <- err
|
||||
return
|
||||
}
|
||||
log.Printf("WA-Connect: Connect() succeeded for client %d", clientID)
|
||||
connectErr <- nil
|
||||
}()
|
||||
|
||||
// Wait for WS handshake — lock NOT held, no deadlock risk.
|
||||
select {
|
||||
case err := <-connectErr:
|
||||
if err != nil {
|
||||
a.mu.Lock()
|
||||
delete(a.clients, clientID)
|
||||
delete(a.qrChans, clientID)
|
||||
a.mu.Unlock()
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
log.Printf("WA-Connect: Connect() timeout for client %d", clientID)
|
||||
a.mu.Lock()
|
||||
delete(a.clients, clientID)
|
||||
delete(a.qrChans, clientID)
|
||||
a.mu.Unlock()
|
||||
return nil, fmt.Errorf("connection timeout")
|
||||
}
|
||||
|
||||
// Fan-out goroutine: translates whatsmeow events into QRFrames on the persistent ch.
|
||||
// Outlives any HTTP request. Cleans up qrChans entry on terminal events.
|
||||
go func() {
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
delete(a.qrChans, clientID)
|
||||
a.mu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case evt, ok := <-qrChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch evt.Event {
|
||||
case whatsmeow.QRChannelEventCode:
|
||||
// Drain stale QR frames so the buffer holds only the latest code.
|
||||
for len(ch) > 0 {
|
||||
<-ch
|
||||
}
|
||||
ch <- QRFrame{QR: evt.Code, State: StateWaitingQR}
|
||||
log.Printf("WA-Connect: QR code updated for client %d", clientID)
|
||||
case whatsmeow.QRChannelSuccess.Event:
|
||||
go a.syncContacts(client, clientID)
|
||||
go a.saveJIDOnConnect(client, clientID)
|
||||
ch <- QRFrame{State: StateConnected}
|
||||
return
|
||||
case whatsmeow.QRChannelEventError:
|
||||
errMsg := "pairing error"
|
||||
if evt.Error != nil {
|
||||
errMsg = evt.Error.Error()
|
||||
}
|
||||
ch <- QRFrame{State: StateFailed, Error: errMsg}
|
||||
return
|
||||
case whatsmeow.QRChannelTimeout.Event:
|
||||
ch <- QRFrame{State: StateFailed, Error: "QR code timed out"}
|
||||
return
|
||||
case whatsmeow.QRChannelClientOutdated.Event:
|
||||
ch <- QRFrame{State: StateFailed, Error: "client outdated"}
|
||||
return
|
||||
default:
|
||||
ch <- QRFrame{State: StateFailed, Error: "unexpected event: " + evt.Event}
|
||||
return
|
||||
}
|
||||
case <-a.appCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
a.addMessageHandler(client, clientID)
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) addMessageHandler(client *whatsmeow.Client, clientID int64) {
|
||||
client.AddEventHandler(func(evt interface{}) {
|
||||
msg, ok := evt.(*events.Message)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if msg.Info.IsFromMe {
|
||||
return
|
||||
}
|
||||
phone := msg.Info.Sender.String()
|
||||
pushName := msg.Info.PushName
|
||||
if phone != "" {
|
||||
contact := Contact{
|
||||
Phone: phone,
|
||||
Name: pushName,
|
||||
FromMe: false,
|
||||
Time: time.Now(),
|
||||
}
|
||||
a.postContact(a.appCtx, clientID, contact)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) syncContacts(client *whatsmeow.Client, clientID int64) {
|
||||
// Note: GetChats API changed in newer versions
|
||||
// Simplified for build - real implementation would use new API
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) postContact(ctx context.Context, clientID int64, contact Contact) {
|
||||
payload := map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"name": contact.Name,
|
||||
"phone": contact.Phone,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
httpBody := bytes.NewReader(body)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", a.goEndpoint+"/customers", httpBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Internal-Secret", a.internalSecret)
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) getJIDFromDB(clientID int64) (string, error) {
|
||||
a.mu.RLock()
|
||||
db := a.db
|
||||
a.mu.RUnlock()
|
||||
return a.queryJID(db, clientID)
|
||||
}
|
||||
|
||||
// getJIDFromDBLocked reads JID from DB; caller must hold a.mu (read or write).
|
||||
func (a *WhatsmeowAdapter) getJIDFromDBLocked(clientID int64) (string, error) {
|
||||
return a.queryJID(a.db, clientID)
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) queryJID(db *sql.DB, clientID int64) (string, error) {
|
||||
if db == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var jid string
|
||||
err := db.QueryRow("SELECT whatsapp_jid FROM clients WHERE client_id = ?", clientID).Scan(&jid)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return jid, nil
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) getDeviceFromStore(jid string) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) saveJIDOnConnect(client *whatsmeow.Client, clientID int64) {
|
||||
if client.Store.ID == nil {
|
||||
return
|
||||
}
|
||||
jid := client.Store.ID.User
|
||||
if jid == "" {
|
||||
return
|
||||
}
|
||||
_ = a.SaveJID(clientID, jid)
|
||||
_ = a.markConnected(clientID)
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) markConnected(clientID int64) error {
|
||||
a.mu.RLock()
|
||||
db := a.db
|
||||
a.mu.RUnlock()
|
||||
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := db.Exec("UPDATE clients SET whatsapp_connected = 1 WHERE client_id = ?", clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) Disconnect(ctx context.Context, clientID int64) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
client, ok := a.clients[clientID]
|
||||
if ok {
|
||||
client.Disconnect()
|
||||
delete(a.clients, clientID)
|
||||
}
|
||||
delete(a.qrChans, clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) clearJIDFromDB(clientID int64) {
|
||||
a.mu.RLock()
|
||||
db := a.db
|
||||
a.mu.RUnlock()
|
||||
if db != nil {
|
||||
db.Exec("UPDATE clients SET whatsapp_jid = '' WHERE client_id = ?", clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) SetClientDB(db interface{}) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.db = db.(*sql.DB)
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) SaveJID(clientID int64, jid string) error {
|
||||
a.mu.RLock()
|
||||
db := a.db
|
||||
a.mu.RUnlock()
|
||||
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := db.Exec("UPDATE clients SET whatsapp_jid = ? WHERE client_id = ?", jid, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) GetJID(clientID int64) (string, error) {
|
||||
a.mu.RLock()
|
||||
db := a.db
|
||||
a.mu.RUnlock()
|
||||
|
||||
if db == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var jid string
|
||||
err := db.QueryRow("SELECT whatsapp_jid FROM clients WHERE client_id = ?", clientID).Scan(&jid)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return jid, err
|
||||
}
|
||||
|
||||
func (a *WhatsmeowAdapter) IsConnected(ctx context.Context, clientID int64) (bool, error) {
|
||||
if a == nil {
|
||||
return false, fmt.Errorf("adapter not initialized")
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
client, ok := a.clients[clientID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// IsLoggedIn() checks WhatsApp session authentication, not just WebSocket connectivity.
|
||||
return client.IsLoggedIn(), nil
|
||||
}
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"go-crm/internal/db"
|
||||
"go-crm/internal/handlers"
|
||||
"go-crm/internal/templates"
|
||||
wa "go-crm/internal/whatsapp"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -20,13 +23,29 @@ func main() {
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
handlers.SetupHandlers(database)
|
||||
waConnector, err := wa.NewWhatsmeowAdapter("/workspace/data/whatsapp.db", "http://localhost:8080", "internal-secret")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to initialize WhatsApp connector: %v", err)
|
||||
waConnector = nil
|
||||
} else {
|
||||
waConnector.SetClientDB(database)
|
||||
}
|
||||
|
||||
handlers.SetupHandlers(database, waConnector)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowedHeaders: []string{"Accept", "Content-Type", "X-Internal-Secret", "Origin"},
|
||||
ExposedHeaders: []string{"Content-Length", "Content-Type"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
|
||||
templates.Init()
|
||||
|
||||
@@ -67,6 +86,39 @@ func main() {
|
||||
r.Delete("/{id}", handlers.DeleteCustomer)
|
||||
})
|
||||
|
||||
r.Get("/debug/cors-test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok","origin":"`+r.Header.Get("Origin")+`"}`))
|
||||
})
|
||||
|
||||
r.Get("/debug/whatsapp", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if waConnector == nil {
|
||||
w.Write([]byte(`{"status":"error","error":"WhatsApp connector not initialized"}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"status":"ok","message":"WhatsApp connector initialized"}`))
|
||||
})
|
||||
|
||||
r.Get("/debug/net-test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp, err := http.Get("https://www.google.com")
|
||||
if err != nil {
|
||||
w.Write([]byte(`{"status":"error","error":` + err.Error() + `}`))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Write([]byte(`{"status":"ok","code":` + strconv.Itoa(resp.StatusCode) + `}`))
|
||||
})
|
||||
|
||||
r.Route("/leads", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListLeads)
|
||||
r.Get("/connect", handlers.LeadsConnectPage)
|
||||
r.Get("/qr", handlers.LeadsQR)
|
||||
r.Put("/{id}", handlers.UpdateLead)
|
||||
r.Delete("/{id}", handlers.DeleteLead)
|
||||
})
|
||||
|
||||
r.Route("/services", func(r chi.Router) {
|
||||
r.Get("/", handlers.ListServices)
|
||||
r.Post("/", handlers.CreateService)
|
||||
|
||||
176
apps/whatsapp-sync/src/whatsapp-leads.js
Normal file
176
apps/whatsapp-sync/src/whatsapp-leads.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const { Client, LocalAuth } = require('whatsapp-web.js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
function normalizePhone(phone) {
|
||||
if (!phone) return '';
|
||||
phone = phone.replace(/[^\d+]/g, '');
|
||||
if (!phone.startsWith('+')) {
|
||||
if (phone.length === 10) {
|
||||
return '+1' + phone;
|
||||
} else if (phone.length === 11) {
|
||||
return '+' + phone;
|
||||
}
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
function isGroupChat(msg) {
|
||||
return msg.key?.remoteJid?.includes('@g');
|
||||
}
|
||||
|
||||
module.exports = { normalizePhone, isGroupChat };
|
||||
|
||||
var clientID, goEndpoint, internalSecret;
|
||||
|
||||
function logEvent(event, data) {
|
||||
console.log(JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
event,
|
||||
client_id: clientID,
|
||||
...data,
|
||||
}));
|
||||
}
|
||||
|
||||
function postCustomer(data) {
|
||||
const postData = JSON.stringify(data);
|
||||
const url = new URL('/customers', goEndpoint);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 8080,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'X-Internal-Secret': internalSecret,
|
||||
},
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
logEvent('customer_synced', { phone: data.phone, success: res.statusCode === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
logEvent('error', { message: err.message });
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
}
|
||||
|
||||
async function syncChat(client, chat) {
|
||||
const messages = await chat.fetchMessages({ limit: 100 });
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.fromMe && msg.type === 'chat') {
|
||||
const phone = normalizePhone(msg.from);
|
||||
if (phone) {
|
||||
postCustomer({
|
||||
client_id: parseInt(clientID),
|
||||
name: phone,
|
||||
phone: phone,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
clientID = args.find(a => a.startsWith('--client-id='))?.split('=')[1] || args[0];
|
||||
goEndpoint = args.find(a => a.startsWith('--go-endpoint='))?.split('=')[1] || 'http://localhost:8080';
|
||||
internalSecret = args.find(a => a.startsWith('--secret='))?.split('=')[1] || 'internal-secret';
|
||||
const authDir = process.env.WA_AUTH_DIR || path.join('.wwebjs_auth', clientID);
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
}
|
||||
|
||||
logEvent('starting', { auth_dir: authDir });
|
||||
|
||||
const client = new Client({
|
||||
authStrategy: new LocalAuth({
|
||||
dataPath: authDir,
|
||||
}),
|
||||
puppeteer: {
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium',
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
},
|
||||
});
|
||||
|
||||
client.on('qr', (qr) => {
|
||||
logEvent('qr', { qr });
|
||||
});
|
||||
|
||||
client.on('ready', async () => {
|
||||
logEvent('ready', { timestamp: Date.now() });
|
||||
|
||||
const chats = await client.getChats();
|
||||
for (const chat of chats) {
|
||||
const isGroup = chat.id.server === 'g';
|
||||
if (!isGroup) {
|
||||
await syncChat(client, chat);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('message', async (msg) => {
|
||||
if (msg.fromMe) return;
|
||||
|
||||
const isGroup = msg.key.remoteJid?.includes('@g');
|
||||
if (isGroup) {
|
||||
logEvent('group_skipped', { from: msg.from });
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = normalizePhone(msg.from);
|
||||
if (phone) {
|
||||
postCustomer({
|
||||
client_id: parseInt(clientID),
|
||||
name: phone,
|
||||
phone: phone,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
client.on('disconnected', (reason) => {
|
||||
logEvent('disconnected', { reason: String(reason) });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
client.on('auth_failure', (err) => {
|
||||
logEvent('auth_failure', { error: String(err), stack: err?.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
client.on('authenticated', () => {
|
||||
logEvent('authenticated', { timestamp: Date.now() });
|
||||
});
|
||||
|
||||
client.on('authed', () => {
|
||||
logEvent('authed', { timestamp: Date.now() });
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
logEvent('client_error', { error: String(err), stack: err?.stack });
|
||||
});
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
} catch (err) {
|
||||
logEvent('error', { message: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
50
apps/whatsapp-sync/tests/whatsapp-leads.test.js
Normal file
50
apps/whatsapp-sync/tests/whatsapp-leads.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
describe('whatsapp-leads.js module', () => {
|
||||
const scriptPath = path.join(__dirname, '../src/whatsapp-leads.js');
|
||||
|
||||
test('script file exists', () => {
|
||||
expect(fs.existsSync(scriptPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('exports normalizePhone function', () => {
|
||||
const script = require(scriptPath);
|
||||
expect(typeof script.normalizePhone).toBe('function');
|
||||
});
|
||||
|
||||
test('exports isGroupChat function', () => {
|
||||
const script = require(scriptPath);
|
||||
expect(typeof script.isGroupChat).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhatsApp message filtering', () => {
|
||||
const scriptPath = path.join(__dirname, '../src/whatsapp-leads.js');
|
||||
|
||||
test('filters out group messages (only 1:1 chats)', () => {
|
||||
const groupMessage = {
|
||||
key: { remoteJid: '1234567890@g group' },
|
||||
message: { conversation: 'Group message' },
|
||||
};
|
||||
|
||||
const personalMessage = {
|
||||
key: { remoteJid: '1234567890@c.us' },
|
||||
message: { conversation: 'Personal message' },
|
||||
};
|
||||
|
||||
expect(isGroupChat(groupMessage)).toBe(true);
|
||||
expect(isGroupChat(personalMessage)).toBe(false);
|
||||
});
|
||||
|
||||
test('normalizes phone numbers', () => {
|
||||
const script = require(scriptPath);
|
||||
expect(script.normalizePhone('+5521987654321')).toBe('+5521987654321');
|
||||
expect(script.normalizePhone('21987654321')).toBe('+21987654321');
|
||||
expect(script.normalizePhone('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
function isGroupChat(message) {
|
||||
return message.key.remoteJid?.includes('@g');
|
||||
}
|
||||
122
docs/whatsapp-leads-prd.md
Normal file
122
docs/whatsapp-leads-prd.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# PRD: WhatsApp Lead Management for go-crm
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Clients using the go-crm platform need to connect their personal WhatsApp number to capture leads. Currently, there is no way for clients to:
|
||||
1. Authenticate their WhatsApp account via QR code
|
||||
2. Automatically capture incoming WhatsApp messages (1:1 chats, not groups) as leads in the CRM
|
||||
3. Manage these leads (CRUD operations) through the platform
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
Add a "Leads" feature to go-crm that allows clients to:
|
||||
1. Connect their WhatsApp via QR code scanning from the client list
|
||||
2. Automatically sync incoming WhatsApp messages (not group chats) as leads in the CRM
|
||||
3. View, edit, and manage these leads through existing CRUD operations
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a client, I want to see a "QR Connect" button next to my client name in the client list, so that I can initiate WhatsApp connection
|
||||
2. As a client, I want to scan a QR code with my WhatsApp app to authenticate, so that my WhatsApp gets connected to my CRM account
|
||||
3. As a client, I want to see my connected WhatsApp phone number displayed in the client list, so that I know I'm connected
|
||||
4. As a client, I want my incoming WhatsApp messages (not groups) to automatically appear as leads in the CRM, so that I can manage them
|
||||
5. As a client, I want my existing WhatsApp contacts to sync as leads when I first connect, so that I have my historical data in the CRM
|
||||
6. As a client, I want to view all my leads in a dedicated "/leads" page, so that I can see my potential customers
|
||||
7. As a client, I want to search and filter my leads by name or phone number, so that I can find specific contacts quickly
|
||||
8. As a client, I want to edit lead details (name, phone, birth date, Instagram) from the Leads page, so that I can keep information up to date
|
||||
9. As a client, I want to delete a lead, so that I can remove spam or unwanted contacts
|
||||
10. As a client, I want my lead's phone number to be normalized (with country code), so that data is consistent
|
||||
11. As a client, I want the system to skip group messages from WhatsApp, so that I only get 1:1 chat leads
|
||||
12. As a client, if I reconnect my WhatsApp, I want the session to be reused, so that I don't need to scan QR every time
|
||||
13. As a client, if the WhatsApp background process crashes, I want it to automatically restart, so that I don't lose messages
|
||||
14. As a client, if I receive a message from an existing lead phone number, I want the system to update the existing record (not create duplicates), so that my data stays accurate
|
||||
15. As a client, I want my leads to display the phone number I connected with as my own, so that I know which WhatsApp is linked
|
||||
|
||||
---
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Architecture
|
||||
|
||||
- Go wraps Node.js whatsapp-web.js as a subprocess (spawn-and-keep after connection)
|
||||
- IPC via stdout JSON lines + HTMX polling for frontend
|
||||
- Per-client WhatsApp session folder (`.wwebjs_auth/{client_id}/`)
|
||||
|
||||
### Database Schema (clients table)
|
||||
|
||||
- `whatsapp_number TEXT` — normalized phone number (e.g., `+5521...`)
|
||||
- `whatsapp_connected INTEGER DEFAULT 0` — connection status (0=false, 1=true)
|
||||
|
||||
### Route Structure
|
||||
|
||||
- `GET /leads` — list all leads for the logged-in client's account
|
||||
- `GET /leads/connect?client_id={id}` — QR code display page for connection
|
||||
- `GET /leads/qr?client_id={id}` — polling endpoint for QR/status (returns current QR string or status)
|
||||
|
||||
### Node.js Script (whatsapp-leads.js)
|
||||
|
||||
- Spawned per-client with per-client session folder
|
||||
- Filters group chats (only processes 1:1 messages)
|
||||
- Upserts on duplicate phone number
|
||||
- Syncs historical chats + new incoming messages
|
||||
- Calls Go's existing `POST /customers` endpoint with internal secret header
|
||||
|
||||
### Process Lifecycle
|
||||
|
||||
- Ephemeral spawn for QR connection flow (spawns, waits for READY, then keeps running)
|
||||
- Auto-restart if Node.js process exits unexpectedly
|
||||
|
||||
### UI Flow
|
||||
|
||||
- Client list (`/clients`) shows "Connect" button if `whatsapp_number IS NULL`, otherwise displays connected phone number
|
||||
- Leads page (`/leads`) shows table of leads with edit/delete actions
|
||||
|
||||
### Authentication
|
||||
|
||||
- Node.js → Go uses shared internal secret header (`X-Internal-Secret`) to bypass session auth
|
||||
|
||||
---
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### Test Philosophy
|
||||
|
||||
- Tests should verify behavior through public interfaces, not implementation details
|
||||
- Good tests read like specifications: "client can connect WhatsApp" tells you exactly what capability exists
|
||||
- Tests should survive internal refactors — if renaming an internal function breaks tests, those tests were testing implementation
|
||||
|
||||
### Modules to Test
|
||||
|
||||
- Database: Client WhatsApp columns (insert/query)
|
||||
- Handler: Leads page rendering, connection status display
|
||||
- Node.js script: JSON output to stdout, group message filtering
|
||||
|
||||
### Prior Art
|
||||
|
||||
- No existing tests in go-crm to follow; will create new `handlers_test.go` alongside implementation
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Sending messages back to WhatsApp from the CRM (only receiving/syncing)
|
||||
- WhatsApp profile pictures/avatars
|
||||
- Multiple WhatsApp numbers per client (one per client)
|
||||
- Group management features
|
||||
- Message notifications/push to mobile
|
||||
- WhatsApp Web client session restoration across Go server restarts (requires systemd/supervisor)
|
||||
|
||||
---
|
||||
|
||||
## Further Notes
|
||||
|
||||
- Phone numbers are stored normalized (with country code) as received from WhatsApp
|
||||
- Historical chat sync happens on first connection (all existing 1:1 chats become leads)
|
||||
- New messages sync in real-time while the Node.js process runs
|
||||
- If WhatsApp session expires/disconnects, client must reconnect via QR
|
||||
Reference in New Issue
Block a user