Add leads handlers, WhatsApp integration, and test files

This commit is contained in:
2026-05-02 17:40:38 +00:00
parent 8833e38b80
commit 57e6e5a6fd
24 changed files with 2305 additions and 115 deletions

11
.gitignore vendored
View File

@@ -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
View File

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

View File

@@ -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 && \

View File

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

View File

@@ -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=

View File

@@ -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
}

View 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)
}
}

View File

@@ -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
}

View File

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

View File

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

View 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)
}
}

View File

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

View 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)
}
}

View 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 = '&#10003;';
// 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
}
}
}
}

View 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)
}
}

View File

@@ -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 {

View 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{})
}

View 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{}) {}

View 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")
}
}

View 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
}

View File

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

View 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();
}

View 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
View 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