# TechXCar — Plan 1: Foundation & Infrastructure > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Set up the complete project foundation — monorepo structure, Docker Compose with all 4 services, Go backend skeleton with PostgreSQL and Redis connections, database migrations for both public and tenant schemas, a tested health endpoint, and a scaffolded React frontend with routing, Tailwind CSS 4, shadcn/ui primitives, and an auth-aware API client. **Architecture:** Monorepo with `backend/` (Go + Fiber) and `frontend/` (React + Vite) directories. PostgreSQL uses a `public` schema for global tables and a per-tenant schema provisioned on signup. Redis handles rate limiting and the async notification job queue. Docker Compose orchestrates all four services (postgres, redis, backend, frontend) for local development and Coolify production. **Tech Stack:** Go 1.22, Fiber v2, pgx v5, golang-migrate, go-redis v9, React 19, TypeScript, Vite 5, TanStack Query v5, Zustand v5, React Router v7, Tailwind CSS 4, shadcn/ui, Vitest --- ### Task 1: Monorepo structure + Docker Compose **Files:** - Create: `docker-compose.yml` - Create: `docker-compose.prod.yml` - Create: `.env.example` - Create: `.gitignore` - Create: `backend/Dockerfile` - Create: `frontend/Dockerfile` - Create: `frontend/nginx.conf` - [ ] **Step 1: Create root .gitignore** Create `/var/home/lmilani/Documentos/IDE/techxcar/.gitignore`: ``` # Env .env .env.local # Go backend/vendor/ backend/tmp/ backend/server # Node frontend/node_modules/ frontend/dist/ frontend/.vite/ # IDE .idea/ .vscode/ *.swp ``` - [ ] **Step 2: Create .env.example** Create `/var/home/lmilani/Documentos/IDE/techxcar/.env.example`: ```env # Database POSTGRES_USER=techxcar POSTGRES_PASSWORD=changeme POSTGRES_DB=techxcar # Backend JWT_SECRET=your-super-secret-key-minimum-32-characters PORT=8080 APP_ENV=development # SMTP, Telegram and other integration settings are configured # post-deploy via the Web UI Settings panel (stored in DB, not in .env) ``` - [ ] **Step 3: Create docker-compose.yml** Create `/var/home/lmilani/Documentos/IDE/techxcar/docker-compose.yml`: ```yaml services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-techxcar} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-techxcar} POSTGRES_DB: ${POSTGRES_DB:-techxcar} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-techxcar}"] interval: 5s timeout: 5s retries: 5 ports: - "5432:5432" redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 ports: - "6379:6379" backend: build: context: ./backend dockerfile: Dockerfile env_file: .env environment: DATABASE_URL: postgres://${POSTGRES_USER:-techxcar}:${POSTGRES_PASSWORD:-techxcar}@postgres:5432/${POSTGRES_DB:-techxcar}?sslmode=disable REDIS_URL: redis://redis:6379 ports: - "8080:8080" depends_on: postgres: condition: service_healthy redis: condition: service_healthy volumes: - pdf_storage:/app/storage frontend: build: context: ./frontend dockerfile: Dockerfile ports: - "3000:80" depends_on: - backend volumes: postgres_data: redis_data: pdf_storage: ``` - [ ] **Step 4: Create docker-compose.prod.yml** Create `/var/home/lmilani/Documentos/IDE/techxcar/docker-compose.prod.yml`: ```yaml services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 backend: image: ${BACKEND_IMAGE:-techxcar-backend:latest} restart: unless-stopped env_file: .env environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 APP_ENV: production depends_on: postgres: condition: service_healthy redis: condition: service_healthy volumes: - pdf_storage:/app/storage frontend: image: ${FRONTEND_IMAGE:-techxcar-frontend:latest} restart: unless-stopped depends_on: - backend volumes: postgres_data: redis_data: pdf_storage: ``` - [ ] **Step 5: Create backend Dockerfile** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/Dockerfile`: ```dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server FROM alpine:3.19 RUN apk --no-cache add ca-certificates tzdata WORKDIR /app COPY --from=builder /app/server . COPY --from=builder /app/migrations ./migrations EXPOSE 8080 CMD ["./server"] ``` - [ ] **Step 6: Create frontend Dockerfile** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/Dockerfile`: ```dockerfile FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` - [ ] **Step 7: Create frontend nginx.conf** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/nginx.conf`: ```nginx server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } } ``` - [ ] **Step 8: Copy .env.example to .env** ```bash cp /var/home/lmilani/Documentos/IDE/techxcar/.env.example /var/home/lmilani/Documentos/IDE/techxcar/.env ``` - [ ] **Step 9: Initialise git and commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git init git add . git commit -m "chore: initial monorepo structure with Docker Compose" ``` Expected: commit success --- ### Task 2: Go module setup + config **Files:** - Create: `backend/go.mod` - Create: `backend/internal/config/config.go` - Create: `backend/internal/config/config_test.go` - [ ] **Step 1: Write the failing test** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/config/config_test.go`: ```go package config_test import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/techxcar/backend/internal/config" ) func TestLoad_defaults(t *testing.T) { os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") os.Setenv("REDIS_URL", "redis://localhost:6379") os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") defer func() { os.Unsetenv("DATABASE_URL") os.Unsetenv("REDIS_URL") os.Unsetenv("JWT_SECRET") }() cfg, err := config.Load() require.NoError(t, err) assert.Equal(t, "8080", cfg.Port) assert.Equal(t, "development", cfg.AppEnv) assert.Equal(t, "postgres://test:test@localhost/test", cfg.DatabaseURL) assert.Equal(t, "redis://localhost:6379", cfg.RedisURL) } func TestLoad_missingDatabaseURL(t *testing.T) { os.Unsetenv("DATABASE_URL") os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") defer os.Unsetenv("JWT_SECRET") _, err := config.Load() assert.ErrorContains(t, err, "DATABASE_URL") } func TestLoad_missingJWTSecret(t *testing.T) { os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") os.Unsetenv("JWT_SECRET") defer os.Unsetenv("DATABASE_URL") _, err := config.Load() assert.ErrorContains(t, err, "JWT_SECRET") } func TestLoad_customPort(t *testing.T) { os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") os.Setenv("REDIS_URL", "redis://localhost:6379") os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") os.Setenv("PORT", "9090") defer func() { os.Unsetenv("DATABASE_URL") os.Unsetenv("REDIS_URL") os.Unsetenv("JWT_SECRET") os.Unsetenv("PORT") }() cfg, err := config.Load() require.NoError(t, err) assert.Equal(t, "9090", cfg.Port) } ``` - [ ] **Step 2: Initialise Go module and install dependencies** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go mod init github.com/techxcar/backend go get github.com/gofiber/fiber/v2@v2.52.5 go get github.com/gofiber/fiber/v2/middleware/cors go get github.com/gofiber/fiber/v2/middleware/helmet go get github.com/gofiber/fiber/v2/middleware/logger go get github.com/gofiber/fiber/v2/middleware/recover go get github.com/golang-migrate/migrate/v4@v4.17.1 go get github.com/golang-migrate/migrate/v4/database/postgres go get github.com/golang-migrate/migrate/v4/source/file go get github.com/jackc/pgx/v5@v5.6.0 go get github.com/jackc/pgx/v5/stdlib go get github.com/redis/go-redis/v9@v9.5.1 go get github.com/golang-jwt/jwt/v5@v5.2.1 go get golang.org/x/crypto@v0.24.0 go get github.com/google/uuid@v1.6.0 go get github.com/joho/godotenv@v1.5.1 go get github.com/stretchr/testify@v1.9.0 ``` - [ ] **Step 3: Run test to verify it fails** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./internal/config/... -v ``` Expected: FAIL — `package github.com/techxcar/backend/internal/config: cannot find package` - [ ] **Step 4: Implement config** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/config/config.go`: ```go package config import ( "errors" "os" ) type Config struct { DatabaseURL string RedisURL string JWTSecret string Port string AppEnv string } func Load() (*Config, error) { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { return nil, errors.New("DATABASE_URL is required") } jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { return nil, errors.New("JWT_SECRET is required") } redisURL := os.Getenv("REDIS_URL") if redisURL == "" { redisURL = "redis://localhost:6379" } port := os.Getenv("PORT") if port == "" { port = "8080" } appEnv := os.Getenv("APP_ENV") if appEnv == "" { appEnv = "development" } return &Config{ DatabaseURL: dbURL, RedisURL: redisURL, JWTSecret: jwtSecret, Port: port, AppEnv: appEnv, }, nil } ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./internal/config/... -v ``` Expected: ``` === RUN TestLoad_defaults --- PASS: TestLoad_defaults === RUN TestLoad_missingDatabaseURL --- PASS: TestLoad_missingDatabaseURL === RUN TestLoad_missingJWTSecret --- PASS: TestLoad_missingJWTSecret === RUN TestLoad_customPort --- PASS: TestLoad_customPort PASS ``` - [ ] **Step 6: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/ git commit -m "feat: Go module setup with typed config loading" ``` --- ### Task 3: PostgreSQL package + migrations **Files:** - Create: `backend/pkg/database/database.go` - Create: `backend/pkg/database/database_test.go` - Create: `backend/migrations/public/000001_create_public_schema.up.sql` - Create: `backend/migrations/public/000001_create_public_schema.down.sql` - Create: `backend/migrations/tenant/000001_create_tenant_schema.up.sql` - Create: `backend/migrations/tenant/000001_create_tenant_schema.down.sql` - [ ] **Step 1: Write the failing test** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/database/database_test.go`: ```go package database_test import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/techxcar/backend/pkg/database" ) func TestNew_invalidURL(t *testing.T) { _, err := database.New("not-a-valid-url") assert.Error(t, err) } func TestNew_valid(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set, skipping integration test") } db, err := database.New(url) require.NoError(t, err) defer db.Close() assert.NoError(t, db.Ping()) } ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./pkg/database/... -v ``` Expected: FAIL — `package github.com/techxcar/backend/pkg/database: cannot find package` - [ ] **Step 3: Implement database package** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/database/database.go`: ```go package database import ( "context" "database/sql" "fmt" "time" "github.com/golang-migrate/migrate/v4" migratepg "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" ) type DB struct { Pool *pgxpool.Pool } func New(dsn string) (*DB, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) if err != nil { return nil, fmt.Errorf("database: failed to create pool: %w", err) } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, fmt.Errorf("database: failed to ping: %w", err) } return &DB{Pool: pool}, nil } func (db *DB) Ping() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return db.Pool.Ping(ctx) } func (db *DB) Close() { db.Pool.Close() } func (db *DB) SetTenantSchema(ctx context.Context, tenantID string) error { schema := fmt.Sprintf("tenant_%s", tenantID) _, err := db.Pool.Exec(ctx, fmt.Sprintf("SET search_path = %s, public", schema)) return err } func (db *DB) ResetSchema(ctx context.Context) error { _, err := db.Pool.Exec(ctx, "SET search_path = public") return err } func (db *DB) stdDB(dsn string) (*sql.DB, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, err } return stdlib.OpenDB(*cfg.ConnConfig), nil } func (db *DB) MigratePublic(dsn, migrationsPath string) error { stdDB, err := db.stdDB(dsn) if err != nil { return fmt.Errorf("migrate: %w", err) } defer stdDB.Close() m, err := migrate.NewWithDatabaseInstance( "file://"+migrationsPath, "postgres", migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: "public"}), ) if err != nil { return fmt.Errorf("migrate: %w", err) } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("migrate: %w", err) } return nil } func (db *DB) ProvisionTenantSchema(ctx context.Context, dsn, tenantID, migrationsPath string) error { schema := fmt.Sprintf("tenant_%s", tenantID) if _, err := db.Pool.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil { return fmt.Errorf("provision: create schema: %w", err) } stdDB, err := db.stdDB(dsn) if err != nil { return fmt.Errorf("provision: %w", err) } defer stdDB.Close() m, err := migrate.NewWithDatabaseInstance( "file://"+migrationsPath, "postgres", migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: schema}), ) if err != nil { return fmt.Errorf("provision: %w", err) } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("provision: %w", err) } return nil } ``` - [ ] **Step 4: Run tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./pkg/database/... -v ``` Expected: ``` === RUN TestNew_invalidURL --- PASS: TestNew_invalidURL === RUN TestNew_valid --- SKIP: TestNew_valid (TEST_DATABASE_URL not set) PASS ``` - [ ] **Step 5: Create public schema migration (up)** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/public/000001_create_public_schema.up.sql`: ```sql CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE IF NOT EXISTS tenants ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'pending')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS invites ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, email TEXT, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS super_admins ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS platform_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id); ``` - [ ] **Step 6: Create public schema migration (down)** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/public/000001_create_public_schema.down.sql`: ```sql DROP TABLE IF EXISTS platform_settings; DROP TABLE IF EXISTS super_admins; DROP TABLE IF EXISTS invites; DROP TABLE IF EXISTS tenants; ``` - [ ] **Step 7: Create tenant schema migration (up)** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/tenant/000001_create_tenant_schema.up.sql`: ```sql CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('tenant_admin', 'manager', 'technician')), active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS clients ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name TEXT NOT NULL, nif TEXT, phone TEXT, email TEXT, address TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS vehicles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), client_id UUID REFERENCES clients(id) ON DELETE SET NULL, plate TEXT NOT NULL, brand TEXT NOT NULL, model TEXT NOT NULL, year INT, vin TEXT, mileage INT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS staff ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES users(id) ON DELETE SET NULL, name TEXT NOT NULL, email TEXT, phone TEXT, type TEXT NOT NULL CHECK (type IN ('internal', 'external')), hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS catalog_items ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, category TEXT NOT NULL, unit TEXT NOT NULL CHECK (unit IN ('un', 'hora', 'litro', 'kg')), base_price NUMERIC(10,2) NOT NULL DEFAULT 0, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS work_orders ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), number SERIAL, client_id UUID REFERENCES clients(id) ON DELETE SET NULL, vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')), internal_notes TEXT, client_notes TEXT, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS wo_items ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL, description TEXT NOT NULL, qty NUMERIC(10,3) NOT NULL DEFAULT 1, unit_price NUMERIC(10,2) NOT NULL, discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0, total NUMERIC(10,2) GENERATED ALWAYS AS (qty * unit_price * (1 - discount_pct / 100)) STORED ); CREATE TABLE IF NOT EXISTS wo_staff_hours ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, hours NUMERIC(6,2) NOT NULL, cost_per_hour NUMERIC(10,2) NOT NULL, total NUMERIC(10,2) GENERATED ALWAYS AS (hours * cost_per_hour) STORED ); CREATE TABLE IF NOT EXISTS wo_status_log ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, from_status TEXT, to_status TEXT NOT NULL, changed_by UUID REFERENCES users(id) ON DELETE SET NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS invoices ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE RESTRICT, type TEXT NOT NULL CHECK (type IN ('quote', 'invoice')), number SERIAL, pdf_path TEXT, issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS expenses ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, type TEXT NOT NULL CHECK (type IN ('fuel', 'parts', 'tools', 'other')), amount NUMERIC(10,2) NOT NULL, description TEXT, date DATE NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS tenant_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_work_orders_status ON work_orders(status); CREATE INDEX IF NOT EXISTS idx_work_orders_client_id ON work_orders(client_id); CREATE INDEX IF NOT EXISTS idx_vehicles_client_id ON vehicles(client_id); CREATE INDEX IF NOT EXISTS idx_vehicles_plate ON vehicles(plate); CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(date); ``` - [ ] **Step 8: Create tenant schema migration (down)** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/tenant/000001_create_tenant_schema.down.sql`: ```sql DROP TABLE IF EXISTS tenant_settings; DROP TABLE IF EXISTS expenses; DROP TABLE IF EXISTS invoices; DROP TABLE IF EXISTS wo_status_log; DROP TABLE IF EXISTS wo_staff_hours; DROP TABLE IF EXISTS wo_items; DROP TABLE IF EXISTS work_orders; DROP TABLE IF EXISTS catalog_items; DROP TABLE IF EXISTS staff; DROP TABLE IF EXISTS vehicles; DROP TABLE IF EXISTS clients; DROP TABLE IF EXISTS users; ``` - [ ] **Step 9: Verify compilation** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go build ./... ``` Expected: no output (success) - [ ] **Step 10: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/ git commit -m "feat: database package with PostgreSQL pool, migrations and tenant schema provisioning" ``` --- ### Task 4: Redis package **Files:** - Create: `backend/pkg/redis/redis.go` - Create: `backend/pkg/redis/redis_test.go` - [ ] **Step 1: Write the failing test** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/redis/redis_test.go`: ```go package redis_test import ( "context" "os" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" redispkg "github.com/techxcar/backend/pkg/redis" ) func TestNew_invalidURL(t *testing.T) { _, err := redispkg.New("not-a-valid-url") assert.Error(t, err) } func TestNew_valid(t *testing.T) { url := os.Getenv("TEST_REDIS_URL") if url == "" { t.Skip("TEST_REDIS_URL not set, skipping integration test") } rdb, err := redispkg.New(url) require.NoError(t, err) defer rdb.Close() ctx := context.Background() err = rdb.Client.Set(ctx, "test_key", "test_value", time.Second).Err() assert.NoError(t, err) val, err := rdb.Client.Get(ctx, "test_key").Result() assert.NoError(t, err) assert.Equal(t, "test_value", val) } ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./pkg/redis/... -v ``` Expected: FAIL — `package github.com/techxcar/backend/pkg/redis: cannot find package` - [ ] **Step 3: Implement Redis package** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/redis/redis.go`: ```go package redis import ( "context" "fmt" "time" goredis "github.com/redis/go-redis/v9" ) type Redis struct { Client *goredis.Client } func New(redisURL string) (*Redis, error) { opts, err := goredis.ParseURL(redisURL) if err != nil { return nil, fmt.Errorf("redis: invalid URL: %w", err) } client := goredis.NewClient(opts) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { client.Close() return nil, fmt.Errorf("redis: failed to connect: %w", err) } return &Redis{Client: client}, nil } func (r *Redis) Close() error { return r.Client.Close() } ``` - [ ] **Step 4: Run tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./pkg/redis/... -v ``` Expected: ``` === RUN TestNew_invalidURL --- PASS: TestNew_invalidURL === RUN TestNew_valid --- SKIP: TestNew_valid (TEST_REDIS_URL not set) PASS ``` - [ ] **Step 5: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/pkg/redis/ git commit -m "feat: Redis package with connection and ping" ``` --- ### Task 5: HTTP server + health endpoint **Files:** - Create: `backend/internal/server/server.go` - Create: `backend/internal/server/health.go` - Create: `backend/internal/server/health_test.go` - Create: `backend/cmd/server/main.go` - [ ] **Step 1: Write the failing test** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/health_test.go`: ```go package server_test import ( "encoding/json" "net/http/httptest" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/techxcar/backend/internal/server" ) func TestHealthEndpoint_returnsOK(t *testing.T) { app := fiber.New() server.RegisterHealthRoutes(app) req := httptest.NewRequest("GET", "/api/v1/health", nil) resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) var body map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) assert.Equal(t, "ok", body["status"]) assert.NotEmpty(t, body["version"]) } func TestHealthEndpoint_wrongMethod(t *testing.T) { app := fiber.New() server.RegisterHealthRoutes(app) req := httptest.NewRequest("POST", "/api/v1/health", nil) resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 405, resp.StatusCode) } ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./internal/server/... -v ``` Expected: FAIL — `package github.com/techxcar/backend/internal/server: cannot find package` - [ ] **Step 3: Implement health handler** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/health.go`: ```go package server import "github.com/gofiber/fiber/v2" const appVersion = "0.1.0" func RegisterHealthRoutes(app *fiber.App) { app.Get("/api/v1/health", handleHealth) } func handleHealth(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "status": "ok", "version": appVersion, }) } ``` - [ ] **Step 4: Implement server setup** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/server.go`: ```go package server import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/helmet" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" ) func New() *fiber.App { app := fiber.New(fiber.Config{ AppName: "TechXCar API", ErrorHandler: errorHandler, }) app.Use(recover.New()) app.Use(logger.New()) app.Use(helmet.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", AllowCredentials: true, })) RegisterHealthRoutes(app) return app } func errorHandler(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError if e, ok := err.(*fiber.Error); ok { code = e.Code } return c.Status(code).JSON(fiber.Map{ "data": nil, "error": err.Error(), }) } ``` - [ ] **Step 5: Run tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go test ./internal/server/... -v ``` Expected: ``` === RUN TestHealthEndpoint_returnsOK --- PASS: TestHealthEndpoint_returnsOK === RUN TestHealthEndpoint_wrongMethod --- PASS: TestHealthEndpoint_wrongMethod PASS ``` - [ ] **Step 6: Create main.go** Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/cmd/server/main.go`: ```go package main import ( "log" "github.com/joho/godotenv" "github.com/techxcar/backend/internal/config" "github.com/techxcar/backend/internal/server" "github.com/techxcar/backend/pkg/database" redispkg "github.com/techxcar/backend/pkg/redis" ) func main() { if err := godotenv.Load(); err != nil { log.Println("No .env file found, reading from environment") } cfg, err := config.Load() if err != nil { log.Fatal("Config error:", err) } db, err := database.New(cfg.DatabaseURL) if err != nil { log.Fatal("Database error:", err) } defer db.Close() if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil { log.Fatal("Migration error:", err) } rdb, err := redispkg.New(cfg.RedisURL) if err != nil { log.Fatal("Redis error:", err) } defer rdb.Close() app := server.New() log.Printf("TechXCar API v0.1.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv) log.Fatal(app.Listen(":" + cfg.Port)) } ``` - [ ] **Step 7: Verify full build and tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/backend go build ./... go test ./... -v ``` Expected: build success, all tests PASS (integration tests skipped without env vars) - [ ] **Step 8: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add backend/ git commit -m "feat: HTTP server with health endpoint, security middleware and full startup sequence" ``` --- ### Task 6: Frontend scaffold with Tailwind CSS 4 **Files:** - Create: `frontend/` (Vite React TypeScript project) - Modify: `frontend/vite.config.ts` - Modify: `frontend/tsconfig.app.json` - Modify: `frontend/src/index.css` - Modify: `frontend/src/main.tsx` - [ ] **Step 1: Scaffold Vite React TypeScript project** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar npm create vite@latest frontend -- --template react-ts ``` - [ ] **Step 2: Install all dependencies** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm install npm install @tanstack/react-query@^5 zustand@^5 react-router@^7 react-hook-form zod @hookform/resolvers npm install class-variance-authority clsx tailwind-merge lucide-react npm install @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-select @radix-ui/react-separator @radix-ui/react-tooltip npm install -D tailwindcss@^4 @tailwindcss/vite @types/node npm install -D vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/user-event ``` - [ ] **Step 3: Replace vite.config.ts** Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/vite.config.ts`: ```ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' export default defineConfig({ plugins: [ react(), tailwindcss(), ], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, }) ``` - [ ] **Step 4: Replace tsconfig.app.json** Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/tsconfig.app.json`: ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["src"] } ``` - [ ] **Step 5: Create test setup file** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/test/setup.ts`: ```ts import '@testing-library/jest-dom' ``` Install jest-dom: ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm install -D @testing-library/jest-dom ``` - [ ] **Step 6: Replace index.css** Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/index.css`: ```css @import "tailwindcss"; :root { --radius: 0.5rem; } ``` - [ ] **Step 7: Add test script to package.json** In `/var/home/lmilani/Documentos/IDE/techxcar/frontend/package.json`, add to the `scripts` object: ```json "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" ``` - [ ] **Step 8: Verify build** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run build ``` Expected: `dist/` folder created, no TypeScript errors - [ ] **Step 9: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/ git commit -m "feat: frontend scaffold with React 19, Vite, TypeScript, Tailwind CSS 4 and Vitest" ``` --- ### Task 7: shadcn/ui foundation components **Files:** - Create: `frontend/src/lib/utils.ts` - Create: `frontend/src/components/ui/button.tsx` - Create: `frontend/src/components/ui/input.tsx` - Create: `frontend/src/components/ui/label.tsx` - Create: `frontend/src/components/ui/badge.tsx` - [ ] **Step 1: Create utils.ts** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/utils.ts`: ```ts import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` - [ ] **Step 2: Create Button component** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/button.tsx`: ```tsx import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-blue-600 text-white shadow hover:bg-blue-700', destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700', outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900', secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200', ghost: 'hover:bg-gray-100 text-gray-700', link: 'text-blue-600 underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2', sm: 'h-8 rounded-md px-3 text-xs', lg: 'h-10 rounded-md px-8', icon: 'h-9 w-9', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( ) } ) Button.displayName = 'Button' export { Button, buttonVariants } ``` - [ ] **Step 3: Create Input component** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/input.tsx`: ```tsx import * as React from 'react' import { cn } from '@/lib/utils' export interface InputProps extends React.InputHTMLAttributes {} const Input = React.forwardRef( ({ className, type, ...props }, ref) => { return ( ) } ) Input.displayName = 'Input' export { Input } ``` - [ ] **Step 4: Create Label component** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/label.tsx`: ```tsx import * as React from 'react' import * as LabelPrimitive from '@radix-ui/react-label' import { cn } from '@/lib/utils' const Label = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) Label.displayName = LabelPrimitive.Root.displayName export { Label } ``` - [ ] **Step 5: Create Badge component** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/badge.tsx`: ```tsx import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const badgeVariants = cva( 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors', { variants: { variant: { default: 'bg-blue-100 text-blue-800', secondary: 'bg-gray-100 text-gray-800', destructive: 'bg-red-100 text-red-800', success: 'bg-green-100 text-green-800', warning: 'bg-yellow-100 text-yellow-800', outline: 'border border-gray-300 text-gray-700', }, }, defaultVariants: { variant: 'default', }, } ) export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
} export { Badge, badgeVariants } ``` - [ ] **Step 6: Verify build** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run build ``` Expected: build success - [ ] **Step 7: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/ git commit -m "feat: shadcn/ui foundation — Button, Input, Label, Badge" ``` --- ### Task 8: Auth store + API client **Files:** - Create: `frontend/src/store/authStore.ts` - Create: `frontend/src/store/authStore.test.ts` - Create: `frontend/src/lib/api.ts` - Create: `frontend/src/lib/queryClient.ts` - [ ] **Step 1: Write the failing test** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/store/authStore.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest' import { useAuthStore } from './authStore' describe('authStore', () => { beforeEach(() => { useAuthStore.setState({ user: null, accessToken: null, isAuthenticated: false, }) }) it('starts unauthenticated', () => { const state = useAuthStore.getState() expect(state.isAuthenticated).toBe(false) expect(state.user).toBeNull() expect(state.accessToken).toBeNull() }) it('setAuth stores user and token', () => { const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } useAuthStore.getState().setAuth(user, 'tok123') const state = useAuthStore.getState() expect(state.isAuthenticated).toBe(true) expect(state.user).toEqual(user) expect(state.accessToken).toBe('tok123') }) it('clearAuth resets to unauthenticated', () => { const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } useAuthStore.getState().setAuth(user, 'tok123') useAuthStore.getState().clearAuth() const state = useAuthStore.getState() expect(state.isAuthenticated).toBe(false) expect(state.user).toBeNull() expect(state.accessToken).toBeNull() }) it('updateToken replaces only the token', () => { const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } useAuthStore.getState().setAuth(user, 'old-token') useAuthStore.getState().updateToken('new-token') const state = useAuthStore.getState() expect(state.accessToken).toBe('new-token') expect(state.user).toEqual(user) }) }) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run test:run ``` Expected: FAIL — `Cannot find module './authStore'` - [ ] **Step 3: Implement auth store** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/store/authStore.ts`: ```ts import { create } from 'zustand' import { persist } from 'zustand/middleware' export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician' export interface AuthUser { id: string email: string name: string role: UserRole tenantId?: string } interface AuthState { user: AuthUser | null accessToken: string | null isAuthenticated: boolean setAuth: (user: AuthUser, accessToken: string) => void clearAuth: () => void updateToken: (accessToken: string) => void } export const useAuthStore = create()( persist( (set) => ({ user: null, accessToken: null, isAuthenticated: false, setAuth: (user, accessToken) => set({ user, accessToken, isAuthenticated: true }), clearAuth: () => set({ user: null, accessToken: null, isAuthenticated: false }), updateToken: (accessToken) => set({ accessToken }), }), { name: 'techxcar-auth', partialize: (state) => ({ user: state.user, accessToken: state.accessToken, isAuthenticated: state.isAuthenticated, }), } ) ) ``` - [ ] **Step 4: Run tests** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run test:run ``` Expected: ``` ✓ authStore > starts unauthenticated ✓ authStore > setAuth stores user and token ✓ authStore > clearAuth resets to unauthenticated ✓ authStore > updateToken replaces only the token ``` - [ ] **Step 5: Create API client** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/api.ts`: ```ts import { useAuthStore } from '@/store/authStore' const BASE_URL = '/api/v1' export class ApiError extends Error { constructor( public status: number, message: string ) { super(message) this.name = 'ApiError' } } async function refreshAccessToken(): Promise { try { const res = await fetch(`${BASE_URL}/auth/refresh`, { method: 'POST', credentials: 'include', }) if (!res.ok) return null const data = await res.json() useAuthStore.getState().updateToken(data.data.access_token) return data.data.access_token } catch { return null } } export async function apiFetch( path: string, options: RequestInit = {} ): Promise { const { accessToken, clearAuth } = useAuthStore.getState() const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record), } if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}` } let res = await fetch(`${BASE_URL}${path}`, { ...options, headers, credentials: 'include', }) if (res.status === 401 && accessToken) { const newToken = await refreshAccessToken() if (newToken) { headers['Authorization'] = `Bearer ${newToken}` res = await fetch(`${BASE_URL}${path}`, { ...options, headers, credentials: 'include', }) } else { clearAuth() throw new ApiError(401, 'Sessão expirada. Por favor inicie sessão novamente.') } } const json = await res.json() if (!res.ok) { throw new ApiError(res.status, json.error ?? 'Erro desconhecido') } return json.data as T } ``` - [ ] **Step 6: Create TanStack Query client** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/queryClient.ts`: ```ts import { QueryClient } from '@tanstack/react-query' import { ApiError } from './api' export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: (failureCount, error) => { if (error instanceof ApiError && error.status < 500) return false return failureCount < 2 }, }, mutations: { retry: false, }, }, }) ``` - [ ] **Step 7: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/ git commit -m "feat: Zustand auth store with persistence and API client with auto token refresh" ``` --- ### Task 9: Frontend routing skeleton **Files:** - Create: `frontend/src/App.tsx` - Create: `frontend/src/pages/auth/LoginPage.tsx` - Create: `frontend/src/pages/app/DashboardPage.tsx` - Create: `frontend/src/pages/admin/DashboardPage.tsx` - Create: `frontend/src/components/layout/AppLayout.tsx` - Create: `frontend/src/components/layout/AdminLayout.tsx` - [ ] **Step 1: Create App.tsx with routing and auth guards** Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/App.tsx`: ```tsx import { BrowserRouter, Routes, Route, Navigate } from 'react-router' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient } from '@/lib/queryClient' import { useAuthStore } from '@/store/authStore' import type { UserRole } from '@/store/authStore' import LoginPage from '@/pages/auth/LoginPage' import AppDashboardPage from '@/pages/app/DashboardPage' import AdminDashboardPage from '@/pages/admin/DashboardPage' import AppLayout from '@/components/layout/AppLayout' import AdminLayout from '@/components/layout/AdminLayout' function RequireAuth({ children, allowedRoles, }: { children: React.ReactNode allowedRoles: UserRole[] }) { const { isAuthenticated, user } = useAuthStore() if (!isAuthenticated) return if (user && !allowedRoles.includes(user.role)) return return <>{children} } export default function App() { return ( } /> } > } /> } > } /> } /> ) } ``` - [ ] **Step 2: Create Login page placeholder** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/auth/LoginPage.tsx`: ```tsx export default function LoginPage() { return (

