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(`AccountAccount Settings
+ 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(`AccountAccount Settings
- 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