This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,633 @@
# Super Admin Tenant Access — Implementation Plan
> **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:** Fix TenantMiddleware schema name bug and add super-admin tenant impersonation with session restore.
**Architecture:** One-line backend bug fix; new `POST /api/v1/admin/tenants/:id/access` endpoint issues a tenant-scoped JWT; frontend authStore grows `previousSession` + two actions; TenantsPage adds "Gerir" button; AppLayout renders amber banner when impersonating.
**Tech Stack:** Go 1.25 + Fiber v2, pgxpool, golang-jwt v5, React 19 + TypeScript strict, Zustand v5, TanStack Query v5
## Global Constraints
- Go module: `github.com/techxcar/backend`
- All user-facing error messages in Português de Portugal (pt-PT)
- API envelope: `{ "data": ..., "error": null }`
- JWT: access 15 min, HS256
- Tenant schema name: `tenant_` + UUID with hyphens replaced by underscores
- Frontend alias: `@/``src/`
- No new npm packages
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `backend/internal/auth/middleware.go` | Modify | Fix schema name construction (line 58) |
| `backend/internal/tenant/handler.go` | Modify | Add `tenantAccessHandler` |
| `backend/internal/tenant/routes.go` | Modify | Register new endpoint |
| `frontend/src/store/authStore.ts` | Modify | Add `previousSession`, `impersonateTenant`, `restoreSession` |
| `frontend/src/pages/admin/TenantsPage.tsx` | Modify | Add "Gerir" button per row |
| `frontend/src/components/layout/AppLayout.tsx` | Modify | Add impersonation banner |
---
### Task 1: Fix TenantMiddleware schema name
**Files:**
- Modify: `backend/internal/auth/middleware.go`
**Interfaces:**
- Consumes: `claims.TenantID` (UUID string with hyphens, e.g. `590b9965-9f7b-4427-b1c8-2b695610e7bd`)
- Produces: `search_path` set to `"tenant_590b9965_9f7b_4427_b1c8_2b695610e7bd", public`
- [ ] **Step 1: Write failing test**
Create `backend/internal/auth/middleware_test.go`:
```go
package auth_test
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/techxcar/backend/internal/auth"
)
func TestRequireRole_allowsMatchingRole(t *testing.T) {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("claims", &auth.Claims{Role: "tenant_admin"})
return c.Next()
})
app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
func TestRequireRole_rejectsMismatch(t *testing.T) {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("claims", &auth.Claims{Role: "technician"})
return c.Next()
})
app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
}
```
- [ ] **Step 2: Run test to verify it passes (baseline)**
```bash
cd backend && go test ./internal/auth/... -v -run TestRequireRole
```
Expected: PASS (these test existing behavior, not the bug)
- [ ] **Step 3: Apply the bug fix**
In `backend/internal/auth/middleware.go`, line 58, replace:
```go
schema := `"tenant_` + claims.TenantID + `"`
if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
```
with:
```go
schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"`
if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
```
Verify `strings` is already imported (it is — `strings.SplitN` is used in `RequireAuth`).
- [ ] **Step 4: Run full auth package tests**
```bash
cd backend && go test ./internal/auth/... -v
```
Expected: all PASS
- [ ] **Step 5: Smoke test tenant login via curl**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@oficina.pt","password":"password123","tenant_slug":"oficina-demo"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['access_token'] if d['error'] is None else 'FAIL: '+d['error'])")
echo $TOKEN
```
Expected: JWT token string (not "FAIL:...")
- [ ] **Step 6: Commit**
```bash
git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go
git commit -m "fix: TenantMiddleware schema name uses underscores to match provisioned schema"
```
---
### Task 2: Backend — tenant access endpoint
**Files:**
- Modify: `backend/internal/tenant/handler.go`
- Modify: `backend/internal/tenant/routes.go`
**Interfaces:**
- Consumes: `repo.GetTenantByID(ctx, id)``*Tenant`; `auth.GenerateAccessToken(userID, tenantID, role, secret)``(string, error)`; `auth.Claims` with `UserID`, `TenantID`, `Role`
- Produces: `POST /api/v1/admin/tenants/:id/access``{ "data": { "access_token": string, "tenant": { "id": string, "name": string, "slug": string } }, "error": null }`
- [ ] **Step 1: Write failing handler test**
Add to `backend/internal/tenant/handler_test.go` (find the existing test file and append):
```go
func TestTenantAccessHandler(t *testing.T) {
secret := "test-secret-32-chars-minimum-len"
app := fiber.New(fiber.Config{ErrorHandler: func(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()})
}})
tok, _ := auth.GenerateAccessToken("sa-1", "", "super_admin", secret)
app.Use(func(c *fiber.Ctx) error {
c.Locals("claims", &auth.Claims{UserID: "sa-1", TenantID: "", Role: "super_admin"})
return c.Next()
})
cfg := &config.Config{JWTSecret: secret}
repo := &mockRepo{
tenant: &Tenant{ID: "abc-123", Slug: "t1", Name: "Tenant 1", Status: "active"},
}
app.Post("/admin/tenants/:id/access",
auth.RequireRole("super_admin"),
tenantAccessHandler(repo, cfg),
)
req := httptest.NewRequest("POST", "/admin/tenants/abc-123/access", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var body struct {
Data struct {
AccessToken string `json:"access_token"`
Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
} `json:"tenant"`
} `json:"data"`
Error *string `json:"error"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Nil(t, body.Error)
assert.NotEmpty(t, body.Data.AccessToken)
assert.Equal(t, "abc-123", body.Data.Tenant.ID)
assert.Equal(t, "Tenant 1", body.Data.Tenant.Name)
}
```
Note: check existing `handler_test.go` for how `mockRepo` is defined and extend it with a `tenant` field + `GetTenantByID` method if not already present.
- [ ] **Step 2: Run test to verify it fails**
```bash
cd backend && go test ./internal/tenant/... -v -run TestTenantAccessHandler
```
Expected: FAIL — `tenantAccessHandler undefined`
- [ ] **Step 3: Implement the handler**
Add to `backend/internal/tenant/handler.go`:
```go
func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, ok := c.Locals("claims").(*auth.Claims)
if !ok {
return fiber.NewError(401, "autenticação necessária")
}
tenantID := c.Params("id")
ten, err := repo.GetTenantByID(c.Context(), tenantID)
if err != nil || ten == nil {
return fiber.NewError(404, "oficina não encontrada")
}
if ten.Status != "active" {
return fiber.NewError(404, "oficina não encontrada ou inativa")
}
token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao gerar token")
}
return c.JSON(fiber.Map{
"data": fiber.Map{
"access_token": token,
"tenant": fiber.Map{
"id": ten.ID,
"name": ten.Name,
"slug": ten.Slug,
},
},
"error": nil,
})
}
}
```
- [ ] **Step 4: Register the route**
In `backend/internal/tenant/routes.go`, inside the `admin` group (after existing routes):
```go
admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg))
```
The existing `admin` group already has `RequireAuth` + `RequireRole("super_admin")` — no extra middleware needed.
- [ ] **Step 5: Run tests**
```bash
cd backend && go test ./internal/tenant/... -v -run TestTenantAccess
```
Expected: PASS
- [ ] **Step 6: Smoke test via curl**
```bash
# Login as super admin
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])")
# Get tenant ID from list
TENANT_ID=$(curl -s http://localhost:8080/api/v1/admin/tenants \
-H "Authorization: Bearer $TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['data'][0]['id'])")
# Request tenant-scoped token
curl -s -X POST http://localhost:8080/api/v1/admin/tenants/$TENANT_ID/access \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
```
Expected: `{ "data": { "access_token": "eyJ...", "tenant": { "id": "...", "name": "...", "slug": "..." } }, "error": null }`
- [ ] **Step 7: Rebuild backend container**
```bash
docker compose up --build -d backend
```
Wait for healthy, re-run smoke test against port 8080.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/tenant/handler.go backend/internal/tenant/routes.go backend/internal/tenant/handler_test.go
git commit -m "feat: tenant access endpoint — super admin can get tenant-scoped JWT"
```
---
### Task 3: Frontend — authStore impersonation
**Files:**
- Modify: `frontend/src/store/authStore.ts`
**Interfaces:**
- Consumes: existing `AuthUser`, `accessToken` state
- Produces:
- `previousSession: { token: string; user: AuthUser } | null` (NOT persisted)
- `impersonateTenant(token: string, user: AuthUser): void`
- `restoreSession(): void`
- [ ] **Step 1: Update authStore.ts**
Replace the entire file content:
```typescript
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 PreviousSession {
token: string
user: AuthUser
}
interface AuthState {
user: AuthUser | null
accessToken: string | null
isAuthenticated: boolean
previousSession: PreviousSession | null
setAuth: (user: AuthUser, accessToken: string) => void
clearAuth: () => void
updateToken: (accessToken: string) => void
impersonateTenant: (token: string, user: AuthUser) => void
restoreSession: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
previousSession: null,
setAuth: (user, accessToken) =>
set({ user, accessToken, isAuthenticated: true, previousSession: null }),
clearAuth: () =>
set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }),
updateToken: (accessToken) =>
set({ accessToken }),
impersonateTenant: (token, user) => {
const { accessToken, user: currentUser } = get()
set({
accessToken: token,
user,
isAuthenticated: true,
previousSession: currentUser && accessToken
? { token: accessToken, user: currentUser }
: null,
})
},
restoreSession: () => {
const { previousSession } = get()
if (!previousSession) return
set({
accessToken: previousSession.token,
user: previousSession.user,
isAuthenticated: true,
previousSession: null,
})
},
}),
{
name: 'techxcar-auth',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
isAuthenticated: state.isAuthenticated,
// previousSession intentionally excluded — impersonation does not survive refresh
}),
}
)
)
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: build succeeds (or only pre-existing errors, none from authStore)
- [ ] **Step 3: Commit**
```bash
git add frontend/src/store/authStore.ts
git commit -m "feat: authStore gains impersonateTenant and restoreSession for super-admin tenant access"
```
---
### Task 4: Frontend — TenantsPage "Gerir" button
**Files:**
- Modify: `frontend/src/pages/admin/TenantsPage.tsx`
**Interfaces:**
- Consumes:
- `POST /api/v1/admin/tenants/:id/access``{ access_token: string, tenant: { id, name, slug } }`
- `useAuthStore().impersonateTenant(token, user)`
- `useNavigate()` from `react-router`
- Produces: button per row that triggers impersonation and navigates to `/app`
- [ ] **Step 1: Add the "Gerir" mutation and button**
In `frontend/src/pages/admin/TenantsPage.tsx`:
1. Add imports at top:
```typescript
import { useNavigate } from 'react-router'
import { useAuthStore } from '@/store/authStore'
import type { AuthUser } from '@/store/authStore'
```
2. Inside `TenantsPage` component, after existing mutations, add:
```typescript
const navigate = useNavigate()
const { impersonateTenant } = useAuthStore()
const accessTenant = useMutation({
mutationFn: (tenantId: string) =>
apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>(
`/admin/tenants/${tenantId}/access`,
{ method: 'POST' }
),
onSuccess: (data) => {
const tenantUser: AuthUser = {
id: data.tenant.id,
email: '',
name: data.tenant.name,
role: 'tenant_admin',
tenantId: data.tenant.id,
}
impersonateTenant(data.access_token, tenantUser)
queryClient.clear()
navigate('/app')
},
})
```
3. In the table `<tbody>`, add a fifth column header `<th>` in `<thead>`:
```tsx
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
```
4. In each `<tr>` inside the `.map()`, add a fifth `<td>` after the "Criada" cell:
```tsx
<td className="px-4 py-3">
<Button
size="sm"
variant="outline"
onClick={() => accessTenant.mutate(t.id)}
disabled={accessTenant.isPending}
>
{accessTenant.isPending ? '...' : 'Gerir'}
</Button>
</td>
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd frontend && npm run build 2>&1 | tail -15
```
Expected: build succeeds
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/admin/TenantsPage.tsx
git commit -m "feat: TenantsPage — Gerir button triggers tenant impersonation"
```
---
### Task 5: Frontend — AppLayout impersonation banner
**Files:**
- Modify: `frontend/src/components/layout/AppLayout.tsx`
**Interfaces:**
- Consumes: `useAuthStore().previousSession`, `useAuthStore().restoreSession()`, `useNavigate()`
- Produces: amber banner at top when `previousSession !== null`; "Voltar ao painel" button restores session and navigates `/admin`
- [ ] **Step 1: Add the banner to AppLayout**
Replace `frontend/src/components/layout/AppLayout.tsx` with:
```tsx
import { Outlet, NavLink, useNavigate } from 'react-router'
import { useLogout } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/authStore'
const nav = [
{ to: '/app', label: 'Dashboard', end: true },
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
{ to: '/app/clients', label: 'Clientes' },
{ to: '/app/catalog', label: 'Catálogo' },
]
export default function AppLayout() {
const { mutate: logout } = useLogout()
const { previousSession, restoreSession, user } = useAuthStore()
const navigate = useNavigate()
function handleRestore() {
restoreSession()
navigate('/admin')
}
return (
<div className="flex flex-col h-screen bg-slate-950">
{previousSession && (
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
<span>
TechXCar Admin a gerir: <strong>{user?.name}</strong>
</span>
<button
onClick={handleRestore}
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
>
Voltar ao painel
</button>
</div>
)}
<div className="flex flex-1 overflow-hidden">
<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">Gestão de Oficina</p>
</div>
<nav className="flex-1 p-3 space-y-1">
{nav.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="p-3 border-t border-slate-800">
<button
onClick={() => logout()}
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
>
Terminar sessão
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6 text-white">
<Outlet />
</main>
</div>
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd frontend && npm run build 2>&1 | tail -10
```
Expected: build succeeds
- [ ] **Step 3: Rebuild frontend container and smoke test**
```bash
docker compose up --build -d frontend
```
Then in browser:
1. Log in as `admin@techxcar.com` / `TechXCar2026!`
2. Go to `/admin/tenants`
3. Click "Gerir" on a tenant row
4. Verify: navigates to `/app`, amber banner shows "TechXCar Admin — a gerir: Oficina Demo"
5. Click "← Voltar ao painel"
6. Verify: back at `/admin`, super_admin session restored, no banner
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/layout/AppLayout.tsx
git commit -m "feat: AppLayout shows impersonation banner with session restore for super admin"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
# TechXCar — Design Spec
**Data:** 2026-06-16
**Versão:** 1.0
---
## 1. Visão Geral
**TechXCar** é um SaaS multi-tenant de gestão de oficina automóvel. Cada oficina tem o seu espaço isolado com clientes, veículos, ordens de trabalho, faturação, técnicos e relatórios. A plataforma é gerida por um super-admin que cria e convida oficinas manualmente.
**Inspiração:** LubeLogger, mas focado na gestão operacional da oficina (não no diário do proprietário do veículo).
---
## 2. Stack Técnica
| Camada | Tecnologia |
|---|---|
| Frontend | React 19 + TypeScript + Vite |
| Backend | Go + Fiber (framework HTTP) |
| Base de dados | PostgreSQL 16 |
| Cache / Filas | Redis 7 |
| Auth | JWT (access 15min + refresh 30d, httpOnly cookie) |
| Passwords | bcrypt cost 12 |
| PDF | Geração server-side em Go (html/template → PDF) |
| Notificações | Telegram Bot API + SMTP |
| Frontend UI | Tailwind CSS 4 + shadcn/ui |
| Estado cliente | TanStack Query + Zustand |
| Formulários | React Hook Form + Zod |
| Roteamento | React Router v7 |
| Migrations | golang-migrate |
| Deploy | Docker Compose → Coolify |
---
## 3. Arquitectura de Deploy
### Serviços Docker
```
techxcar/
├── frontend/ # React 19 + TypeScript + Vite (servido via Nginx)
├── backend/ # Go + Fiber
├── docker-compose.yml # Desenvolvimento
└── docker-compose.prod.yml # Produção (Coolify)
```
Quatro serviços:
- `frontend` — Nginx a servir o build Vite na porta 80
- `backend` — Binário Go na porta 8080
- `postgres` — PostgreSQL 16
- `redis` — Redis 7
### Configuração em dois níveis
1. **`.env`** — valores de arranque imutáveis em runtime: `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT`, `APP_ENV`. Requerem reinício do container para alterar.
2. **`platform_settings` / `tenant_settings` (DB)** — configuração dinâmica gerida pela Web UI sem redeploy: SMTP, Telegram Bot Token, templates de notificação, limites, dados de faturação. Cached em Redis por 5 minutos.
### Coolify
- Secrets geridos pelo Coolify (injectados como variáveis de ambiente)
- HTTPS automático via Coolify (Let's Encrypt)
- Health checks em `/api/v1/health`
---
## 4. Multi-tenancy
### Estratégia: Schema-per-tenant no PostgreSQL
- Schema `public`: tabelas globais (`tenants`, `invites`, `super_admins`, `platform_settings`)
- Schema `tenant_<uuid>`: dados isolados de cada oficina
- Middleware no backend define `SET search_path = tenant_<uuid>` por request, com base no JWT
### Onboarding de oficinas
**Via convite:** Super-admin gera token assinado com expiração configurável. Oficina clica no link, preenche nome, NIF, email, password → schema PostgreSQL provisionado automaticamente via migrations.
**Via criação manual:** Super-admin cria a oficina no painel `/admin`, define credenciais iniciais, envia email de boas-vindas.
---
## 5. Autenticação & Autorização
### Níveis de acesso
```
super_admin — gestão da plataforma, todos os tenants
tenant_admin — dono/gestor principal da oficina
manager — gestor operacional, sem acesso a settings financeiras
technician — técnico interno, acesso a OTs atribuídas
external_technician — sem login; apenas para atribuição e registo de custos
```
### Fluxo JWT
- `POST /api/v1/auth/login` → devolve `access_token` (body) + `refresh_token` (httpOnly cookie)
- `POST /api/v1/auth/refresh` → renova access token usando refresh token do cookie
- `POST /api/v1/auth/logout` → invalida refresh token
- Rate limiting: 10 tentativas/min por IP nas rotas de auth (Redis)
---
## 6. Módulos de Negócio
### 6.1 Clientes & Veículos
- **Cliente**: nome, NIF, telefone, email, morada, notas
- **Veículo**: pertence a um cliente (opcional), matrícula, marca, modelo, ano, VIN, km actual
- Um cliente pode ter N veículos
- OT pode ser criada sem cliente nem veículo (cliente temporário)
### 6.2 Ordens de Trabalho (OT)
**Estados e transições:**
```
Aberta → Em Progresso → Concluída → Faturada
Cancelada (de qualquer estado excepto Faturada)
```
**Conteúdo de uma OT:**
- Cliente + Veículo (ambos opcionais)
- Items: serviços/peças do catálogo (qty, preço unitário, desconto por item)
- Técnico(s) atribuído(s) + registo de horas (horas × custo/hora)
- Notas internas (não visíveis ao cliente)
- Notas para o cliente (aparecem no PDF)
- Log de estados: timestamp + utilizador responsável por cada transição
- Total calculado: soma de items + mão de obra
### 6.3 Catálogo de Serviços & Peças
- Item: código, nome, categoria, unidade (un/hora/litro/kg), preço base, activo/inactivo
- Categorias livres (criadas pela oficina)
- Ao adicionar a OT: preço copiado do catálogo mas editável por OT
- Pesquisa por código ou nome no momento de adição à OT
### 6.4 Faturação & Orçamentos
- **Orçamento**: gerado a partir de OT em estado "Aberta", PDF com logo e dados da oficina
- **Fatura**: gerada quando OT passa a "Faturada", numeração sequencial por oficina
- PDFs gerados server-side em Go com **`chromedp`** (render HTML → PDF via Chrome headless); armazenados localmente (volume Docker) ou S3-compatible (configurável nas Settings)
- Dados da oficina para PDF: logo, nome, NIF, morada, IBAN — configuráveis nas Settings da Web UI
### 6.5 Técnicos (Staff)
- **Interno**: tem conta no sistema (role `technician`), aparece na atribuição de OTs
- **Externo**: registo simplificado (nome, contacto, custo/hora), sem login
- Registo de horas por OT: horas trabalhadas × custo/hora = custo de mão de obra
- Listagem com estado activo/inactivo
### 6.6 Combustíveis & Despesas da Oficina
- Registo: data, tipo (combustível / peças / ferramentas / outros), valor, descrição, veículo (opcional)
- Filtros por período e tipo
- Totais agregados no dashboard e relatórios
### 6.7 Notificações ao Cliente
- **Telegram**: token do bot configurável nas Settings; envia mensagem quando OT muda para "Concluída" ou "Faturada"
- **Email**: SMTP configurável nas Settings; template HTML editável na Web UI
- Configurável por oficina: quais eventos disparam notificação e qual canal
- Envio assíncrono: o handler HTTP publica um job numa lista Redis; uma goroutine dedicada consome a lista e envia as notificações (não bloqueia o request HTTP)
### 6.8 Dashboard & Relatórios
**Dashboard (tempo real):**
- Receita do mês actual vs mês anterior
- Nº de OTs por estado (cards)
- OTs abertas recentes (tabela)
- Serviços mais realizados (top 5)
**Relatórios (com filtro de período):**
- Receita por período (dia/semana/mês)
- OTs por estado e por técnico
- Despesas vs Receita
- Exportação em PDF e CSV
---
## 7. Estrutura do Backend Go
```
backend/
├── cmd/server/main.go
├── internal/
│ ├── auth/ # JWT, middleware, bcrypt
│ ├── tenant/ # Gestão tenants, schema provisioning
│ ├── workorder/ # Ordens de trabalho
│ ├── client/ # Clientes & veículos
│ ├── catalog/ # Serviços & peças
│ ├── invoice/ # Faturação, geração PDF
│ ├── staff/ # Técnicos
│ ├── expense/ # Combustíveis & despesas
│ ├── notification/ # Telegram + Email (worker assíncrono)
│ ├── report/ # Relatórios & exportações
│ └── settings/ # Settings dinâmicas (DB + cache Redis)
├── pkg/
│ ├── database/ # Pool PostgreSQL, migrations
│ ├── redis/ # Cliente Redis
│ └── pdf/ # Geração PDF
└── migrations/
├── public/ # Schema público
└── tenant/ # Schema tenant (aplicado no provisionamento)
```
### API
- Base: `/api/v1/`
- Autenticação: Bearer token no header `Authorization`
- Tenant resolvido via claim `tenant_id` no JWT
- Respostas: `{ "data": ..., "error": null, "meta": { "page": ..., "total": ... } }`
- Paginação cursor-based
---
## 8. Estrutura do Frontend React
```
frontend/
├── src/
│ ├── pages/
│ │ ├── auth/ # Login
│ │ ├── admin/ # Super-admin (dashboard, tenants, settings)
│ │ └── app/ # Área da oficina
│ │ ├── dashboard/
│ │ ├── work-orders/
│ │ ├── clients/
│ │ ├── vehicles/
│ │ ├── catalog/
│ │ ├── staff/
│ │ ├── expenses/
│ │ ├── reports/
│ │ └── settings/
│ ├── components/
│ │ ├── ui/ # shadcn/ui base components
│ │ └── shared/ # Componentes reutilizáveis da app
│ ├── hooks/ # Custom hooks (useAuth, useTenant, etc.)
│ ├── lib/
│ │ ├── api.ts # Cliente HTTP (fetch + interceptors)
│ │ └── queryClient.ts # TanStack Query config
│ ├── store/ # Zustand stores
│ └── i18n/ # Estrutura i18n (PT por defeito, EN preparado)
```
### Rotas protegidas
- `/login` — público
- `/admin/*` — requer role `super_admin`
- `/app/*` — requer role `tenant_admin`, `manager` ou `technician`
- Guards no React Router v7 redireccionam para `/login` se JWT expirado
---
## 9. Base de Dados — Schemas
### Schema `public`
```sql
tenants (id uuid PK, slug text UNIQUE, name text, status, plan, created_at)
invites (id uuid PK, tenant_id uuid FK, token text UNIQUE, expires_at, used_at nullable)
super_admins (id uuid PK, email text UNIQUE, password_hash text, created_at)
platform_settings (key text PK, value text, updated_at)
```
### Schema `tenant_<uuid>` (por oficina)
```sql
users (id uuid PK, email, password_hash, role, name, active)
clients (id uuid PK, name, nif, phone, email, address, notes, created_at)
vehicles (id uuid PK, client_id uuid nullable FK, plate, brand, model, year, vin, mileage, notes)
work_orders (id uuid PK, number serial, client_id nullable, vehicle_id nullable, status, internal_notes, client_notes, created_by, created_at, updated_at)
wo_items (id uuid PK, work_order_id FK, catalog_item_id nullable FK, description, qty, unit_price, discount_pct, total)
wo_staff_hours (id uuid PK, work_order_id FK, staff_id FK, hours, cost_per_hour, total)
wo_status_log (id uuid PK, work_order_id FK, from_status, to_status, changed_by FK, changed_at)
catalog_items (id uuid PK, code, name, category, unit, base_price, active)
invoices (id uuid PK, work_order_id FK, type: quote|invoice, number serial, pdf_path, issued_at)
staff (id uuid PK, user_id nullable FK, name, email, phone, type: internal|external, hourly_rate, active)
expenses (id uuid PK, vehicle_id nullable FK, type, amount, description, date)
tenant_settings (key text PK, value text, updated_at)
```
---
## 10. Internacionalização (i18n)
- Estrutura i18n no frontend desde o início (ficheiros JSON por locale em `src/i18n/`)
- Idioma padrão e único no MVP: **Português de Portugal (pt-PT)**
- Inglês (en) preparado na estrutura mas não traduzido no MVP
- Datas, moeda e números formatados com `Intl` API (locale `pt-PT`, moeda `EUR`)
---
## 11. Segurança
- HTTPS obrigatório em produção (Coolify / Let's Encrypt)
- JWT com expiração curta + refresh token rotativo em httpOnly cookie
- Passwords com bcrypt cost 12
- Rate limiting em todas as rotas de auth (Redis)
- Row-level isolation via PostgreSQL `search_path` por tenant
- Headers de segurança: CSP, HSTS, X-Frame-Options (middleware Fiber)
- Inputs validados com Zod (frontend) e struct validation em Go (backend)
- Ficheiros PDF armazenados fora do webroot, servidos via endpoint autenticado
---
## 12. Fora de Âmbito (MVP)
- Billing automático / Stripe
- App mobile nativa
- Integração com sistemas de diagnóstico OBD
- Portal self-service para clientes da oficina
- Multi-idioma completo (EN traduzido)
- Backups automáticos (responsabilidade do Coolify/infra)
@@ -0,0 +1,175 @@
# TechXCar — Plan 3: Core Workshop Pages
**Date:** 2026-06-22
**Status:** Approved
## Scope
Build the 3 missing tenant-facing pages: Clients, Catalog, Work Orders. Backend has all 31 endpoints ready. Frontend needs hooks, pages, modals, and routing wired up.
---
## Architecture
Approach: **hooks-per-domain + pages**. Each domain has a dedicated hook file in `frontend/src/hooks/` that wraps TanStack Query. Pages consume hooks and stay presentational.
### New files
```
frontend/src/
├── hooks/
│ ├── useClients.ts
│ ├── useCatalog.ts
│ └── useWorkOrders.ts
├── components/
│ └── ui/
│ └── dialog.tsx ← shadcn Dialog (shared by all modals)
└── pages/app/
├── clients/
│ ├── ClientsPage.tsx
│ └── ClientDetailPage.tsx
├── catalog/
│ └── CatalogPage.tsx
└── work-orders/
├── WorkOrdersPage.tsx
└── WorkOrderDetailPage.tsx
```
### New routes (added to `App.tsx`)
```
/app/clients → ClientsPage
/app/clients/:id → ClientDetailPage
/app/catalog → CatalogPage
/app/work-orders → WorkOrdersPage
/app/work-orders/:id → WorkOrderDetailPage
```
---
## Domain: Clients
### `useClients.ts`
| Hook | Method | Endpoint |
|---|---|---|
| `useClients()` | GET | `/clients` |
| `useClient(id)` | GET | `/clients/:id` |
| `useCreateClient()` | POST | `/clients` |
| `useUpdateClient()` | PUT | `/clients/:id` |
| `useDeleteClient()` | DELETE | `/clients/:id` |
| `useClientVehicles(clientId)` | GET | `/clients/:id/vehicles` |
| `useCreateVehicle(clientId)` | POST | `/clients/:id/vehicles` |
| `useUpdateVehicle()` | PUT | `/vehicles/:id` |
All mutations call `queryClient.invalidateQueries` on success.
### `ClientsPage`
- Table columns: Nome, NIF, Telefone, Email, Data criação
- "Novo Cliente" button → modal (create)
- Edit icon per row → same modal prefilled
- Modal fields: nome (required), NIF, telefone, email, morada, notas
### `ClientDetailPage`
- Header: client name + fields (NIF, phone, email, address, notes) + Edit button
- "Veículos" section below: table with matrícula, marca, modelo, ano
- "Adicionar Veículo" → modal: matrícula (required), marca (required), modelo (required), ano, VIN, quilómetros, notas
- Edit icon per vehicle row → same modal prefilled
- Back link → `/app/clients`
---
## Domain: Catalog
### `useCatalog.ts`
| Hook | Method | Endpoint |
|---|---|---|
| `useCatalog()` | GET | `/catalog` |
| `useCreateCatalogItem()` | POST | `/catalog` |
| `useUpdateCatalogItem()` | PUT | `/catalog/:id` |
| `useDeleteCatalogItem()` | DELETE | `/catalog/:id` |
### `CatalogPage`
- Table columns: Código, Nome, Categoria, Unidade, Preço Base, Estado (badge)
- "Novo Item" button → modal (create)
- Edit icon per row → modal prefilled
- Delete: button toggles to "Confirmar?" before calling DELETE
- Modal fields: código, nome (required), categoria, unidade (select: un/hora/litro/kg), preço base (number), activo (checkbox)
---
## Domain: Work Orders
### `useWorkOrders.ts`
| Hook | Method | Endpoint |
|---|---|---|
| `useWorkOrders(status?)` | GET | `/work-orders?status=` |
| `useWorkOrder(id)` | GET | `/work-orders/:id` |
| `useCreateWorkOrder()` | POST | `/work-orders` |
| `useTransitionWorkOrder()` | POST | `/work-orders/:id/transition` |
| `useAddWOItem()` | POST | `/work-orders/:id/items` |
| `useRemoveWOItem()` | DELETE | `/work-orders/:id/items/:itemId` |
| `useAddStaffHours()` | POST | `/work-orders/:id/staff-hours` |
| `useRemoveStaffHours()` | DELETE | `/work-orders/:id/staff-hours/:shId` |
### `WorkOrdersPage`
- Table columns: Nº OT, Cliente, Veículo, Estado (badge colorido), Data
- Status filter: tabs (Todas / Abertas / Em Progresso / Concluídas / Faturadas / Canceladas)
- "Nova OT" → modal: cliente (searchable dropdown from `/clients`), veículo (filtered by client), notas internas, notas cliente
- Click row → navigate to `/app/work-orders/:id`
### `WorkOrderDetailPage`
Two-column layout:
**Left column — info + transitions:**
- OT number, status badge, client name, vehicle plate + brand/model
- Internal notes, client notes (editable inline via PUT)
- State stepper: `Aberta → Em Progresso → Concluída → Faturada`
- Transition buttons: advance to next state, or "Cancelar OT" (any state except invoiced)
- Back link → `/app/work-orders`
**Right column — items + hours:**
- "Peças / Serviços" table: descrição, qty, preço unitário, desconto %, total
- "Adicionar Item" → modal: select catalog item (searchable), qty, unit price (prefilled from catalog), discount %
- Remove icon per row (DELETE)
- "Horas de Técnico" table: técnico (staff_id), horas, custo/hora, total
- "Adicionar Horas" → modal: staff_id (text for now — staff module in Plan 4), horas, custo/hora
- Remove icon per row (DELETE)
- Totals row at bottom: subtotal peças + subtotal horas + total geral
### State badge colours
| Status | Colour |
|---|---|
| open | slate |
| in_progress | blue |
| completed | green |
| invoiced | purple |
| cancelled | red |
---
## Shared components
- **`dialog.tsx`** — shadcn Dialog, used by all create/edit modals. Install via shadcn CLI or copy pattern from existing ui components.
- All pages follow existing dark slate theme: `bg-slate-950` root, `bg-slate-900` panels, `border-slate-700/800`, white text.
- Loading states: `<p className="text-slate-400">A carregar...</p>`
- Empty states: `<p className="text-slate-500 text-sm">Nenhum registo.</p>`
- Error states: show `ApiError.message` in a red banner.
---
## Out of scope (Plan 4+)
- Staff management (staff_id is free text for now)
- PDF invoice generation
- Notifications (Telegram / email)
- Dashboard statistics
- Billing / invoicing module
@@ -0,0 +1,115 @@
# Super Admin — Tenant Access Design
**Date:** 2026-06-29
**Status:** Approved
**Scope:** Bug fix (TenantMiddleware schema name) + feature (super admin impersonation with session restore)
---
## Problem
1. **Bug:** `TenantMiddleware` constructs schema name as `"tenant_<uuid-with-hyphens>"` but schemas are named `tenant_<uuid-with-underscores>`. All tenant user requests fail with SET search_path error. Independent of the feature — must always be fixed.
2. **Feature gap:** Super admin can list and create tenants but cannot access or manage data within individual tenant workspaces.
---
## Solution
### Bug Fix — TenantMiddleware
Replace raw UUID concatenation with normalized schema name (hyphens → underscores):
```go
// Before
schema := `"tenant_` + claims.TenantID + `"`
// After
schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"`
```
### Feature — Tenant Impersonation with Session Restore
**Flow:**
1. Super admin clicks "Gerir" on any tenant row in TenantsPage
2. Frontend calls `POST /api/v1/admin/tenants/:id/access`
3. Backend validates tenant (exists + active), generates a 15-min access token with `tenantID=<id>` and `role="tenant_admin"`
4. Frontend saves current session to `previousSession` in authStore, sets new token+user
5. Navigates to `/app` — super admin now sees the tenant workspace
6. AppLayout shows a banner: `[TechXCar Admin] A gerir: <tenant name> [← Voltar ao painel]`
7. Clicking "Voltar" restores the previous session and navigates to `/admin`
---
## Backend
### New endpoint
`POST /api/v1/admin/tenants/:id/access`
- Auth: `RequireAuth` + `RequireRole("super_admin")`
- Validates: tenant ID format, tenant exists, tenant status = "active"
- Returns: `{ "data": { "access_token": "...", "tenant": { "id", "name", "slug" } }, "error": null }`
- Token: standard 15-min access token, `userID = super_admin_id`, `tenantID = tenant.ID`, `role = "tenant_admin"`
- No new refresh token — the impersonation session is access-token only
### Route registration
Added to `tenant.RegisterRoutes` under the existing `admin` group:
```
POST /api/v1/admin/tenants/:id/access
```
---
## Frontend
### authStore changes
New fields:
```typescript
previousSession?: { token: string; user: AuthUser }
```
New actions:
- `impersonateTenant(token, user)` — saves current `{ token, user }` to `previousSession`, sets new token+user
- `restoreSession()` — restores `previousSession` into token+user, clears `previousSession`
`previousSession` is NOT persisted to localStorage (impersonation does not survive page refresh — intentional).
### TenantsPage
Each tenant row gains a "Gerir" button (secondary/outline variant).
On click:
1. POST `/admin/tenants/:id/access`
2. On success: `impersonateTenant(data.access_token, { ...data.tenant, role: 'tenant_admin' })`
3. Invalidate TanStack Query cache (avoid stale super-admin-scoped data)
4. Navigate to `/app`
### AppLayout
When `authStore.previousSession` is defined, render a fixed banner at the top:
```
[TechXCar Admin] A gerir: <user.name> [← Voltar ao painel]
```
- Background: amber/yellow to visually distinguish from normal tenant UI
- "Voltar" button: calls `restoreSession()`, navigates to `/admin`
- Banner height: ~40px; main content padding-top adjusts accordingly
---
## Error Handling
- Tenant not found or not active → 404 "oficina não encontrada ou inativa"
- `impersonateTenant` failure (network) → show error toast, do not change session
- Restoring session never fails (purely client-side state)
---
## Out of Scope
- Refresh token for impersonation sessions (15-min window is intentional)
- Audit log of impersonation events (future)
- Super admin creating/editing data within tenant as themselves (they appear as tenant_admin)