diff --git a/.gitignore b/.gitignore index 29739b8..fca3b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,21 @@ data/*.db # Go build artifacts apps/go-crm/go-crm apps/go-crm/tmp/ + +# Sandbox / generated data +.opencode-sandbox/data/ +.opencode-sandbox/*.db + +# Binaries / tools +ngrok +apps/go-crm/go-crm +apps/go-crm/main +apps/go-crm/migrate + +# Temporary build dirs +tmp/ +apps/tmp/ + +# WhatsApp compiled binaries +apps/whatsme/whatsme +*.log diff --git a/.opencode-sandbox/data/go-crm.db b/.opencode-sandbox/data/go-crm.db deleted file mode 100644 index 26ff913..0000000 Binary files a/.opencode-sandbox/data/go-crm.db and /dev/null differ diff --git a/.opencode/skills/backend-driven-ui/SKILL.md b/.opencode/skills/backend-driven-ui/SKILL.md new file mode 100644 index 0000000..a8873ba --- /dev/null +++ b/.opencode/skills/backend-driven-ui/SKILL.md @@ -0,0 +1,36 @@ +--- +name: backend-driven-ui +description: Create distinctive, production-grade frontend interfaces using Golang and Hypermedia (HTMX/Templ). Use this skill to build reactive web components and pages that prioritize performance and maintainability without the complexity of modern JavaScript frameworks. +--- + +This skill guides the creation of high-end, "vibe-coded" interfaces using a **Golang-centric stack**. It avoids the maintenance burden of React/JS frameworks by leveraging **HTMX** and **Templ**, focusing on server-side logic that delivers rich, interactive client experiences. + +## Design Thinking & "Vibe Coding" + +Before coding, commit to a BOLD aesthetic direction that feels "hand-crafted" rather than "framework-default": +- **Hypermedia First**: Architecture relies on HTML fragments. Every interaction is a server-side transition, making the app feel incredibly fast and robust. +- **Tone**: Since we are avoiding the "React look," lean into distinctive styles: **High-Tech Industrial**, **Neo-Brutalist**, **Swiss International**, or **Terminal-Core**. +- **The "Vibe"**: Aim for the "vibe coding" energy—fast feedback loops, visible data flowing through the terminal, and interfaces that feel like specialized tools rather than generic SaaS dashboards. + +## Frontend Aesthetics Guidelines + +Focus on: +- **Typography**: Use characterful fonts that reflect the precision of Go. Lean towards high-quality Monospace (e.g., JetBrains Mono, Berkeley Graphics) for a "tooling" feel, or sophisticated Serifs paired with tight Grotesks for an editorial look. +- **Color & Theme**: Use high-contrast themes. Since this is built with **Tailwind CSS**, use specific color scales (e.g., Zinc, Slate, or custom olive/amber palettes) to avoid the "Tailwind default" blue. +- **Motion (CSS-Only)**: Prioritize CSS transitions and HTMX swapping animations (`htmx-settling`, `htmx-requesting`). Use staggered reveals and smooth opacity fades to mask the server-side round trip, making it feel "instant." +- **Interaction**: Use **HTMX attributes** (`hx-get`, `hx-post`, `hx-target`) to create "Active Search," "Infinite Scroll," and "Inline Editing" patterns that surprise users who expect a heavy JS bundle. + +## Technical Implementation (The Go Stack) + +- **Templ over HTML**: All components must be written in `.templ` files. This ensures type safety and allows you to pass Go structs directly into your UI components. +- **HTMX over JavaScript**: Replace `useState` and `useEffect` with `hx-trigger` and `hx-swap`. Maintain application state in the Go backend or the URL, not in a complex client-side store. +- **Tailwind for Styling**: Use utility classes to keep styles local to the HTML. Avoid external CSS files to maintain the "Locality of Behavior" principle. +- **Zero-JS Interactivity**: If client-side logic is strictly necessary (modals, toggles), use **Alpine.js** for its minimal footprint, keeping the code readable within the HTML. + +## What to Avoid +- **No Heavy Frameworks**: Strictly avoid React, Vue, or Angular. +- **No JS "Glue Code"**: Avoid writing custom vanilla JavaScript for things HTMX can handle natively. +- **No "AI Slop" Aesthetics**: Avoid the "Inter font + purple gradient + rounded card" combo. +- **No JSON APIs for the UI**: Do not build internal JSON endpoints for your own frontend; return HTML fragments instead. + +**IMPORTANT**: The beauty of this approach lies in its **mechanical elegance**. The code should be as clean and performant as the Go binary itself. Show that a "Vibe" can be achieved through clever hypermedia patterns and rock-solid backend engineering. diff --git a/AGENTS.md b/AGENTS.md index fdd87c9..2e9e3d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,206 +1,111 @@ -# AGENTS.md - Agent Coding Guidelines - -Monorepo: data engineering projects + WhatsApp CRM apps. - ---- +# AGENTS.md ## Project Structure ``` workspace/ ├── apps/ -│ ├── 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 +│ ├── go-crm/ # Go server (Chi), HTML templates, hot-reload via air +│ ├── whatsapp-crm/ # Next.js 14 App Router, sql.js (browser SQLite), Kanban +│ ├── whatsapp-sync/ # Node.js WhatsApp sync service +│ ├── whatsapp-reader/ # Standalone message reader +│ └── timesfm-forecast/ # Streamlit + TimesFM (Python/uv) +├── data/ # SQLite DBs: go-crm.db, whatsapp.db +├── data-engineering/ # Udacity DE portfolio (standalone, no unified build) └── skills/ # dbt reference templates ``` --- -## Build / Lint / Test Commands +## Developer Commands ### go-crm (primary) ```bash cd apps/go-crm +go run main.go # plain dev (no hot-reload) +air # hot-reload dev (uses .air.toml) +go build -o go-crm main.go # binary +./go-crm # run binary -go run main.go # dev -go build -o go-crm main.go # build binary -./go-crm # run binary +go mod tidy # clean go.mod/go.sum -go mod download # deps -go mod tidy # clean go.mod/go.sum - -# DB: /workspace/data/go-crm.db +# DB: /workspace/data/go-crm.db (hardcoded path — do not change) +# WhatsApp session: /workspace/data/whatsapp.db (hardcoded path) ``` -### whatsapp-crm (Next.js) +### whatsapp-crm ```bash cd apps/whatsapp-crm - -npm run dev # dev server 0.0.0.0:3000 -npm run build # production build -npm run start # production server - -npm run lint # ESLint - -npm run test # all Jest tests -npm run test:watch # watch mode -npm run test:coverage # coverage - -npm run test -- tests/kanban.test.ts -npm run test -- --testPathPattern=kanban +npm run dev # Next.js dev server 0.0.0.0:3000 +npm run build # production build +npm run lint # ESLint +npm run test # Jest (uses tests/setup.ts) ``` ### whatsapp-sync ```bash cd apps/whatsapp-sync - -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 -npm run test:coverage +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 (mocks qrcode-terminal + whatsapp-web.js) ``` ### whatsapp-reader ```bash cd apps/whatsapp-reader - -node index.js # main script -node sync.js # sync script +node index.js # main script +node sync.js # sync script # no test script ``` ### timesfm-forecast ```bash cd apps/timesfm-forecast +uv venv && source .venv/bin/activate +uv pip install -e . +ruff check . # lint (dev dep) +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 +# See apps/timesfm-forecast/README.md for CUDA/CPU wheel guidance ``` --- -## Code Style Guidelines +## Important Quirks -### TypeScript +### WhatsApp auth sessions +- `.wwebjs_auth/` directories store session state — do not commit these +- `whatsapp-sync` uses `better-sqlite3` for persistence +- `whatsapp-crm` browser-side uses `sql.js` (WebAssembly, no native deps) + +### go-crm auth +- `X-Internal-Secret` header required on requests (value: `internal-secret`) +- WhatsApp connector init URL: `http://localhost:8080` + +### Docker +```bash +docker compose up whatsapp-crm # CRM only +docker compose --profile sync up # CRM + sync +docker compose --profile test up # test runner +``` + +### go-crm hot-reload +- Uses `air` (not `go run`) — configured in `.air.toml` +- Build excludes `_test.go` and `vendor/`, `tmp/`, `assets/` + +### TypeScript config (whatsapp-crm, whatsapp-sync) - `strict: true` in tsconfig.json -- Explicit types for params + return values -- `interface` for objects, `type` for unions/aliases +- Path alias `@/*` maps to `src/*` -```typescript -interface Contact { - id: number - name: string - stage: Stage -} - -function getContactById(id: number): Contact | null -``` - -### Imports -- Path alias `@/*` (tsconfig.json) -- Order: external → internal → relative -- Group: React imports → other imports → types → components - -```typescript -import { useState, useMemo } from 'react' -import { Stage, STAGES, Contact } from '@/lib/types' -import { ContactCard } from '@/components/ContactCard' -``` - -### 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 -- Zod for input validation + react-hook-form -- Wrap async in try/catch -- Return proper HTTP status codes - -```typescript -export async function POST(request: Request) { - try { - const body = await request.json() - const validated = CreateContactInput.parse(body) - } catch (error) { - if (error instanceof ZodError) { - return Response.json({ error: error.errors }, { status: 400 }) - } - return Response.json({ error: 'Internal server error' }, { status: 500 }) - } -} -``` - -### Component Structure -- `'use client'` directive for client-side -- Destructure props, explicit typing -- Keep components focused, small -- Extract reusable logic to custom hooks - -```typescript -'use client' - -interface Props { - contacts: Contact[] - onContactClick: (contact: Contact) => void -} - -export default function ComponentName({ contacts, onContactClick }: Props) { - const [state, setState] = useState(false) - - return
{/* JSX */}
-} -``` - -### Database (sql.js) -- 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` -- `@testing-library/react` for component tests -- `@testing-library/user-event` for interactions -- AAA pattern: Arrange, Act, Assert - -```typescript -test('should update contact stage', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByText('Move to Next Stage')) - expect(onStageChange).toHaveBeenCalledWith(1, 'DECIDINDO') -}) -``` - -### CSS / Styling -- CSS modules or global CSS -- BEM-like: `block-element--modifier` -- Co-locate styles when possible - -### Git -- Meaningful commit messages -- Branch naming: `feature/description` or `fix/description` -- Run `npm run lint` + `npm run test` before commit +### Testing mocks +- `whatsapp-sync` has manual mocks for `qrcode-terminal` and `whatsapp-web.js` in `tests/__mocks__/` --- -## Important Notes +## What to Avoid -- 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 +- Do not use `go run main.go` for development — use `air` +- Do not commit `.wwebjs_auth/` or `.wwebjs_cache/` directories +- Do not change hardcoded DB paths in `main.go` +- The `data-engineering/` projects are standalone — no shared build or test commands \ No newline at end of file diff --git a/apps/go-crm/.air.toml b/apps/go-crm/.air.toml index 014d929..ee7d0c4 100644 --- a/apps/go-crm/.air.toml +++ b/apps/go-crm/.air.toml @@ -4,20 +4,20 @@ tmp_dir = "tmp" [build] bin = "./tmp/main" cmd = "go build -buildvcs=false -o ./tmp/main ." - delay = 1000 - exclude_dir = ["assets", "tmp", "vendor"] + delay = 2000 + exclude_dir = ["assets", "tmp", "vendor", "data", "node_modules", ".git"] exclude_file = [] - exclude_regex = ["_test.go"] - exclude_unchanged = false + exclude_regex = ["_test.go", "\\.db$", "\\.db-journal$", "\\.db-shm$", "\\.db-wal$"] + exclude_unchanged = true follow_symlink = false full_screen = false - include_dir = [] + include_dir = ["internal"] include_ext = ["go", "templ"] - kill_delay = "0s" + kill_delay = "2s" log = "build-errors.toml" send_exit = false send_user = false - stop_on_error = false + stop_on_error = true [log] main_only = false diff --git a/apps/go-crm/Makefile b/apps/go-crm/Makefile new file mode 100644 index 0000000..92f2e6f --- /dev/null +++ b/apps/go-crm/Makefile @@ -0,0 +1,24 @@ +.PHONY: test test-watch build clean dev migrate + +test: + go test ./... -v + +test-watch: + while true; do \ + inotifywait -q -e modify -e create -e delete -r internal/ 2>/dev/null || sleep 2; \ + go test ./... -v; \ + done + +build: + go build -o go-crm . + +clean: + rm -f go-crm + +dev: + ./scripts/dev.sh + +migrate: + go build -o migrate cmd/migrate/main.go + ./migrate + rm -f migrate diff --git a/apps/go-crm/README.md b/apps/go-crm/README.md new file mode 100644 index 0000000..bc1504c --- /dev/null +++ b/apps/go-crm/README.md @@ -0,0 +1,125 @@ +# go-crm (lightweight local dev) + +This service is intentionally small so junior Go developers can understand it end-to-end without Docker, hot reloads, or other heavy tooling. The goal: `go run main.go` with a single SQLite file in `.dev-data/`. + +## Local development (junior-friendly) + +1. Ensure Go 1.25+ is installed and `go` is in your `PATH`. +2. Run the helper script (no Docker): + ```bash + ./scripts/dev.sh + ``` +3. The server boots on [http://localhost:8080](http://localhost:8080). +4. The database and WhatsApp store live under `.dev-data/` (auto-created). + +There are no file watchers or `air` hot reloads—just the plain Go toolchain so newcomers can focus on code, not tooling. + +## Project layout (modern, clean layers) + +``` +go-crm/ +├── cmd/ # optional commands (e.g. migrations) +│ └── migrate/ # lightweight migration CLI (future) +├── config/ # templated configs (kept simple) +├── data/ # production data path (gitignored) +├── internal/ # HTTP handlers (usecases wired to HTTP) +│ ├── handlers/ +│ ├── middleware/ +│ └── templates/ +├── pkg/ # reusable application packages +│ ├── domain/ # entities + repository interfaces +│ ├── usecase/ # application services / business logic +│ └── repo/ # persistence adapters (SQLite, etc.) +├── scripts/ # helper scripts (dev runner, migrations) +├── main.go # application entrypoint (wire handlers → usecases) +├── go.mod +└── go.sum +``` + +## Recommended architecture / interfaces + +### `pkg/domain/lead.go` +```go +package domain + +// Lead is the core business entity. +type Lead struct { + LeadID int64 + ClientID int64 + Name string + PhoneRaw string + PhoneNormalized string + ServiceInterest string + Status string + PaymentStatus string +} + +// LeadRepository abstracts persistence. +type LeadRepository interface { + FindAll(ctx context.Context, clientID int64) ([]Lead, error) + FindByID(ctx context.Context, clientID, leadID int64) (*Lead, error) + Update(ctx context.Context, lead Lead) error +} +``` + +### `pkg/usecase/lead_service.go` +```go +package usecase + +import ( + "context" + "go-crm/pkg/domain" +) + +// LeadService orchestrates business logic. +type LeadService struct { + Repo domain.LeadRepository +} + +func (s *LeadService) ListAll(ctx context.Context, clientID int64) ([]domain.Lead, error) { + return s.Repo.FindAll(ctx, clientID) +} +``` + +### `pkg/repo/sqlite_lead_repo.go` +```go +package repo + +import ( + "context" + "database/sql" + "go-crm/pkg/domain" +) + +// SQLiteLeadRepository implements LeadRepository using sqlite. +type SQLiteLeadRepository struct { + DB *sql.DB +} + +func (r *SQLiteLeadRepository) FindAll(ctx context.Context, clientID int64) ([]domain.Lead, error) { + // query leads table, scan rows, return []domain.Lead +} +``` + +Handlers in `internal/handlers` should accept a clear service layer (`usecase.LeadService`), not raw DB logic. Keep controllers thin: parse request, call service, write HTML/JSON. + +## Migration strategy + +- Do not scan/clean the entire `leads` table on every startup (that was causing RAM blowups). +- Run the lightweight CLI with `make migrate` (which builds and runs the migration binary) whenever you need to refresh the schema or re-decode legacy Latin-1 data. To target non-default files, run the migration binary directly: + ```bash + go build -o migrate cmd/migrate/main.go && ./migrate --db /path/to/go-crm.db + ``` + +## Running tests + +```bash +go test ./... +``` + +## Summary + +- No Docker or `air` watchers—just `go run main.go`. +- Clean package boundaries (domain/usecase/repo) improve readability and testability. +- Scripts under `scripts/` orchestrate database setup or helper tasks. +- Use the architecture sketch above to guide future refactors. diff --git a/apps/go-crm/cmd/migrate/main.go b/apps/go-crm/cmd/migrate/main.go new file mode 100644 index 0000000..b40c259 --- /dev/null +++ b/apps/go-crm/cmd/migrate/main.go @@ -0,0 +1,28 @@ +// Command migrate applies schema changes and sanitizes legacy data. +package main + +import ( + "flag" + "fmt" + "log" + + "go-crm/config" + "go-crm/internal/db" +) + +func main() { + dbPath := flag.String("db", config.DatabasePath(), "path to SQLite database file") + flag.Parse() + + database, err := db.Init(*dbPath) + if err != nil { + log.Fatalf("migration failed: %v", err) + } + defer func() { + if err := database.Close(); err != nil { + log.Printf("warning: closing database: %v", err) + } + }() + + fmt.Println("Migration complete. Schema ensured and legacy encodings for lead_statuses & service_keywords fixed.") +} diff --git a/apps/go-crm/config/config.go b/apps/go-crm/config/config.go new file mode 100644 index 0000000..b078126 --- /dev/null +++ b/apps/go-crm/config/config.go @@ -0,0 +1,110 @@ +package config + +import ( + "os" + "path/filepath" +) + +const ( + defaultWorkspaceRoot = "/workspace" + defaultDataDirName = "data" + defaultDatabaseFile = "go-crm.db" + defaultWhatsAppStore = "whatsapp.db" + defaultHTTPEndpoint = "http://localhost:8080" + defaultInternalSecret = "internal-secret" +) + +// WorkspaceRoot returns the path to the shared workspace. CRM_WORKSPACE_PATH overrides it. +// When no override is provided, we try to detect the workspace by walking up from the current +// working directory and looking for the typical monorepo layout. If that fails, we fall back +// to `../..` relative to the current directory and ultimately to `/workspace`. +func WorkspaceRoot() string { + if v := os.Getenv("CRM_WORKSPACE_PATH"); v != "" { + return filepath.Clean(v) + } + if candidate := detectWorkspaceRoot(); candidate != "" { + return candidate + } + if fallback := fallbackWorkspaceRoot(); fallback != "" { + return fallback + } + return defaultWorkspaceRoot +} + +func detectWorkspaceRoot() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + for dir := wd; ; { + if hasDir(dir, "data") && hasDir(dir, "apps") { + return filepath.Clean(dir) + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +func fallbackWorkspaceRoot() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Clean(filepath.Join(wd, "..", "..")) +} + +func hasDir(dir, name string) bool { + info, err := os.Stat(filepath.Join(dir, name)) + return err == nil && info.IsDir() +} + +// DataDir returns the directory that stores SQLite files and related state. CRM_DATA_DIR overrides it. +// If a local ".dev-data" directory exists (for junior-friendly dev), it is preferred. +func DataDir() string { + if v := os.Getenv("CRM_DATA_DIR"); v != "" { + return filepath.Clean(v) + } + // prefer local .dev-data directory if present + if cwd, err := os.Getwd(); err == nil { + if info, err := os.Stat(filepath.Join(cwd, ".dev-data")); err == nil && info.IsDir() { + return filepath.Join(cwd, ".dev-data") + } + } + return filepath.Join(WorkspaceRoot(), defaultDataDirName) +} + +// DatabasePath returns the path to the Go CRM SQLite database. CRM_DATABASE_PATH overrides it. +func DatabasePath() string { + if v := os.Getenv("CRM_DATABASE_PATH"); v != "" { + return filepath.Clean(v) + } + return filepath.Join(DataDir(), defaultDatabaseFile) +} + +// WhatsAppStorePath returns the path where the Whatsmeow session DB is stored. CRM_WHATSAPP_STORE_PATH overrides it. +func WhatsAppStorePath() string { + if v := os.Getenv("CRM_WHATSAPP_STORE_PATH"); v != "" { + return filepath.Clean(v) + } + return filepath.Join(DataDir(), defaultWhatsAppStore) +} + +// HTTPServerEndpoint returns the HTTP endpoint used when WhatsApp events need to call back to the Go server. +func HTTPServerEndpoint() string { + if v := os.Getenv("CRM_HTTP_ENDPOINT"); v != "" { + return v + } + return defaultHTTPEndpoint +} + +// InternalSecret returns the internal secret required by the WhatsApp connector. +func InternalSecret() string { + if v := os.Getenv("CRM_INTERNAL_SECRET"); v != "" { + return v + } + return defaultInternalSecret +} diff --git a/apps/go-crm/data/go-crm.db.bak b/apps/go-crm/data/go-crm.db.bak deleted file mode 100644 index a8e422c..0000000 Binary files a/apps/go-crm/data/go-crm.db.bak and /dev/null differ diff --git a/apps/go-crm/docker-compose.yml b/apps/go-crm/docker-compose.yml index 2ebf3b0..8568458 100644 --- a/apps/go-crm/docker-compose.yml +++ b/apps/go-crm/docker-compose.yml @@ -12,6 +12,10 @@ services: working_dir: /workspace/apps/go-crm command: air restart: unless-stopped + deploy: + resources: + limits: + memory: 2G networks: crm-network: diff --git a/apps/go-crm/go.mod b/apps/go-crm/go.mod index 3440a86..a870e3c 100644 --- a/apps/go-crm/go.mod +++ b/apps/go-crm/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-chi/cors v1.2.2 go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7 golang.org/x/crypto v0.50.0 + golang.org/x/text v0.37.0 ) require ( @@ -32,7 +33,6 @@ require ( 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 diff --git a/apps/go-crm/go.sum b/apps/go-crm/go.sum index 391c7f2..242321a 100644 --- a/apps/go-crm/go.sum +++ b/apps/go-crm/go.sum @@ -72,8 +72,8 @@ 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= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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= diff --git a/apps/go-crm/internal/handlers/auth.go b/apps/go-crm/internal/handlers/auth.go index c73412b..7bf88c2 100644 --- a/apps/go-crm/internal/handlers/auth.go +++ b/apps/go-crm/internal/handlers/auth.go @@ -9,8 +9,11 @@ import ( "golang.org/x/crypto/bcrypt" ) -func SignupPage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") +// package-level globals kept for existing tests; App methods use a.DB directly. +var DB *sql.DB + +func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -30,7 +33,7 @@ func SignupPage(w http.ResponseWriter, r *http.Request) { `)) } -func Signup(w http.ResponseWriter, r *http.Request) { +func (a *App) Signup(w http.ResponseWriter, r *http.Request) { r.ParseForm() email := r.FormValue("email") name := r.FormValue("name") @@ -47,21 +50,17 @@ func Signup(w http.ResponseWriter, r *http.Request) { return } - account := struct { - Email string - Name string - Password string - CreatedAt int64 - }{ - Email: email, - Name: name, - Password: string(hashedPassword), - CreatedAt: time.Now().Unix(), + // Use a transaction so account + client + session are atomic. + tx, err := a.DB.Begin() + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return } + defer tx.Rollback() - _, err = DB.Exec( + _, err = tx.Exec( "INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)", - account.Email, account.Name, account.Password, account.CreatedAt, + email, name, string(hashedPassword), time.Now().Unix(), ) if err != nil { http.Error(w, "Email already exists", http.StatusBadRequest) @@ -69,45 +68,40 @@ func Signup(w http.ResponseWriter, r *http.Request) { } var accountID int64 - err = DB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", email).Scan(&accountID) - if err != nil { + if err = tx.QueryRow("SELECT account_id FROM accounts WHERE email = ?", email).Scan(&accountID); err != nil { http.Error(w, "Failed to create account", http.StatusInternalServerError) return } - client := struct { - AccountID int64 - Name string - CreatedAt int64 - }{ - AccountID: accountID, - Name: name, - CreatedAt: time.Now().Unix(), - } - _, err = DB.Exec( + if _, err = tx.Exec( "INSERT INTO clients (account_id, name, created_at) VALUES (?, ?, ?)", - client.AccountID, client.Name, client.CreatedAt, - ) - if err != nil { + accountID, name, time.Now().Unix(), + ); err != nil { http.Error(w, "Failed to create client", http.StatusInternalServerError) return } sessionID := generateSessionID() expires := time.Now().Add(24 * time.Hour).Unix() - - _, err = DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires) - if err != nil { + if _, err = tx.Exec( + "INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", + sessionID, accountID, expires, + ); err != nil { http.Error(w, "Failed to create session", http.StatusInternalServerError) return } + if err = tx.Commit(); err != nil { + http.Error(w, "Failed to commit signup", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/"}) http.Redirect(w, r, "/", http.StatusFound) } -func LoginPage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") +func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -126,16 +120,14 @@ func LoginPage(w http.ResponseWriter, r *http.Request) { `)) } -var DB *sql.DB - -func Login(w http.ResponseWriter, r *http.Request) { +func (a *App) Login(w http.ResponseWriter, r *http.Request) { r.ParseForm() email := r.FormValue("email") password := r.FormValue("password") var accountID int64 var hashedPassword string - err := DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword) + err := a.DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword) if err != nil { http.Error(w, "Invalid credentials", http.StatusUnauthorized) return @@ -149,7 +141,7 @@ func Login(w http.ResponseWriter, r *http.Request) { sessionID := generateSessionID() expires := time.Now().Add(24 * time.Hour).Unix() - _, err = DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires) + _, err = a.DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires) if err != nil { http.Error(w, "Failed to create session", http.StatusInternalServerError) return @@ -159,16 +151,152 @@ func Login(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } -func Logout(w http.ResponseWriter, r *http.Request) { +func (a *App) Logout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err == nil { - DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value) + a.DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value) } - http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1}) http.Redirect(w, r, "/auth/login", http.StatusFound) } +func (a *App) AccountPage(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + var name, email string + err := a.DB.QueryRow("SELECT name, email FROM accounts WHERE account_id = ?", accountID).Scan(&name, &email) + if err != nil { + http.Error(w, "Account not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`Account

