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

53 KiB

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:

# 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:

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:

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:

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:

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:

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
cp /var/home/lmilani/Documentos/IDE/techxcar/.env.example /var/home/lmilani/Documentos/IDE/techxcar/.env
  • Step 9: Initialise git and commit
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:

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
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
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:

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
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
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:

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
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:

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
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:

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:

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:

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:

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
cd /var/home/lmilani/Documentos/IDE/techxcar/backend
go build ./...

Expected: no output (success)

  • Step 10: Commit
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:

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
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:

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
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
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:

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
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:

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:

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
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:

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
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
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

cd /var/home/lmilani/Documentos/IDE/techxcar
npm create vite@latest frontend -- --template react-ts
  • Step 2: Install all dependencies
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:

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:

{
  "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:

import '@testing-library/jest-dom'

Install jest-dom:

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:

@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:

"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
  • Step 8: Verify build
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build

Expected: dist/ folder created, no TypeScript errors

  • Step 9: Commit
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:

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:

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:

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:

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:

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
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build

Expected: build success

  • Step 7: Commit
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:

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
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:

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
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:

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:

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
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:

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:

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:

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:

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:

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:

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
cd /var/home/lmilani/Documentos/IDE/techxcar/frontend
npm run build

Expected: build success, zero TypeScript errors

  • Step 8: Commit
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