TechXCar

Gestão de Oficina

Login — implementado no Plano 2

) } ``` - [ ] **Step 3: Create App layout with sidebar shell** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/layout/AppLayout.tsx`: ```tsx import { Outlet } from 'react-router' import { useAuthStore } from '@/store/authStore' export default function AppLayout() { const { user } = useAuthStore() return (
Oficina
) } ``` - [ ] **Step 4: Create Admin layout** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/layout/AdminLayout.tsx`: ```tsx import { Outlet } from 'react-router' export default function AdminLayout() { return (
) } ``` - [ ] **Step 5: Create App dashboard placeholder** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/app/DashboardPage.tsx`: ```tsx export default function DashboardPage() { return (

Dashboard

Bem-vindo ao TechXCar — implementado no Plano 5

) } ``` - [ ] **Step 6: Create Admin dashboard placeholder** Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/admin/DashboardPage.tsx`: ```tsx export default function AdminDashboardPage() { return (

Painel de Administração

Gestão da plataforma — implementado no Plano 2

) } ``` - [ ] **Step 7: Build to verify no TypeScript errors** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar/frontend npm run build ``` Expected: build success, zero TypeScript errors - [ ] **Step 8: Commit** ```bash cd /var/home/lmilani/Documentos/IDE/techxcar git add frontend/src/ git commit -m "feat: React Router v7 routing skeleton with role-based auth guards and layout shells" ``` --- ## O que este plano entrega - Monorepo com Docker Compose pronto para Coolify (4 serviços: postgres, redis, backend, frontend) - Backend Go compilável com config tipado, pool PostgreSQL, conexão Redis e migrações automáticas ao startup - Schema público (tenants, invites, super_admins, platform_settings) e schema tenant completo (12 tabelas) - Endpoint `/api/v1/health` testado com Fiber - Frontend React 19 com Vite, TypeScript, Tailwind CSS 4, shadcn/ui base, auth store com persistência, API client com refresh automático de token e routing protegido por role ## Planos seguintes | Plano | Âmbito | |---|---| | **Plano 2** | Auth & Multi-tenancy: login JWT, criação de tenants, sistema de convites, painel super-admin | | **Plano 3** | Core Workshop: Clientes, Veículos, Catálogo, Ordens de Trabalho (CRUD completo + máquina de estados) | | **Plano 4** | Faturação, Técnicos & Despesas: Faturas/Orçamentos, geração PDF com chromedp, gestão de staff, despesas | | **Plano 5** | Notificações, Dashboard & Relatórios: worker Telegram/Email, KPIs, relatórios, exportação CSV/PDF |