Inicial
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user