Account Settings

+

Name:

+

Email:

+

New Password:

+ +
+ Back to Dashboard`)) +} + +func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + r.ParseForm() + name := r.FormValue("name") + password := r.FormValue("password") + + if name != "" { + a.DB.Exec("UPDATE accounts SET name = ? WHERE account_id = ?", name, accountID) + } + + if password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err == nil { + a.DB.Exec("UPDATE accounts SET password = ? WHERE account_id = ?", string(hashedPassword), accountID) + } + } + + http.Redirect(w, r, "/auth/account", http.StatusFound) +} + +// --- session helpers on App -------------------------------------------------- + +func (a *App) getSession(r *http.Request) (int64, error) { + cookie, err := r.Cookie("session") + if err != nil { + return 0, err + } + var accountID int64 + err = a.DB.QueryRow( + "SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?", + cookie.Value, time.Now().Unix(), + ).Scan(&accountID) + return accountID, err +} + +func (a *App) GetAccountID(r *http.Request) (int64, error) { + return a.getSession(r) +} + +func (a *App) requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) { + accountID, err := a.getSession(r) + if err != nil { + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"error":"unauthorized"}`)) + } else { + http.Redirect(w, r, "/auth/login", http.StatusFound) + } + return 0, false + } + return accountID, true +} + +func (a *App) clientIDForAccount(accountID int64) int64 { + var clientID int64 + a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID) + return clientID +} + +// --- package-level shims kept for existing tests that set the global DB ------ + +func GetAccountID(r *http.Request) (int64, error) { + return getSession(r) +} + +func getSession(r *http.Request) (int64, error) { + cookie, err := r.Cookie("session") + if err != nil { + return 0, err + } + var accountID int64 + err = DB.QueryRow( + "SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?", + cookie.Value, time.Now().Unix(), + ).Scan(&accountID) + return accountID, err +} + +func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) { + accountID, err := getSession(r) + if err != nil { + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"error":"unauthorized"}`)) + } else { + http.Redirect(w, r, "/auth/login", http.StatusFound) + } + return 0, false + } + return accountID, true +} + +func clientIDForAccount(accountID int64) int64 { + var clientID int64 + DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID) + return clientID +} + +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") +} + +// --- session ID generation --------------------------------------------------- + func generateSessionID() string { return time.Now().Format("20060102150405") + "-" + randomString(32) } @@ -182,88 +310,41 @@ func randomString(n int) string { return string(b) } -func getSession(r *http.Request) (int64, error) { - cookie, err := r.Cookie("session") - if err != nil { - return 0, err - } +// SignupPage, Signup, LoginPage, Login, Logout, AccountPage, UpdateAccount +// are also kept as package-level functions for backward compat with test +// setups that call handlers.Signup directly. They delegate to the globals. - var accountID int64 - err = DB.QueryRow("SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?", cookie.Value, time.Now().Unix()).Scan(&accountID) - return accountID, err +func SignupPage(w http.ResponseWriter, r *http.Request) { + a := &App{DB: DB, WAConnector: WAConnector} + a.SignupPage(w, r) } -func GetAccountID(r *http.Request) (int64, error) { - return getSession(r) +func Signup(w http.ResponseWriter, r *http.Request) { + a := &App{DB: DB, WAConnector: WAConnector} + a.Signup(w, r) } -func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) { - accountID, err := getSession(r) - if err != nil { - 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 LoginPage(w http.ResponseWriter, r *http.Request) { + a := &App{DB: DB, WAConnector: WAConnector} + a.LoginPage(w, r) } -func isAPIRequest(r *http.Request) bool { - accept := r.Header.Get("Accept") - return accept == "application/json" || r.URL.Path == "/leads/qr" +func Login(w http.ResponseWriter, r *http.Request) { + a := &App{DB: DB, WAConnector: WAConnector} + a.Login(w, r) } -func SetupAuthHandlers(db *sql.DB) { - DB = db - chi.RegisterMethod("GET") +func Logout(w http.ResponseWriter, r *http.Request) { + a := &App{DB: DB, WAConnector: WAConnector} + a.Logout(w, r) } func AccountPage(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) - if !ok { - return - } - - var name, email string - err := DB.QueryRow("SELECT name, email FROM accounts WHERE account_id = ?", accountID).Scan(&name, &email) - if err != nil { - http.Error(w, "Account not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`Account

