diff --git a/.gitignore b/.gitignore index 7235a64..29739b8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index c6060f9..fdd87c9 100644 --- a/AGENTS.md +++ b/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`) \ No newline at end of file +- 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`) \ No newline at end of file diff --git a/apps/go-crm/Dockerfile b/apps/go-crm/Dockerfile index cc7833c..3e8725b 100644 --- a/apps/go-crm/Dockerfile +++ b/apps/go-crm/Dockerfile @@ -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 && \ diff --git a/apps/go-crm/go.mod b/apps/go-crm/go.mod index d03912c..3440a86 100644 --- a/apps/go-crm/go.mod +++ b/apps/go-crm/go.mod @@ -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 diff --git a/apps/go-crm/go.sum b/apps/go-crm/go.sum index e61f4e4..391c7f2 100644 --- a/apps/go-crm/go.sum +++ b/apps/go-crm/go.sum @@ -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= diff --git a/apps/go-crm/internal/db/crud.go b/apps/go-crm/internal/db/crud.go index 0429188..e156836 100644 --- a/apps/go-crm/internal/db/crud.go +++ b/apps/go-crm/internal/db/crud.go @@ -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 } diff --git a/apps/go-crm/internal/db/crud_test.go b/apps/go-crm/internal/db/crud_test.go new file mode 100644 index 0000000..5771b7a --- /dev/null +++ b/apps/go-crm/internal/db/crud_test.go @@ -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) + } +} \ No newline at end of file diff --git a/apps/go-crm/internal/db/db.go b/apps/go-crm/internal/db/db.go index 4aa3935..b1c0fa4 100644 --- a/apps/go-crm/internal/db/db.go +++ b/apps/go-crm/internal/db/db.go @@ -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 } \ No newline at end of file diff --git a/apps/go-crm/internal/handlers/auth.go b/apps/go-crm/internal/handlers/auth.go index c7c2025..c73412b 100644 --- a/apps/go-crm/internal/handlers/auth.go +++ b/apps/go-crm/internal/handlers/auth.go @@ -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") diff --git a/apps/go-crm/internal/handlers/clients.go b/apps/go-crm/internal/handlers/clients.go index 969d613..aeface6 100644 --- a/apps/go-crm/internal/handlers/clients.go +++ b/apps/go-crm/internal/handlers/clients.go @@ -54,15 +54,21 @@ func ListClients(w http.ResponseWriter, r *http.Request) {
| Name | Phone | Actions | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Name | Phone | Actions | |||||||||||||||||
| ` + c.Name + ` | ` + c.Phone + ` | -` + c.Email + ` | +` + whatsappCell + ` |
View
diff --git a/apps/go-crm/internal/handlers/clients_test.go b/apps/go-crm/internal/handlers/clients_test.go
new file mode 100644
index 0000000..686bbfb
--- /dev/null
+++ b/apps/go-crm/internal/handlers/clients_test.go
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/apps/go-crm/internal/handlers/customers.go b/apps/go-crm/internal/handlers/customers.go
index 220da62..d4750f8 100644
--- a/apps/go-crm/internal/handlers/customers.go
+++ b/apps/go-crm/internal/handlers/customers.go
@@ -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)
-
- 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 clientID == 0 {
+ clientID, _ = strconv.ParseInt(r.FormValue("client_id"), 10, 64)
+ }
+
+ 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
diff --git a/apps/go-crm/internal/handlers/customers_test.go b/apps/go-crm/internal/handlers/customers_test.go
new file mode 100644
index 0000000..e317888
--- /dev/null
+++ b/apps/go-crm/internal/handlers/customers_test.go
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/apps/go-crm/internal/handlers/leads.go b/apps/go-crm/internal/handlers/leads.go
new file mode 100644
index 0000000..bde798f
--- /dev/null
+++ b/apps/go-crm/internal/handlers/leads.go
@@ -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(`
+
+
+ Leads+
+
+
+
Connect WhatsApp+Scan the QR code below with your WhatsApp app to connect + +Loading... + + +`)) +} + +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 + } + } + } +} + diff --git a/apps/go-crm/internal/handlers/leads_qr_test.go b/apps/go-crm/internal/handlers/leads_qr_test.go new file mode 100644 index 0000000..c3a0169 --- /dev/null +++ b/apps/go-crm/internal/handlers/leads_qr_test.go @@ -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) + } +} diff --git a/apps/go-crm/internal/handlers/setup.go b/apps/go-crm/internal/handlers/setup.go index 3d07b45..ebd0359 100644 --- a/apps/go-crm/internal/handlers/setup.go +++ b/apps/go-crm/internal/handlers/setup.go @@ -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 { diff --git a/apps/go-crm/internal/whatsapp/connector.go b/apps/go-crm/internal/whatsapp/connector.go new file mode 100644 index 0000000..f860feb --- /dev/null +++ b/apps/go-crm/internal/whatsapp/connector.go @@ -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{}) +} \ No newline at end of file diff --git a/apps/go-crm/internal/whatsapp/fake.go b/apps/go-crm/internal/whatsapp/fake.go new file mode 100644 index 0000000..8b13c5b --- /dev/null +++ b/apps/go-crm/internal/whatsapp/fake.go @@ -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{}) {} \ No newline at end of file diff --git a/apps/go-crm/internal/whatsapp/session_test.go b/apps/go-crm/internal/whatsapp/session_test.go new file mode 100644 index 0000000..2856a0a --- /dev/null +++ b/apps/go-crm/internal/whatsapp/session_test.go @@ -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") + } +} diff --git a/apps/go-crm/internal/whatsapp/whatsmeow.go b/apps/go-crm/internal/whatsapp/whatsmeow.go new file mode 100644 index 0000000..cb57c7e --- /dev/null +++ b/apps/go-crm/internal/whatsapp/whatsmeow.go @@ -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 +} diff --git a/apps/go-crm/main.go b/apps/go-crm/main.go index d6a4d5d..ed19d91 100644 --- a/apps/go-crm/main.go +++ b/apps/go-crm/main.go @@ -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) diff --git a/apps/whatsapp-sync/src/whatsapp-leads.js b/apps/whatsapp-sync/src/whatsapp-leads.js new file mode 100644 index 0000000..665cff1 --- /dev/null +++ b/apps/whatsapp-sync/src/whatsapp-leads.js @@ -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(); +} \ No newline at end of file diff --git a/apps/whatsapp-sync/tests/whatsapp-leads.test.js b/apps/whatsapp-sync/tests/whatsapp-leads.test.js new file mode 100644 index 0000000..f7a445e --- /dev/null +++ b/apps/whatsapp-sync/tests/whatsapp-leads.test.js @@ -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'); +} \ No newline at end of file diff --git a/docs/whatsapp-leads-prd.md b/docs/whatsapp-leads-prd.md new file mode 100644 index 0000000..68865e9 --- /dev/null +++ b/docs/whatsapp-leads-prd.md @@ -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 \ No newline at end of file | |||||||||||||||