Files
techxcar/docs/superpowers/plans/2026-06-16-plan1-foundation.md
T
Luciano Milani 5de37bb512 Inicial
2026-07-02 12:47:55 +01:00

2069 lines
53 KiB
Markdown

# 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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
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<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
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<AuthState>()(
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<string | null> {
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<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const { accessToken, clearAuth } = useAuthStore.getState()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
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 <Navigate to="/login" replace />
if (user && !allowedRoles.includes(user.role)) return <Navigate to="/login" replace />
return <>{children}</>
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/admin"
element={
<RequireAuth allowedRoles={['super_admin']}>
<AdminLayout />
</RequireAuth>
}
>
<Route index element={<AdminDashboardPage />} />
</Route>
<Route
path="/app"
element={
<RequireAuth allowedRoles={['tenant_admin', 'manager', 'technician']}>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<AppDashboardPage />} />
</Route>
<Route path="/" element={<Navigate to="/app" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
```
- [ ] **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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
<p className="text-gray-500 mt-2 text-sm">Gestão de Oficina</p>
<p className="text-gray-400 mt-6 text-xs">Login implementado no Plano 2</p>
</div>
</div>
)
}
```
- [ ] **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 (
<div className="flex h-screen bg-gray-50">
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<h1 className="text-lg font-bold text-gray-900">TechXCar</h1>
<p className="text-xs text-gray-500 mt-0.5 truncate">{user?.name}</p>
</div>
<nav className="flex-1 p-3">
<p className="text-xs text-gray-400 px-2">Navegação Plano 2</p>
</nav>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
<header className="h-14 bg-white border-b border-gray-200 px-6 flex items-center justify-between">
<span className="text-sm text-gray-500">Oficina</span>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
</div>
)
}
```
- [ ] **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 (
<div className="flex h-screen bg-slate-950">
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
<div className="p-4 border-b border-slate-800">
<h1 className="text-lg font-bold text-white">TechXCar</h1>
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
</div>
<nav className="flex-1 p-3">
<p className="text-xs text-slate-500 px-2">Navegação Plano 2</p>
</nav>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<Outlet />
</main>
</div>
)
}
```
- [ ] **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 (
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1 text-sm">Bem-vindo ao TechXCar implementado no Plano 5</p>
</div>
)
}
```
- [ ] **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 (
<div>
<h1 className="text-2xl font-bold">Painel de Administração</h1>
<p className="text-slate-400 mt-1 text-sm">Gestão da plataforma implementado no Plano 2</p>
</div>
)
}
```
- [ ] **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 |