Account Settings

-

Name:

-

Email:

-

New Password:

- -
- Back to Dashboard`)) + a := &App{DB: DB, WAConnector: WAConnector} + a.AccountPage(w, r) } func UpdateAccount(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) - if !ok { - return - } - - r.ParseForm() - name := r.FormValue("name") - password := r.FormValue("password") - - if name != "" { - DB.Exec("UPDATE accounts SET name = ? WHERE account_id = ?", name, accountID) - } - - if password != "" { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err == nil { - DB.Exec("UPDATE accounts SET password = ? WHERE account_id = ?", string(hashedPassword), accountID) - } - } - - http.Redirect(w, r, "/auth/account", http.StatusFound) -} \ No newline at end of file + a := &App{DB: DB, WAConnector: WAConnector} + a.UpdateAccount(w, r) +} diff --git a/apps/go-crm/internal/handlers/clients.go b/apps/go-crm/internal/handlers/clients.go index aeface6..8a7b501 100644 --- a/apps/go-crm/internal/handlers/clients.go +++ b/apps/go-crm/internal/handlers/clients.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListClients(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListClients(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -22,13 +22,13 @@ func ListClients(w http.ResponseWriter, r *http.Request) { limit = 20 } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -60,8 +60,14 @@ func ListClients(w http.ResponseWriter, r *http.Request) { `)) for _, c := range clients { var whatsappCell string - if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" { - whatsappCell = c.WhatsAppNumber + connected := false + if c.WhatsAppNumber != "" && a.WAConnector != nil { + connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID) + } + if connected { + whatsappCell = c.WhatsAppNumber + ` ` + } else if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" { + whatsappCell = c.WhatsAppNumber + ` Reconnect` } else { whatsappCell = `Connect` } @@ -88,11 +94,13 @@ func ListClients(w http.ResponseWriter, r *http.Request) { `)) } - w.Write([]byte(``)) + w.Write([]byte(` +

Back to Home

+`)) } -func CreateClient(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -104,33 +112,57 @@ func CreateClient(w http.ResponseWriter, r *http.Request) { Phone: r.FormValue("phone"), Email: r.FormValue("email"), Address: r.FormValue("address"), - Notes: r.FormValue("notes"), + Notes: r.FormValue("notes"), CreatedAt: time.Now().Unix(), } - if err := client.Create(DB); err != nil { + if err := client.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Refresh", "true") } -func ViewClient(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ViewClient(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - client, err := db.GetClientByID(DB, accountID, id) + client, err := db.GetClientByID(a.DB, accountID, id) if err != nil { http.Error(w, "Client not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + var whatsappSection string + if client.WhatsAppNumber != "" { + connected := false + if a.WAConnector != nil { + connected, _ = a.WAConnector.IsConnected(r.Context(), client.ClientID) + } + status := "Not connected" + style := "color:#999" + if connected { + status = "Connected" + style = "color:#28a745" + } else if client.WhatsAppConnected == 1 { + status = "Disconnected (was connected)" + style = "color:#dc3545" + } + whatsappSection = ` +

WhatsApp: ` + client.WhatsAppNumber + ` (` + status + `)

+

Reconnect WhatsApp

` + } else { + whatsappSection = ` +

WhatsApp: Not configured Connect

` + } + w.Write([]byte(` @@ -140,13 +172,13 @@ func ViewClient(w http.ResponseWriter, r *http.Request) {

Phone: ` + client.Phone + `

Email: ` + client.Email + `

Address: ` + client.Address + `

-

Notes: ` + client.Notes + `

+

Notes: ` + client.Notes + `

` + whatsappSection + ` Back `)) } -func UpdateClient(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) UpdateClient(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -156,14 +188,14 @@ func UpdateClient(w http.ResponseWriter, r *http.Request) { client := db.Client{ ClientID: id, AccountID: accountID, - Name: r.FormValue("name"), - Phone: r.FormValue("phone"), - Email: r.FormValue("email"), - Address: r.FormValue("address"), - Notes: r.FormValue("notes"), + Name: r.FormValue("name"), + Phone: r.FormValue("phone"), + Email: r.FormValue("email"), + Address: r.FormValue("address"), + Notes: r.FormValue("notes"), } - if err := client.Update(DB); err != nil { + if err := client.Update(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -171,19 +203,37 @@ func UpdateClient(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteClient(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) DeleteClient(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) client := &db.Client{ClientID: id, AccountID: accountID} - if err := client.Delete(DB); err != nil { + if err := client.Delete(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte("OK")) -} \ No newline at end of file +} + +// --- package-level shims kept for existing tests --- + +func ListClients(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListClients(w, r) +} +func CreateClient(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateClient(w, r) +} +func ViewClient(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewClient(w, r) +} +func UpdateClient(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateClient(w, r) +} +func DeleteClient(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteClient(w, r) +} diff --git a/apps/go-crm/internal/handlers/clients_test.go b/apps/go-crm/internal/handlers/clients_test.go index 686bbfb..85c2078 100644 --- a/apps/go-crm/internal/handlers/clients_test.go +++ b/apps/go-crm/internal/handlers/clients_test.go @@ -210,4 +210,128 @@ func TestLeadsQRPolling(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } -} \ No newline at end of file +} + +func TestListClientsHasBackToHomeLink(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-nav" + 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) + } + + 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, `href="/"`) { + t.Error("expected clients page to have Back to Home link") + } +} + +func TestListClientsShowsConnectButtonForUnconnectedClients(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) + } + + var clientID int64 + testDB.QueryRow( + "INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id", + accountID, "Unconnected Client", "+5521987654321", time.Now().Unix(), + ).Scan(&clientID) + + sessionID := "test-session-connect-btn" + 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) + } + + 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, "leads/connect?client_id=") { + t.Error("expected clients page to show Connect link for unconnected clients") + } +} diff --git a/apps/go-crm/internal/handlers/dashboard.go b/apps/go-crm/internal/handlers/dashboard.go new file mode 100644 index 0000000..1afd527 --- /dev/null +++ b/apps/go-crm/internal/handlers/dashboard.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "fmt" + "net/http" +) + +func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) { + accountID, err := a.getSession(r) + if err != nil { + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + + var clientID int64 + a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID) + var reviewCount int + if clientID > 0 { + a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND needs_review = 1", clientID).Scan(&reviewCount) + } + + waStatus := "disconnected" + waStyle := "color:#dc3545" + connectLink := "" + if a.WAConnector != nil { + if connected, _ := a.WAConnector.IsConnected(r.Context(), clientID); connected { + waStatus = "connected" + waStyle = "color:#28a745" + } else if clientID > 0 { + // Fallback: check DB column when in-memory state not available. + var dbConnected int + a.DB.QueryRow("SELECT whatsapp_connected FROM clients WHERE client_id = ?", clientID).Scan(&dbConnected) + if dbConnected == 1 { + waStatus = "connected" + waStyle = "color:#28a745" + } else { + connectLink = fmt.Sprintf(` Connect`, clientID) + } + } else { + connectLink = ` Create client to connect` + } + } + + badge := "" + if reviewCount > 0 { + badge = fmt.Sprintf(` %d`, reviewCount) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + Dashboard + + + +

CRM Dashboard

+

WhatsApp: %s%s

+ +`, waStyle, waStatus, connectLink, badge) +} + +// --- package-level shim kept for existing tests --- + +func Dashboard(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).Dashboard(w, r) +} diff --git a/apps/go-crm/internal/handlers/dashboard_test.go b/apps/go-crm/internal/handlers/dashboard_test.go new file mode 100644 index 0000000..9c2fbe5 --- /dev/null +++ b/apps/go-crm/internal/handlers/dashboard_test.go @@ -0,0 +1,291 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "go-crm/internal/db" + "go-crm/internal/whatsapp" + + "golang.org/x/crypto/bcrypt" +) + +func TestDashboardHidesConnectLinkWhenDBConnected(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() + + // FakeConnector NOT marked connected — only DB column is set. + 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) + } + + var clientID int64 + testDB.QueryRow( + "INSERT INTO clients (account_id, name, phone, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?) RETURNING client_id", + accountID, "DB Connected Client", "+5521987654321", 1, time.Now().Unix(), + ).Scan(&clientID) + + sessionID := "test-session-db-connected" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + w := httptest.NewRecorder() + + Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "connected") { + t.Error("expected dashboard to show 'connected' status from DB column") + } + + if strings.Contains(body, "/leads/connect") { + t.Error("expected dashboard to NOT show Connect link when DB column indicates connected") + } +} + +func TestDashboardShowsConnectLinkWhenDisconnected(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-dashboard" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + w := httptest.NewRecorder() + + Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "disconnected") { + t.Error("expected dashboard to show 'disconnected' status") + } + + if !strings.Contains(body, "/clients") { + t.Error("expected dashboard to show link to create client when no client exists") + } +} + +func TestDashboardShowsConnectedStatus(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() + + fakeWA := whatsapp.NewFakeConnector() + fakeWA.MarkConnected(1) + + DB = testDB + WAConnector = fakeWA + + 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) + } + + _, err = testDB.Exec( + "INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?)", + accountID, "Test Client", "+5521987654321", time.Now().Unix(), + ) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + sessionID := "test-session-connected" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + w := httptest.NewRecorder() + + Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "connected") { + t.Error("expected dashboard to show 'connected' status") + } + + if strings.Contains(body, "/leads/connect") { + t.Error("expected dashboard to NOT show Connect link when WhatsApp is connected") + } +} + +func TestDashboardShowsConnectLinkWithClientID(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) + } + + 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) + + sessionID := "test-session-disconnected-client" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + w := httptest.NewRecorder() + + Dashboard(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "disconnected") { + t.Error("expected dashboard to show 'disconnected' status") + } + + if !strings.Contains(body, "/leads/connect?client_id=") { + t.Error("expected dashboard to show Connect link with client_id when client exists") + } +} diff --git a/apps/go-crm/internal/handlers/leads.go b/apps/go-crm/internal/handlers/leads.go index bde798f..87023f0 100644 --- a/apps/go-crm/internal/handlers/leads.go +++ b/apps/go-crm/internal/handlers/leads.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "strings" "time" "go-crm/internal/db" @@ -14,8 +15,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListLeads(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListLeads(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -27,7 +28,7 @@ func ListLeads(w http.ResponseWriter, r *http.Request) { 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 = ?)" + query := "SELECT cu.customer_id, cu.client_id, cu.name, cu.phone, cu.birth_date, cu.instagram, cu.created_at, COALESCE(cl.whatsapp_connected,0), COALESCE(cl.whatsapp_number,'') FROM customers cu JOIN clients cl ON cu.client_id = cl.client_id WHERE cl.account_id = ?" args := []interface{}{accountID} if search != "" { @@ -36,10 +37,10 @@ func ListLeads(w http.ResponseWriter, r *http.Request) { args = append(args, searchPat, searchPat) } - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + query += " ORDER BY cu.created_at DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) - rows, err := DB.Query(query, args...) + rows, err := a.DB.Query(query, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -49,13 +50,13 @@ func ListLeads(w http.ResponseWriter, r *http.Request) { 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 { + if err := rows.Scan(&c.CustomerID, &c.ClientID, &c.Name, &c.Phone, &c.BirthDate, &c.Instagram, &c.CreatedAt, &c.WhatsAppConnected, &c.WhatsAppNumber); err != nil { continue } customers = append(customers, c) } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -80,17 +81,24 @@ func ListLeads(w http.ResponseWriter, r *http.Request) { - + `)) for _, c := range customers { + waStatus := "Not connected" + waStyle := "color: #999;" + if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" { + waStatus = c.WhatsAppNumber + waStyle = "color: #28a745; font-weight: 600;" + } w.Write([]byte(` + -
NamePhoneBirth DateInstagramActions
NamePhoneBirth DateInstagramWhatsAppActions
` + c.Name + ` ` + c.Phone + ` ` + c.BirthDate + ` ` + c.Instagram + `` + waStatus + `
@@ -99,7 +107,7 @@ func ListLeads(w http.ResponseWriter, r *http.Request) {
+ @@ -112,12 +120,12 @@ func ListLeads(w http.ResponseWriter, r *http.Request) { } w.Write([]byte(`
-

Back to Clients

+

Back to Home | Back to Clients

`)) } -func UpdateLead(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) UpdateLead(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -125,7 +133,7 @@ func UpdateLead(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) r.ParseForm() - _, err := DB.Exec( + _, err := a.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, ) @@ -134,18 +142,18 @@ func UpdateLead(w http.ResponseWriter, r *http.Request) { return } - ListLeads(w, r) + a.ListLeads(w, r) } -func DeleteLead(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) DeleteLead(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - _, err := DB.Exec( + _, err := a.DB.Exec( "DELETE FROM customers WHERE customer_id = ? AND client_id IN (SELECT client_id FROM clients WHERE account_id = ?)", id, accountID, ) @@ -154,24 +162,24 @@ func DeleteLead(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte("OK")) } -func LeadsConnectPage(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) LeadsConnectPage(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64) - client, err := db.GetClientByID(DB, accountID, clientID) + client, err := db.GetClientByID(a.DB, accountID, clientID) if err != nil { http.Error(w, "Client not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -196,7 +204,6 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) { .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 = ''; @@ -209,12 +216,29 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) { document.getElementById('status').textContent = 'Scan with WhatsApp'; setTimeout(pollQR, 5000); } else if (data.status === 'ready') { - document.getElementById('status').textContent = 'Connected!'; + document.getElementById('status').textContent = 'Connected! Verifying phone...'; document.getElementById('qrcode').innerHTML = '✓'; - // Stop polling — connected. + setTimeout(() => { + fetch('/leads/verify/` + strconv.FormatInt(client.ClientID, 10) + `') + .then(r => r.json()) + .then(v => { + var msg = 'WhatsApp: ' + (v.wa_phone || 'unknown'); + if (v.match === 'yes') { + document.getElementById('status').textContent = msg + ' — matches ' + (v.client_phone || '') + ' ✓'; + document.getElementById('status').style.color = '#28a745'; + } else if (v.match === 'no') { + document.getElementById('status').textContent = msg + ' — does NOT match client phone ' + (v.client_phone || '') + ' ⚠'; + document.getElementById('status').style.color = '#dc3545'; + } else { + document.getElementById('status').textContent = msg + ' (client phone unknown — verify manually)'; + } + }) + .catch(() => { + document.getElementById('status').textContent = 'Connected! (could not verify phone)'; + }); + }, 1500); } 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; @@ -228,7 +252,7 @@ func LeadsConnectPage(w http.ResponseWriter, r *http.Request) { } pollQR(); -

Back to Clients

+

Back to Home | Back to Clients

`)) } @@ -237,46 +261,44 @@ func jsonEscape(s string) string { return string(b) } -func LeadsQR(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) LeadsQR(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } clientID, _ := strconv.ParseInt(r.URL.Query().Get("client_id"), 10, 64) - client, err := db.GetClientByID(DB, accountID, clientID) + client, err := db.GetClientByID(a.DB, accountID, clientID) if err != nil { - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"status":"error","error":"client not found"}`)) return } - if WAConnector == nil { - w.Header().Set("Content-Type", "application/json") + if a.WAConnector == nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte(`{"status":"error","error":"WhatsApp not configured - contact admin"}`)) return } - connected, err := WAConnector.IsConnected(r.Context(), clientID) + connected, err := a.WAConnector.IsConnected(r.Context(), clientID) if err != nil { - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", "application/json; charset=utf-8") 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.Header().Set("Content-Type", "application/json; charset=utf-8") w.Write([]byte(`{"client_id":` + strconv.FormatInt(client.ClientID, 10) + `,"status":"ready"}`)) return } - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Type", "application/json; charset=utf-8") 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) + qrChan, err := a.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()) + `}`)) @@ -311,3 +333,52 @@ func LeadsQR(w http.ResponseWriter, r *http.Request) { } } +func (a *App) VerifyLead(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID, _ := strconv.ParseInt(chi.URLParam(r, "client_id"), 10, 64) + client, err := db.GetClientByID(a.DB, accountID, clientID) + if err != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"status":"error","error":"client not found"}`)) + return + } + + match := "unknown" + if client.WhatsAppNumber != "" && client.Phone != "" { + cleanWA := strings.TrimPrefix(client.WhatsAppNumber, "+") + cleanClient := strings.TrimPrefix(client.Phone, "+") + if cleanWA == cleanClient { + match = "yes" + } else { + match = "no" + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"status":"ok","wa_phone":"` + jsonEscape(client.WhatsAppNumber) + `","client_phone":"` + jsonEscape(client.Phone) + `","match":"` + match + `","client_name":"` + jsonEscape(client.Name) + `"}`)) +} + +// --- package-level shims kept for existing tests --- + +func ListLeads(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListLeads(w, r) +} +func UpdateLead(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateLead(w, r) +} +func DeleteLead(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteLead(w, r) +} +func LeadsConnectPage(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).LeadsConnectPage(w, r) +} +func LeadsQR(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).LeadsQR(w, r) +} +func VerifyLead(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).VerifyLead(w, r) +} diff --git a/apps/go-crm/internal/handlers/payments.go b/apps/go-crm/internal/handlers/payments.go index a652a7d..035538a 100644 --- a/apps/go-crm/internal/handlers/payments.go +++ b/apps/go-crm/internal/handlers/payments.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListPayments(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListPayments(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -23,19 +23,19 @@ func ListPayments(w http.ResponseWriter, r *http.Request) { limit = 20 } - payments, err := db.ListPayments(DB, clientID, limit, offset) + payments, err := db.ListPayments(a.DB, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - customers, err := db.ListCustomers(DB, accountID, clientID, limit, offset) + customers, err := db.ListCustomers(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -53,7 +53,7 @@ func ListPayments(w http.ResponseWriter, r *http.Request) { customerNames[cu.CustomerID] = cu.Name } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Payments

`)) for _, p := range payments { paid := "No" @@ -73,8 +73,8 @@ func ListPayments(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`
ClientCustomerAmountPaidDateMethodActions
`)) } -func CreatePayment(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) CreatePayment(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -97,7 +97,7 @@ func CreatePayment(w http.ResponseWriter, r *http.Request) { CreatedAt: time.Now().Unix(), } - if err := payment.Create(DB); err != nil { + if err := payment.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -105,8 +105,8 @@ func CreatePayment(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewPayment(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewPayment(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -114,19 +114,19 @@ func ViewPayment(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) var p db.Payment var hasPaid int - err := DB.QueryRow("SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments WHERE payment_id = ?", id).Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt) + err := a.DB.QueryRow("SELECT payment_id, client_id, customer_id, schedule_id, has_paid, amount, payment_date, payment_method, created_at FROM payments WHERE payment_id = ?", id).Scan(&p.PaymentID, &p.ClientID, &p.CustomerID, &p.ScheduleID, &hasPaid, &p.Amount, &p.PaymentDate, &p.PaymentMethod, &p.CreatedAt) if err != nil { http.Error(w, "Payment not found", http.StatusNotFound) return } p.HasPaid = hasPaid == 1 - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Payment

Amount: ` + strconv.FormatFloat(p.Amount, 'f', 2, 64) + `

Paid: ` + strconv.FormatBool(p.HasPaid) + `

Method: ` + p.PaymentMethod + `

Back`)) } -func UpdatePayment(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdatePayment(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -137,7 +137,7 @@ func UpdatePayment(w http.ResponseWriter, r *http.Request) { customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64) amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) hasPaid := r.FormValue("has_paid") == "on" - _, err := DB.Exec("UPDATE payments SET client_id=?, customer_id=?, has_paid=?, amount=?, payment_date=?, payment_method=? WHERE payment_id=?", clientID, customerID, hasPaid, amount, r.FormValue("payment_date"), r.FormValue("payment_method"), id) + _, err := a.DB.Exec("UPDATE payments SET client_id=?, customer_id=?, has_paid=?, amount=?, payment_date=?, payment_method=? WHERE payment_id=?", clientID, customerID, hasPaid, amount, r.FormValue("payment_date"), r.FormValue("payment_method"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -145,19 +145,30 @@ func UpdatePayment(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeletePayment(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeletePayment(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) - DB.Exec("DELETE FROM payments WHERE payment_id = ?", id) + a.DB.Exec("DELETE FROM payments WHERE payment_id = ?", id) } -func boolToStr(b bool) string { - if b { - return "Yes" - } - return "No" -} \ No newline at end of file +// --- package-level shims kept for existing tests --- + +func ListPayments(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListPayments(w, r) +} +func CreatePayment(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreatePayment(w, r) +} +func ViewPayment(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewPayment(w, r) +} +func UpdatePayment(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdatePayment(w, r) +} +func DeletePayment(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeletePayment(w, r) +} diff --git a/apps/go-crm/internal/handlers/questions.go b/apps/go-crm/internal/handlers/questions.go index 309367d..af6b984 100644 --- a/apps/go-crm/internal/handlers/questions.go +++ b/apps/go-crm/internal/handlers/questions.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListQuestions(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListQuestions(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -23,19 +23,19 @@ func ListQuestions(w http.ResponseWriter, r *http.Request) { limit = 20 } - questions, err := db.ListQuestions(DB, clientID, limit, offset) + questions, err := db.ListQuestions(a.DB, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - customers, err := db.ListCustomers(DB, accountID, clientID, limit, offset) + customers, err := db.ListCustomers(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -53,7 +53,7 @@ func ListQuestions(w http.ResponseWriter, r *http.Request) { customerNames[cu.CustomerID] = cu.Name } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Questions

`)) for _, q := range questions { clientName := clientNames[q.ClientID] @@ -69,8 +69,8 @@ func ListQuestions(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`
ClientCustomerQuestionStatusActions
`)) } -func CreateQuestion(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) CreateQuestion(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -81,13 +81,13 @@ func CreateQuestion(w http.ResponseWriter, r *http.Request) { question := db.Question{ ClientID: clientID, CustomerID: customerID, - Question: r.FormValue("question"), - Timestamp: time.Now().Unix(), - Status: r.FormValue("status"), - CreatedAt: time.Now().Unix(), + Question: r.FormValue("question"), + Timestamp: time.Now().Unix(), + Status: r.FormValue("status"), + CreatedAt: time.Now().Unix(), } - if err := question.Create(DB); err != nil { + if err := question.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -95,26 +95,26 @@ func CreateQuestion(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewQuestion(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewQuestion(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) var q db.Question - err := DB.QueryRow("SELECT question_id, client_id, customer_id, question, timestamp, status, created_at FROM questions WHERE question_id = ?", id).Scan(&q.QuestionID, &q.ClientID, &q.CustomerID, &q.Question, &q.Timestamp, &q.Status, &q.CreatedAt) + err := a.DB.QueryRow("SELECT question_id, client_id, customer_id, question, timestamp, status, created_at FROM questions WHERE question_id = ?", id).Scan(&q.QuestionID, &q.ClientID, &q.CustomerID, &q.Question, &q.Timestamp, &q.Status, &q.CreatedAt) if err != nil { http.Error(w, "Question not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Question

Client ID: ` + strconv.FormatInt(q.ClientID, 10) + `

Customer ID: ` + strconv.FormatInt(q.CustomerID, 10) + `

Question: ` + q.Question + `

Status: ` + q.Status + `

Back`)) } -func UpdateQuestion(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdateQuestion(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -123,7 +123,7 @@ func UpdateQuestion(w http.ResponseWriter, r *http.Request) { r.ParseForm() clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64) customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64) - _, err := DB.Exec("UPDATE questions SET client_id=?, customer_id=?, question=?, status=? WHERE question_id=?", clientID, customerID, r.FormValue("question"), r.FormValue("status"), id) + _, err := a.DB.Exec("UPDATE questions SET client_id=?, customer_id=?, question=?, status=? WHERE question_id=?", clientID, customerID, r.FormValue("question"), r.FormValue("status"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -131,18 +131,18 @@ func UpdateQuestion(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteQuestion(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeleteQuestion(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - DB.Exec("DELETE FROM questions WHERE question_id = ?", id) + a.DB.Exec("DELETE FROM questions WHERE question_id = ?", id) } -func ListAnswers(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListAnswers(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -154,13 +154,13 @@ func ListAnswers(w http.ResponseWriter, r *http.Request) { limit = 20 } - answers, err := db.ListAnswersWithDetails(DB, accountID, questionID, limit, offset) + answers, err := db.ListAnswersWithDetails(a.DB, accountID, questionID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - questions, err := db.ListQuestions(DB, 0, limit, offset) + questions, err := db.ListQuestions(a.DB, 0, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -173,7 +173,7 @@ func ListAnswers(w http.ResponseWriter, r *http.Request) { questionText[q.QuestionID] = q.Question } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(` @@ -202,19 +202,19 @@ func ListAnswers(w http.ResponseWriter, r *http.Request) { `)) - for _, a := range answers { - qText := a.QuestionText + for _, a2 := range answers { + qText := a2.QuestionText if qText == "" { - qText = questionText[a.QuestionID] + qText = questionText[a2.QuestionID] if qText == "" { - qText = strconv.FormatInt(a.QuestionID, 10) + qText = strconv.FormatInt(a2.QuestionID, 10) } } - clientName := a.ClientName + clientName := a2.ClientName if clientName == "" { - clientName = strconv.FormatInt(a.ClientID, 10) + clientName = strconv.FormatInt(a2.ClientID, 10) } - customerName := a.CustomerName + customerName := a2.CustomerName if customerName == "" { customerName = "Unknown" } @@ -223,24 +223,24 @@ func ListAnswers(w http.ResponseWriter, r *http.Request) { ` + clientName + ` ` + customerName + ` ` + qText + ` - ` + a.Answer + ` - ` + a.Status + ` + ` + a2.Answer + ` + ` + a2.Status + ` - View - -
+ View + +
- + -
+ - +
@@ -253,8 +253,8 @@ func ListAnswers(w http.ResponseWriter, r *http.Request) { `)) } -func CreateAnswer(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) CreateAnswer(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -263,14 +263,14 @@ func CreateAnswer(w http.ResponseWriter, r *http.Request) { questionID, _ := strconv.ParseInt(r.FormValue("question_id"), 10, 64) var clientID int64 - err := DB.QueryRow("SELECT client_id FROM questions WHERE question_id = ?", questionID).Scan(&clientID) + err := a.DB.QueryRow("SELECT client_id FROM questions WHERE question_id = ?", questionID).Scan(&clientID) if err != nil { http.Error(w, "Question not found", http.StatusBadRequest) return } var checkID int64 - err = DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID) + err = a.DB.QueryRow("SELECT client_id FROM clients WHERE client_id = ? AND account_id = ?", clientID, accountID).Scan(&checkID) if err != nil { http.Error(w, "Invalid question", http.StatusBadRequest) return @@ -282,10 +282,10 @@ func CreateAnswer(w http.ResponseWriter, r *http.Request) { Answer: r.FormValue("answer"), Timestamp: time.Now().Unix(), Status: "active", - CreatedAt: time.Now().Unix(), + CreatedAt: time.Now().Unix(), } - if err := answer.Create(DB); err != nil { + if err := answer.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -293,26 +293,26 @@ func CreateAnswer(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewAnswer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewAnswer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - var a db.Answer - err := DB.QueryRow("SELECT answer_id, client_id, question_id, answer, timestamp, status, created_at FROM answers WHERE answer_id = ?", id).Scan(&a.AnswerID, &a.ClientID, &a.QuestionID, &a.Answer, &a.Timestamp, &a.Status, &a.CreatedAt) + var ans db.Answer + err := a.DB.QueryRow("SELECT answer_id, client_id, question_id, answer, timestamp, status, created_at FROM answers WHERE answer_id = ?", id).Scan(&ans.AnswerID, &ans.ClientID, &ans.QuestionID, &ans.Answer, &ans.Timestamp, &ans.Status, &ans.CreatedAt) if err != nil { http.Error(w, "Answer not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

Answer

Question ID: ` + strconv.FormatInt(a.QuestionID, 10) + `

Answer: ` + a.Answer + `

Back`)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`

Answer

Question ID: ` + strconv.FormatInt(ans.QuestionID, 10) + `

Answer: ` + ans.Answer + `

Back`)) } -func UpdateAnswer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdateAnswer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -320,7 +320,7 @@ func UpdateAnswer(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) r.ParseForm() questionID, _ := strconv.ParseInt(r.FormValue("question_id"), 10, 64) - _, err := DB.Exec("UPDATE answers SET question_id=?, answer=? WHERE answer_id=?", questionID, r.FormValue("answer"), id) + _, err := a.DB.Exec("UPDATE answers SET question_id=?, answer=? WHERE answer_id=?", questionID, r.FormValue("answer"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -328,12 +328,45 @@ func UpdateAnswer(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteAnswer(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeleteAnswer(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - DB.Exec("DELETE FROM answers WHERE answer_id = ?", id) -} \ No newline at end of file + a.DB.Exec("DELETE FROM answers WHERE answer_id = ?", id) +} + +// --- package-level shims kept for existing tests --- + +func ListQuestions(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListQuestions(w, r) +} +func CreateQuestion(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateQuestion(w, r) +} +func ViewQuestion(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewQuestion(w, r) +} +func UpdateQuestion(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateQuestion(w, r) +} +func DeleteQuestion(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteQuestion(w, r) +} +func ListAnswers(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListAnswers(w, r) +} +func CreateAnswer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateAnswer(w, r) +} +func ViewAnswer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewAnswer(w, r) +} +func UpdateAnswer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateAnswer(w, r) +} +func DeleteAnswer(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteAnswer(w, r) +} diff --git a/apps/go-crm/internal/handlers/report.go b/apps/go-crm/internal/handlers/report.go new file mode 100644 index 0000000..89df300 --- /dev/null +++ b/apps/go-crm/internal/handlers/report.go @@ -0,0 +1,219 @@ +package handlers + +import ( + "fmt" + "math" + "net/http" + "time" +) + +type serviceCount struct { + Name string + Count int +} + +type reportData struct { + TotalLeads int + ServiceCounts []serviceCount + StatusCounts []serviceCount + TotalScheduled int + ConversionRate float64 + TotalRevenue float64 + TotalSales int + AverageTicket float64 + TopServices []serviceCount + MonthLabel string +} + +// MonthlyReport renders the monthly performance report for the current month. +// GET /report +func (a *App) MonthlyReport(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) + if !ok { + return + } + + clientID := a.clientIDForAccount(accountID) + if clientID == 0 { + http.Error(w, "No client found for account", http.StatusBadRequest) + return + } + + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Unix() + nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()).Unix() + + rd := reportData{MonthLabel: now.Format("January 2006")} + + a.DB.QueryRow( + "SELECT COUNT(*) FROM leads WHERE client_id = ? AND created_at >= ? AND created_at < ?", + clientID, monthStart, nextMonth, + ).Scan(&rd.TotalLeads) + + rows, err := a.DB.Query( + `SELECT service_interest, COUNT(*) as cnt FROM leads + WHERE client_id = ? AND created_at >= ? AND created_at < ? + GROUP BY service_interest ORDER BY cnt DESC`, + clientID, monthStart, nextMonth, + ) + if err == nil { + defer rows.Close() + for rows.Next() { + var sc serviceCount + rows.Scan(&sc.Name, &sc.Count) + rd.ServiceCounts = append(rd.ServiceCounts, sc) + } + } + + statusRows, err := a.DB.Query( + `SELECT status, COUNT(*) as cnt FROM leads + WHERE client_id = ? AND created_at >= ? AND created_at < ? + GROUP BY status ORDER BY cnt DESC`, + clientID, monthStart, nextMonth, + ) + if err == nil { + defer statusRows.Close() + for statusRows.Next() { + var sc serviceCount + statusRows.Scan(&sc.Name, &sc.Count) + rd.StatusCounts = append(rd.StatusCounts, sc) + } + } + + a.DB.QueryRow( + "SELECT COUNT(*) FROM leads WHERE client_id = ? AND status = 'Agendou' AND created_at >= ? AND created_at < ?", + clientID, monthStart, nextMonth, + ).Scan(&rd.TotalScheduled) + + if rd.TotalLeads > 0 { + rd.ConversionRate = math.Round(float64(rd.TotalScheduled)/float64(rd.TotalLeads)*100*10) / 10 + } + + a.DB.QueryRow( + `SELECT COALESCE(SUM(amount),0), COUNT(*) FROM payments + WHERE client_id = ? AND has_paid = 1 AND created_at >= ? AND created_at < ?`, + clientID, monthStart, nextMonth, + ).Scan(&rd.TotalRevenue, &rd.TotalSales) + + if rd.TotalSales > 0 { + rd.AverageTicket = math.Round(rd.TotalRevenue/float64(rd.TotalSales)*100) / 100 + } + + topRows, err := a.DB.Query( + `SELECT s.name, COUNT(*) as cnt + FROM payments p + JOIN scheduling sch ON p.schedule_id = sch.schedule_id + JOIN services s ON sch.service_id = s.service_id + WHERE p.client_id = ? AND p.has_paid = 1 AND p.created_at >= ? AND p.created_at < ? + GROUP BY s.name ORDER BY cnt DESC LIMIT 5`, + clientID, monthStart, nextMonth, + ) + if err == nil { + defer topRows.Close() + for topRows.Next() { + var sc serviceCount + topRows.Scan(&sc.Name, &sc.Count) + rd.TopServices = append(rd.TopServices, sc) + } + } + + var reviewCount int + a.DB.QueryRow("SELECT COUNT(*) FROM leads WHERE client_id = ? AND needs_review = 1", clientID).Scan(&reviewCount) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` + + + Monthly Report — %s + + + +

Monthly Report — %s

+ + +

Overview

+
+
%d
Total Leads
+
%d
Agendamentos
+
%.1f%%
Conversion Rate
+
+ +

Revenue

+
+
R$ %.2f
Total Month Revenue
+
%d
Total Sales
+
R$ %.2f
Average Ticket
+
+ +

Leads by Service Interest

+ %s + +

Leads by Status

+ %s + +

Top 5 Services Sold

+ %s + +`, + rd.MonthLabel, + rd.MonthLabel, + reviewCount, + rd.TotalLeads, rd.TotalScheduled, rd.ConversionRate, + rd.TotalRevenue, rd.TotalSales, rd.AverageTicket, + renderServiceTable(rd.ServiceCounts, rd.TotalLeads), + renderServiceTable(rd.StatusCounts, rd.TotalLeads), + renderTopServicesTable(rd.TopServices), + ) +} + +func renderServiceTable(counts []serviceCount, total int) string { + if len(counts) == 0 { + return `

No data for this month.

` + } + out := `` + for _, sc := range counts { + pct := 0.0 + if total > 0 { + pct = math.Round(float64(sc.Count)/float64(total)*100*10) / 10 + } + out += fmt.Sprintf(``, + htmlEscape(sc.Name), sc.Count, pct) + } + out += `
NameQuantity%
%s%d%.1f%%
` + return out +} + +func renderTopServicesTable(counts []serviceCount) string { + if len(counts) == 0 { + return `

No sales data for this month.

` + } + out := `` + for _, sc := range counts { + out += fmt.Sprintf(``, htmlEscape(sc.Name), sc.Count) + } + out += `
ServiceSales
%s%d
` + return out +} + +// --- package-level shim kept for existing tests --- + +func MonthlyReport(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).MonthlyReport(w, r) +} diff --git a/apps/go-crm/internal/handlers/scheduling.go b/apps/go-crm/internal/handlers/scheduling.go index cf47c3d..ac85c61 100644 --- a/apps/go-crm/internal/handlers/scheduling.go +++ b/apps/go-crm/internal/handlers/scheduling.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListSchedules(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListSchedules(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -24,25 +24,25 @@ func ListSchedules(w http.ResponseWriter, r *http.Request) { limit = 20 } - schedules, err := db.ListSchedules(DB, clientID, customerID, limit, offset) + schedules, err := db.ListSchedules(a.DB, clientID, customerID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - customers, err := db.ListCustomers(DB, accountID, clientID, limit, offset) + customers, err := db.ListCustomers(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - services, err := db.ListServices(DB, accountID, clientID, limit, offset) + services, err := db.ListServices(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -65,7 +65,7 @@ func ListSchedules(w http.ResponseWriter, r *http.Request) { serviceNames[s.ServiceID] = s.Name } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Scheduling

`)) for _, s := range schedules { clientName := clientNames[s.ClientID] @@ -85,8 +85,8 @@ func ListSchedules(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`
ClientCustomerServiceDateHourStatusActions
`)) } -func CreateSchedule(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) CreateSchedule(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -96,16 +96,16 @@ func CreateSchedule(w http.ResponseWriter, r *http.Request) { customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64) serviceID, _ := strconv.ParseInt(r.FormValue("service_id"), 10, 64) schedule := db.Schedule{ - ClientID: clientID, + ClientID: clientID, CustomerID: customerID, ServiceID: serviceID, PlanDate: r.FormValue("plan_date"), - Time: r.FormValue("time"), - Status: r.FormValue("status"), - CreatedAt: time.Now().Unix(), + Time: r.FormValue("time"), + Status: r.FormValue("status"), + CreatedAt: time.Now().Unix(), } - if err := schedule.Create(DB); err != nil { + if err := schedule.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -113,26 +113,26 @@ func CreateSchedule(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewSchedule(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewSchedule(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) var sch db.Schedule - err := DB.QueryRow("SELECT schedule_id, client_id, customer_id, service_id, plan_date, time, status, created_at FROM scheduling WHERE schedule_id = ?", id).Scan(&sch.ScheduleID, &sch.ClientID, &sch.CustomerID, &sch.ServiceID, &sch.PlanDate, &sch.Time, &sch.Status, &sch.CreatedAt) + err := a.DB.QueryRow("SELECT schedule_id, client_id, customer_id, service_id, plan_date, time, status, created_at FROM scheduling WHERE schedule_id = ?", id).Scan(&sch.ScheduleID, &sch.ClientID, &sch.CustomerID, &sch.ServiceID, &sch.PlanDate, &sch.Time, &sch.Status, &sch.CreatedAt) if err != nil { http.Error(w, "Schedule not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Schedule

Client ID: ` + strconv.FormatInt(sch.ClientID, 10) + `

Customer ID: ` + strconv.FormatInt(sch.CustomerID, 10) + `

Service ID: ` + strconv.FormatInt(sch.ServiceID, 10) + `

Date: ` + sch.PlanDate + `

Time: ` + sch.Time + `

Status: ` + sch.Status + `

Back`)) } -func UpdateSchedule(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdateSchedule(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -142,7 +142,7 @@ func UpdateSchedule(w http.ResponseWriter, r *http.Request) { clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64) customerID, _ := strconv.ParseInt(r.FormValue("customer_id"), 10, 64) serviceID, _ := strconv.ParseInt(r.FormValue("service_id"), 10, 64) - _, err := DB.Exec("UPDATE scheduling SET client_id=?, customer_id=?, service_id=?, plan_date=?, time=?, status=? WHERE schedule_id=?", clientID, customerID, serviceID, r.FormValue("plan_date"), r.FormValue("time"), r.FormValue("status"), id) + _, err := a.DB.Exec("UPDATE scheduling SET client_id=?, customer_id=?, service_id=?, plan_date=?, time=?, status=? WHERE schedule_id=?", clientID, customerID, serviceID, r.FormValue("plan_date"), r.FormValue("time"), r.FormValue("status"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -150,12 +150,30 @@ func UpdateSchedule(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteSchedule(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeleteSchedule(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id) -} \ No newline at end of file + a.DB.Exec("DELETE FROM scheduling WHERE schedule_id = ?", id) +} + +// --- package-level shims kept for existing tests --- + +func ListSchedules(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListSchedules(w, r) +} +func CreateSchedule(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateSchedule(w, r) +} +func ViewSchedule(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewSchedule(w, r) +} +func UpdateSchedule(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateSchedule(w, r) +} +func DeleteSchedule(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteSchedule(w, r) +} diff --git a/apps/go-crm/internal/handlers/services.go b/apps/go-crm/internal/handlers/services.go index 8c73299..892f691 100644 --- a/apps/go-crm/internal/handlers/services.go +++ b/apps/go-crm/internal/handlers/services.go @@ -10,8 +10,8 @@ import ( "github.com/go-chi/chi/v5" ) -func ListServices(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) ListServices(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -23,13 +23,13 @@ func ListServices(w http.ResponseWriter, r *http.Request) { limit = 20 } - services, err := db.ListServices(DB, accountID, clientID, limit, offset) + services, err := db.ListServices(a.DB, accountID, clientID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - clients, err := db.ListClients(DB, accountID, limit, offset) + clients, err := db.ListClients(a.DB, accountID, limit, offset) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -40,7 +40,7 @@ func ListServices(w http.ResponseWriter, r *http.Request) { clientOptions += `` } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

Services

`)) for _, s := range services { w.Write([]byte(``)) @@ -48,8 +48,8 @@ func ListServices(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`
NamePriceDurationActions
` + s.Name + `` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `` + s.Duration + `View
`)) } -func CreateService(w http.ResponseWriter, r *http.Request) { - accountID, ok := requireAuth(w, r) +func (a *App) CreateService(w http.ResponseWriter, r *http.Request) { + accountID, ok := a.requireAuth(w, r) if !ok { return } @@ -58,7 +58,7 @@ func CreateService(w http.ResponseWriter, r *http.Request) { 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) + err := a.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 @@ -67,14 +67,14 @@ func CreateService(w http.ResponseWriter, r *http.Request) { price, _ := strconv.ParseFloat(r.FormValue("price"), 64) service := db.Service{ ClientID: clientID, - Name: r.FormValue("name"), - Price: price, + Name: r.FormValue("name"), + Price: price, Description: r.FormValue("description"), - Duration: r.FormValue("duration"), - CreatedAt: time.Now().Unix(), + Duration: r.FormValue("duration"), + CreatedAt: time.Now().Unix(), } - if err := service.Create(DB); err != nil { + if err := service.Create(a.DB); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -82,26 +82,26 @@ func CreateService(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func ViewService(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) ViewService(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) var s db.Service - err := DB.QueryRow("SELECT service_id, client_id, name, price, description, duration, created_at FROM services WHERE service_id = ?", id).Scan(&s.ServiceID, &s.ClientID, &s.Name, &s.Price, &s.Description, &s.Duration, &s.CreatedAt) + err := a.DB.QueryRow("SELECT service_id, client_id, name, price, description, duration, created_at FROM services WHERE service_id = ?", id).Scan(&s.ServiceID, &s.ClientID, &s.Name, &s.Price, &s.Description, &s.Duration, &s.CreatedAt) if err != nil { http.Error(w, "Service not found", http.StatusNotFound) return } - w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`

` + s.Name + `

Price: ` + strconv.FormatFloat(s.Price, 'f', 2, 64) + `

Description: ` + s.Description + `

Duration: ` + s.Duration + `

Back`)) } -func UpdateService(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) UpdateService(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } @@ -110,7 +110,7 @@ func UpdateService(w http.ResponseWriter, r *http.Request) { r.ParseForm() clientID, _ := strconv.ParseInt(r.FormValue("client_id"), 10, 64) price, _ := strconv.ParseFloat(r.FormValue("price"), 64) - _, err := DB.Exec("UPDATE services SET client_id=?, name=?, price=?, description=?, duration=? WHERE service_id=?", clientID, r.FormValue("name"), price, r.FormValue("description"), r.FormValue("duration"), id) + _, err := a.DB.Exec("UPDATE services SET client_id=?, name=?, price=?, description=?, duration=? WHERE service_id=?", clientID, r.FormValue("name"), price, r.FormValue("description"), r.FormValue("duration"), id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -118,12 +118,30 @@ func UpdateService(w http.ResponseWriter, r *http.Request) { w.Header().Set("HX-Refresh", "true") } -func DeleteService(w http.ResponseWriter, r *http.Request) { - _, ok := requireAuth(w, r) +func (a *App) DeleteService(w http.ResponseWriter, r *http.Request) { + _, ok := a.requireAuth(w, r) if !ok { return } id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - DB.Exec("DELETE FROM services WHERE service_id = ?", id) -} \ No newline at end of file + a.DB.Exec("DELETE FROM services WHERE service_id = ?", id) +} + +// --- package-level shims kept for existing tests --- + +func ListServices(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ListServices(w, r) +} +func CreateService(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).CreateService(w, r) +} +func ViewService(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).ViewService(w, r) +} +func UpdateService(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).UpdateService(w, r) +} +func DeleteService(w http.ResponseWriter, r *http.Request) { + (&App{DB: DB, WAConnector: WAConnector}).DeleteService(w, r) +} diff --git a/apps/go-crm/internal/handlers/setup.go b/apps/go-crm/internal/handlers/setup.go index ebd0359..39d447f 100644 --- a/apps/go-crm/internal/handlers/setup.go +++ b/apps/go-crm/internal/handlers/setup.go @@ -5,10 +5,28 @@ import ( "time" "go-crm/internal/whatsapp" + "go-crm/pkg/usecase" ) +// App holds the application dependencies injected at startup. +// All handler methods live on *App, eliminating package-level global state. +type App struct { + DB *sql.DB + LeadService *usecase.LeadService + WAConnector whatsapp.Connector +} + +// NewApp constructs an App with the given database, lead service, and WhatsApp connector. +func NewApp(db *sql.DB, leadSvc *usecase.LeadService, wa whatsapp.Connector) *App { + return &App{DB: db, LeadService: leadSvc, WAConnector: wa} +} + +// WAConnector is a package-level global kept for backward-compat with existing tests. +// New code should use App.WAConnector via NewApp. var WAConnector whatsapp.Connector +// SetupHandlers is kept for backward compatibility with existing tests. +// New code should use NewApp directly. func SetupHandlers(db *sql.DB, wa whatsapp.Connector) { DB = db WAConnector = wa @@ -16,4 +34,4 @@ func SetupHandlers(db *sql.DB, wa whatsapp.Connector) { func getCurrentTimestamp() int64 { return time.Now().Unix() -} \ No newline at end of file +} diff --git a/apps/go-crm/internal/handlers/status_test.go b/apps/go-crm/internal/handlers/status_test.go new file mode 100644 index 0000000..c7f6f76 --- /dev/null +++ b/apps/go-crm/internal/handlers/status_test.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "go-crm/internal/db" + "go-crm/internal/whatsapp" + + "golang.org/x/crypto/bcrypt" +) + +func TestListClientsWhatsAppStatusIndicators(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() + + fakeWA := whatsapp.NewFakeConnector() + fakeWA.MarkConnected(1) + + DB = testDB + WAConnector = fakeWA + + 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) + } + + _, err = testDB.Exec( + "INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?)", + accountID, "Connected Client", "+5521987654321", "+5521987654321", 1, time.Now().Unix(), + ) + if err != nil { + t.Fatalf("failed to create connected client: %v", err) + } + + _, err = testDB.Exec( + "INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?)", + accountID, "Disconnected Client", "+5521987654322", "+5521987654322", 1, time.Now().Unix(), + ) + if err != nil { + t.Fatalf("failed to create disconnected client: %v", err) + } + + _, err = testDB.Exec( + "INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?)", + accountID, "No WhatsApp Client", "+5521987654323", time.Now().Unix(), + ) + if err != nil { + t.Fatalf("failed to create no-wa client: %v", err) + } + + sessionID := "test-session-status" + 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) + } + + 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, "Connected Client") { + t.Error("expected to find connected client") + } + + if !strings.Contains(body, "Disconnected Client") { + t.Error("expected to find disconnected client") + } + + if !strings.Contains(body, "No WhatsApp Client") { + t.Error("expected to find no-wa client") + } + + if !strings.Contains(body, "Connect") { + t.Error("expected Connect link for clients without active connection") + } + + greenDot := `color:#28a745` + if !strings.Contains(body, greenDot) { + t.Error("expected green dot for connected client") + } +} diff --git a/apps/go-crm/internal/handlers/viewclient_test.go b/apps/go-crm/internal/handlers/viewclient_test.go new file mode 100644 index 0000000..73fbf33 --- /dev/null +++ b/apps/go-crm/internal/handlers/viewclient_test.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "go-crm/internal/db" + "go-crm/internal/whatsapp" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +func TestViewClientShowsWhatsAppInfo(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() + + fakeWA := whatsapp.NewFakeConnector() + fakeWA.MarkConnected(1) + + DB = testDB + WAConnector = fakeWA + + 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) + } + + var clientID int64 + testDB.QueryRow( + "INSERT INTO clients (account_id, name, phone, whatsapp_number, whatsapp_connected, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING client_id", + accountID, "WA Client", "+5521987654321", "+5521987654321", 1, time.Now().Unix(), + ).Scan(&clientID) + + sessionID := "test-session-viewclient" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/clients/"+strconv.FormatInt(clientID, 10), nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", strconv.FormatInt(clientID, 10)) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + + ViewClient(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "+5521987654321") { + t.Error("expected ViewClient to show WhatsApp number") + } + + if !strings.Contains(body, "WhatsApp") { + t.Error("expected ViewClient to show WhatsApp label") + } +} + +func TestViewClientShowsNotConnected(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) + } + + var clientID int64 + testDB.QueryRow( + "INSERT INTO clients (account_id, name, phone, created_at) VALUES (?, ?, ?, ?) RETURNING client_id", + accountID, "No WA Client", "+5521987654322", time.Now().Unix(), + ).Scan(&clientID) + + sessionID := "test-session-viewclient-no-wa" + 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) + } + + req := httptest.NewRequest(http.MethodGet, "/clients/"+strconv.FormatInt(clientID, 10), nil) + req.AddCookie(&http.Cookie{Name: "session", Value: sessionID}) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", strconv.FormatInt(clientID, 10)) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + + ViewClient(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + body := w.Body.String() + + if !strings.Contains(body, "WhatsApp") { + t.Error("expected ViewClient to show WhatsApp section even when not connected") + } + + if !strings.Contains(body, "Connect") { + t.Error("expected ViewClient to show Connect link when not connected") + } +} diff --git a/apps/go-crm/internal/middleware/utf8.go b/apps/go-crm/internal/middleware/utf8.go new file mode 100644 index 0000000..fd71cf6 --- /dev/null +++ b/apps/go-crm/internal/middleware/utf8.go @@ -0,0 +1,11 @@ +package middleware + +import "net/http" + +// ForceUTF8Middleware forces the browser to interpret HTML as UTF-8 for all responses. +func ForceUTF8Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + next.ServeHTTP(w, r) + }) +} diff --git a/apps/go-crm/internal/parser/parser.go b/apps/go-crm/internal/parser/parser.go new file mode 100644 index 0000000..b552a77 --- /dev/null +++ b/apps/go-crm/internal/parser/parser.go @@ -0,0 +1,73 @@ +// Package parser provides message text parsing utilities for the CRM. +// It extracts structured lead information from raw WhatsApp message text. +package parser + +import ( + "regexp" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +// ExtractService scans msg for any keyword in the mapping (case-insensitive, +// accent-insensitive) and returns the corresponding service name. +// If no keyword matches, it returns "Não especificou". +// +// mapping is a map[keyword]serviceName loaded from the service_keywords table. +func ExtractService(msg string, mapping map[string]string) string { + normalized := normalizeText(msg) + + // Sort by keyword length descending so longer phrases match before shorter ones. + // We iterate the map which has no order, so we do two passes: + // first collect all matches, then pick the longest keyword match. + type match struct { + keyword string + service string + } + var best match + + for kw, svc := range mapping { + normKW := normalizeText(kw) + if strings.Contains(normalized, normKW) { + if len(kw) > len(best.keyword) { + best = match{keyword: kw, service: svc} + } + } + } + + if best.service != "" { + return best.service + } + return "Não especificou" +} + +// NormalizePhone converts a raw WhatsApp phone string to E.164 format (+countrycode...). +// It strips the @s.whatsapp.net suffix, removes non-digit characters, and prepends "+". +func NormalizePhone(raw string) string { + // Strip WhatsApp JID suffix. + if idx := strings.Index(raw, "@"); idx != -1 { + raw = raw[:idx] + } + // Keep only digits. + digits := regexp.MustCompile(`\D`).ReplaceAllString(raw, "") + if digits == "" { + return raw + } + return "+" + digits +} + +// normalizeText lowercases and removes accents from s for fuzzy comparison. +func normalizeText(s string) string { + // NFD decomposition splits accented characters into base + combining marks. + t := norm.NFD.String(strings.ToLower(s)) + // Remove combining marks (Unicode category Mn). + var b strings.Builder + for _, r := range t { + if unicode.Is(unicode.Mn, r) { + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/apps/go-crm/internal/parser/parser_test.go b/apps/go-crm/internal/parser/parser_test.go new file mode 100644 index 0000000..4c1ccb1 --- /dev/null +++ b/apps/go-crm/internal/parser/parser_test.go @@ -0,0 +1,117 @@ +package parser_test + +import ( + "testing" + + "go-crm/internal/parser" +) + +func TestExtractService_KnownKeywords(t *testing.T) { + keywords := map[string]string{ + "head spa": "Head Spa", + "head-spa": "Head Spa", + "headspa": "Head Spa", + "massagem": "Massagem completa", + "massagem completa": "Massagem completa", + "drenagem": "Drenagem linfatica", + "linfática": "Drenagem linfatica", + "hydra": "Hydra Boost", + "hydra boost": "Hydra Boost", + "henna": "Design Henna", + "design henna": "Design Henna", + "masculino": "Masculino", + "masc": "Masculino", + } + + mapping := map[string]string{ + "head spa": "Head Spa", + "head-spa": "Head Spa", + "headspa": "Head Spa", + "massagem": "Massagem completa", + "massagem completa": "Massagem completa", + "drenagem": "Drenagem linfatica", + "linfática": "Drenagem linfatica", + "hydra": "Hydra Boost", + "hydra boost": "Hydra Boost", + "henna": "Design Henna", + "design henna": "Design Henna", + "masculino": "Masculino", + "masc": "Masculino", + } + + for kw, expected := range keywords { + // Build messages in Portuguese with the keyword embedded in natural phrasing. + messages := []string{ + "Olá, gostaria de agendar um " + kw, + "Boa tarde! Quero fazer " + kw + " por favor", + "Quanto custa " + kw + "?", + kw, + } + for _, msg := range messages { + got := parser.ExtractService(msg, mapping) + if got != expected { + t.Errorf("message %q with keyword %q: got %q, want %q", msg, kw, got, expected) + } + } + } +} + +func TestExtractService_UnknownMessage_ReturnsNaoEspecificou(t *testing.T) { + mapping := map[string]string{ + "massagem": "Massagem completa", + } + + messages := []string{ + "Olá, tudo bem?", + "Qual o horário de funcionamento?", + "Vocês atendem no sábado?", + "", + } + + for _, msg := range messages { + got := parser.ExtractService(msg, mapping) + if got != "Não especificou" { + t.Errorf("message %q: got %q, want %q", msg, got, "Não especificou") + } + } +} + +func TestExtractService_CaseInsensitive(t *testing.T) { + mapping := map[string]string{ + "head spa": "Head Spa", + "henna": "Design Henna", + } + + cases := map[string]string{ + "Quero HEAD SPA": "Head Spa", + "HENNA por favor": "Design Henna", + "Head Spa agora": "Head Spa", + } + + for msg, expected := range cases { + got := parser.ExtractService(msg, mapping) + if got != expected { + t.Errorf("message %q: got %q, want %q", msg, got, expected) + } + } +} + +func TestNormalizePhone(t *testing.T) { + cases := []struct { + raw string + want string + }{ + {"5511999999999@s.whatsapp.net", "+5511999999999"}, + {"5511999999999", "+5511999999999"}, + {"+5511999999999", "+5511999999999"}, + {"55 11 99999-9999", "+5511999999999"}, + {"11999999999", "+11999999999"}, + } + + for _, c := range cases { + got := parser.NormalizePhone(c.raw) + if got != c.want { + t.Errorf("NormalizePhone(%q) = %q, want %q", c.raw, got, c.want) + } + } +} diff --git a/apps/go-crm/internal/templates/layout.go b/apps/go-crm/internal/templates/layout.go index 3598e50..b9bd761 100644 --- a/apps/go-crm/internal/templates/layout.go +++ b/apps/go-crm/internal/templates/layout.go @@ -52,6 +52,7 @@ func Layout(title, content string) *template.Template {