21 KiB
Plano — MAI CALL v0.1 (ciclo mínimo)
Autor: Opus 4.7 (sessão de design, 2026-05-16). Destinado a implementação pelo Sonnet. Estado do scaffold no momento do design: Next.js 15 + tRPC v11 + Prisma 6 + Postgres + Auth.js v5 beta + Tailwind 3 + shadcn (inlined) + Docker Compose + pnpm 11 monorepo + Turbo + Playwright.
packages/domainvazio. Auth = dev-autologin sem password.
Decisões fixadas (não revisitar sem motivo forte)
- Offline-first com fila persistente (IndexedDB + service worker, sync on reconnect). Justificação: zonas pintura/Wi-Fi mau são restrição declarada do projeto.
- MinIO em Docker como storage de fotos, acedido via AWS S3 SDK v3 (portável para cloud).
- Dev-autologin mantém-se na v0.1, mas operador "escolhe-se" via picker que chama
signIn('credentials')do Auth.js — não inventar canal de identidade paralelo. - Destino: demo agora, piloto a seguir. Abstracções certas (ObjectStorage interface), mas sem construir auth real / backup / observabilidade ainda.
1. Modelo de dados
Adiciona-se um modelo novo e mantém-se o DomainEvent existente como log de transições.
enum MaintenanceRequestStatus {
OPEN
CLAIMED
RESOLVED
}
model MaintenanceRequest {
id String @id @default(cuid())
tenantId String
workstationId String
reportedByUserId String
description String // max 1000 chars (Zod)
photoKey String? // chave no MinIO; null se sem foto
status MaintenanceRequestStatus @default(OPEN)
clientRequestId String // idempotência (UUID do cliente)
createdAt DateTime @default(now())
claimedByUserId String?
claimedAt DateTime?
resolvedByUserId String?
resolvedAt DateTime?
resolutionNote String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workstation Workstation @relation(fields: [workstationId], references: [id])
reportedBy User @relation("reported", fields: [reportedByUserId], references: [id])
claimedBy User? @relation("claimed", fields: [claimedByUserId], references: [id])
resolvedBy User? @relation("resolved", fields: [resolvedByUserId], references: [id])
@@unique([tenantId, clientRequestId]) // idempotência por tenant
@@index([tenantId, status, createdAt]) // alimenta fila do admin
@@index([tenantId, reportedByUserId])
}
Adicionar relations inversas em User, Workstation, Tenant. Acrescentar 'MaintenanceRequest' a TENANT_SCOPED_MODELS em packages/db/src/tenant-extension.ts.
Máquina de estados (deliberadamente mínima):
OPEN(estado inicial — qualquer OPERATOR/ADMIN/SUPERVISOR pode criar)- →
CLAIMED(ADMIN/SUPERVISOR fazemclaim; gravaclaimedByUserId,claimedAt) - →
RESOLVED(apenas a partir deCLAIMED; gravaresolvedByUserId,resolvedAt,resolutionNoteopcional)
Sem reopen, sem cancel, sem "in-progress" separado de claimed. Transições inválidas devolvem 409.
A cada transição emite-se um DomainEvent (aggregateType='MaintenanceRequest', eventType ∈ {'created','claimed','resolved'}) na mesma transacção Prisma. Custo: ~5 linhas; benefício: relatório de fim de turno e auditoria caem-nos no colo depois sem refactor.
clientRequestId é fundamental para offline-first: o service worker pode tentar submeter duas vezes; o @@unique([tenantId, clientRequestId]) garante idempotência. O endpoint trata violação de unique como "already created, return existing".
2. tRPC — procedures
Tudo protectedProcedure. Sem necessidade de procedures públicas neste módulo.
| Router.procedure | Tipo | Quem | Input | Output |
|---|---|---|---|---|
maintenanceRequest.create |
mutation | OPERATOR+ | { workstationId, description, photoKey?, clientRequestId } |
{ id, status, createdAt } |
maintenanceRequest.queue |
query | ADMIN/SUPERVISOR | { statuses?: Status[], area?: string } |
lista paginada (cursor) |
maintenanceRequest.myRecent |
query | OPERATOR | { limit?: number } |
últimos N do próprio user |
maintenanceRequest.claim |
mutation | ADMIN/SUPERVISOR | { id } |
request actualizado |
maintenanceRequest.resolve |
mutation | ADMIN/SUPERVISOR | { id, resolutionNote? } |
request actualizado |
maintenanceRequest.getById |
query | qualquer (tenant scope) | { id } |
request com relations |
workstation.list |
query | qualquer | — | Workstation[] |
user.listOperators |
query | qualquer | — | { id, email }[] filtrado por role=OPERATOR |
storage.signPhotoUpload |
mutation | OPERATOR+ | { contentType, byteSize } |
{ uploadUrl, photoKey, expiresAt } |
storage.signPhotoDownload |
query | qualquer | { photoKey } |
{ url, expiresAt } (curta) |
Acrescenta-se role middleware simples em packages/api/src/trpc.ts: requireRole(...roles) derivado de protectedProcedure. Usado nas mutations admin (claim, resolve).
Validação Zod:
description:z.string().trim().min(3).max(1000)photoKey: regex^tenants/[a-z0-9-]+/maintenance/[a-z0-9-]+\.(jpg|jpeg|png|webp)$byteSize:z.number().int().min(1).max(10 * 1024 * 1024)(10 MB cap)contentType: enum['image/jpeg','image/png','image/webp']
3. Storage — abstracção + MinIO
Cria-se packages/storage com:
export interface ObjectStorage {
signPut(key: string, contentType: string, byteSize: number, ttlSec: number): Promise<{ url: string; expiresAt: Date }>;
signGet(key: string, ttlSec: number): Promise<{ url: string; expiresAt: Date }>;
delete(key: string): Promise<void>;
}
export function makeStorage(): ObjectStorage { /* lê env, devolve MinIO impl */ }
Impl única na v0.1: MinioStorage usando @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner (a mesma SDK serve AWS, R2, Wasabi, MinIO — só muda endpoint). Não instalar SDK específica do MinIO; manter S3 protocol universal é o ponto.
Compose ganha:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment: { MINIO_ROOT_USER, MINIO_ROOT_PASSWORD }
ports: ["9000:9000", "9001:9001"]
volumes: ["minio-data:/data"]
minio-init: # job one-shot que cria o bucket fieldops
image: minio/mc:latest
depends_on: { minio: { condition: service_started } }
entrypoint: >
/bin/sh -c "mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb -p local/fieldops || true &&
mc anonymous set none local/fieldops"
Env novas: S3_ENDPOINT, S3_REGION (us-east-1 default p/ MinIO), S3_BUCKET=fieldops, S3_ACCESS_KEY, S3_SECRET_KEY, S3_FORCE_PATH_STYLE=true. Validar em apps/operator-pwa/env.ts e no admin-web.
Convenção de chaves: tenants/{tenantId}/maintenance/{requestId-or-clientRequestId}.{ext}. Determinismo da chave permite ao cliente pre-calcular antes de submeter — essencial para o fluxo offline (foto sobe primeiro, mutation guarda só a key).
Lifecycle: nenhum (v0.1). Backup: documenta-se mc mirror no README; sem cron ainda.
4. Identidade do operador
Decisão: manter dev-autologin, mas usar a Auth.js signIn já existente para distinguir operadores em vez de inventar um canal paralelo.
Concretamente:
- O picker da PWA chama
signIn('credentials', { email, redirect: false })quando o operador se selecciona. Credentials provider já aceita email-sem-password (apps/operator-pwa/lib/auth.ts:38-58), portanto não precisa de mudança. resolveUser()continua igual: a sessão Auth.js real ganha precedência sobreAUTH_DEV_AUTOLOGIN. O fallback admin só dispara quando ninguém ainda escolheu (primeiro load).- A escolha persiste via cookie de sessão Auth.js — sobrevive a reload e a fechar/abrir tab.
- "Trocar utilizador" é um botão que chama
signOut+ manda para o picker.
Isto deixa o TAG/cartão para o MY QUALITY num caminho limpo: substituis o picker por um leitor de RFID que devolve um email/id de operador → chamas o mesmo signIn. Zero refactor no resto do sistema.
Para o admin-web: continua com AUTH_DEV_AUTOLOGIN=true a entrar como admin@demo.local. Sem picker do lado admin na v0.1 — é "demo, qualquer um que abra é o admin".
5. UI Operador (operator-pwa)
Quatro ecrãs. Mobile-first, target tablet/telemóvel em portrait.
E1 — Picker de operador (/select-operator)
- Lista grande, tap-friendly, dos OPERATOR do tenant. Avatar opcional, label = email ou nome.
- Tap →
signIn('credentials')→ redirect a/. - Skip automático se já houver sessão.
E2 — Home (/)
- Header: nome do operador + botão "Trocar".
- Estado de sync: chip "Tudo sincronizado" / "N pedidos por enviar" se a fila IndexedDB não estiver vazia.
- Botão grande primário: "Pedir manutenção" → E3.
- Lista colapsada "Os meus pedidos" (últimos 5): query
myRecent+ união com pendentes da IndexedDB (status local "pending"). Cores por status. Sem detalhes — só feedback.
E3 — Novo pedido (/maintenance/new)
- Campo 1 — Posto: dropdown searchable (Combobox da shadcn). Itens vêm de
workstation.listcom cache SWR longo. Sticky "Recentes" no topo (localStorage dos últimos 3 escolhidos). Obrigatório. - Campo 2 — Foto (opcional):
<input type="file" accept="image/*" capture="environment" />. Preview redimensionado client-side com<canvas>para max 1600px lado maior + compressão JPEG q=0.8 (reduz upload em zona pintura). Botão "Remover foto". - Campo 3 — Descrição: textarea, 3-1000 chars, contador. Obrigatório.
- Submeter:
- Gerar
clientRequestId = crypto.randomUUID(). - Gerar
photoKeydeterministicamente:tenants/{tenantId}/maintenance/{clientRequestId}.jpg. - Enfileirar em IndexedDB (Dexie):
{ clientRequestId, photoKey, photoBlob, workstationId, description, queuedAt }. - Marcar SW para sync.
- Navegar a
/maintenance/sent?cid={clientRequestId}(E4) sem esperar pelo servidor.
- Gerar
E4 — Confirmação (/maintenance/sent)
- Mostra "Pedido em fila" (se ainda em IndexedDB) ou "Pedido enviado #abc" (se sincronizado). Reactivo a updates do sync. Botão "Voltar".
Service worker + sync (offline-first):
- Lib:
workbox(caching app shell) + Dexie 4 (queue). - App shell precaching: HTML/JS/CSS estáticos, ícones, fontes.
- Runtime caching:
workstation.listeuser.listOperatorscom StaleWhileRevalidate (servem picker e dropdown offline). - Worker de sync (lógica em TS, corrida no main thread quando o tab estiver visível + Background Sync API quando suportado):
- Loop: enquanto fila não-vazia e online (
navigator.onLine+ ping a/api/health):- Para cada item: (a)
signPhotoUpload→ PUT directo ao MinIO (se houver foto); (b)maintenanceRequest.create; (c) remove da fila ao sucesso; (d) retry com backoff exponencial em falhas de rede; (e) erros 4xx (excepto 409) → move para "dead-letter" local + toast ao operador "Pedido X falhou, contactar supervisor".
- Para cada item: (a)
- 409 (duplicate
clientRequestId) é tratado como sucesso — robustez face a retries duplicados.
- Loop: enquanto fila não-vazia e online (
- UI tem que reagir: emite eventos
BroadcastChannel('mai-call-sync')que o React assina.
Critério-chave: desligar o Wi-Fi do tablet, criar 3 pedidos, ligar de novo. Os 3 chegam ao admin-web sem o operador fazer nada.
6. UI Manutenção (admin-web)
Uma única página /maintenance (sem ainda preocupação com dashboard, login screen, settings).
- Header: contador "N pedidos abertos" + filtros (status multi-select, área dropdown).
- Lista (cards verticais; tabela é hostil em ecrã pequeno):
- Thumbnail foto (signed GET URL com TTL curto, lazy-loaded).
- Posto (
{code} — {name} • {area}). - Descrição.
- "Reportado por {operador} • há {Xm}".
- Footer: badge status, e dependendo do estado:
OPEN→ botão Aceitar (mutationclaim).CLAIMED(por mim ou outro): mostra "Aceite por {user} há {Xm}" + botão Marcar resolvido (abre dialog comresolutionNoteopcional, mutationresolve).RESOLVED: badge verde, "Resolvido por {user} há {Xm}", sem acções.
Real-time: polling. queue faz useQuery com refetchInterval: 5000 e refetchIntervalInBackground: false. Justificação:
- SSE/WS exigem canal extra no Next route handler + auth no upgrade + reconexão. Polling de 5s em fila de manutenção fabril é amplamente suficiente — não é trading.
- Migrar para SSE depois é trivial e isolado a esta query (
tRPC subscriptionsou endpoint SSE puro).
Notificações: badge no document.title ((3) FieldOps — Maintenance) + <audio> curto opcional ao detectar novo pedido OPEN (toggle persistido em localStorage). Sem push, sem email, sem websocket. Custos quase zero, valor enorme.
7. Cortes propositados — o que NÃO entra na v0.1
| Cortado | Porquê | Quando volta |
|---|---|---|
| Severidade / categoria | UX-cost na linha sem valor demonstrado; defaults razoáveis funcionam | v0.2, junto com SLAs |
| SLAs / alertas / timers | Requer infra de scheduling | v0.2 |
| Relatório fim de turno automático | DomainEvents já registados → gerar depois sem refactor | v0.2 ou v0.3 |
| Múltiplas fotos / vídeo / áudio | Triplica complexidade upload/storage | quando piloto pedir |
| Reabrir pedido fechado / chat operador-técnico | Workflow real, não MVP | v0.2+ |
| Categorização AI da descrição | Tentação clássica de over-engineer | só com dados reais |
| Export CSV/PDF | Stakeholder pedirá; resposta é "ver Insights & Sim" | módulo separado |
| Push notifications mobile | PWA push web tem suporte Apple irregular; badge+polling chega | só se piloto exigir |
| Auth real (passwords, SSO, MFA) | Definido como demo→piloto; v0.1 dev-autologin | v0.2, pré-piloto |
| TAG/cartão | Substitui-se o picker; arquitectura já preparada | quando MY QUALITY entrar |
| Multi-tenant onboarding UI | Cria-se via seed/SQL | só com 2º cliente |
| i18n PT/EN | Hardcoded PT na v0.1 (cliente é Mangualde) | v0.2 com cliente piloto |
| Testes E2E completos | 1 happy-path Playwright basta na v0.1 | expandir com piloto |
| Observabilidade | Logs estruturados via logger já existente; chega |
quando piloto exigir |
8. Próximo módulo a ligar — MY QUALITY
Razão: reusa Workstation + identidade-de-operador-num-posto, não introduz integração brownfield nova, e fecha o segundo case de uso de "operador na linha que reporta algo". MAI BOM exige integração com stock/MAI (mais canalização); SWA DIGITAL exige standard work formalizado (mais conteúdo); Paperless precisa de forms engine. MY QUALITY é o mais barato a seguir.
O que o MAI CALL v0.1 deve já deixar preparado (sem código extra agora, só desenho que não fecha portas):
OperatorSessioné um futuro modelo, não inventes ainda. Mas o picker de operador da PWA já se comporta como o "badge-in" futuro: escolher operador é uma acção persistente, não uma form-field por pedido. Quando MY QUALITY chegar, acrescenta-seOperatorSession(tenantId, userId, workstationId, startedAt, endedAt?)e o picker passa a perguntar também o posto (binding operador↔posto), o que substitui o dropdown de posto em E3 do MAI CALL (deixa de ser por-pedido, passa a vir da sessão). Refactor pequeno, contido a 1 ecrã.DomainEventjá registado alimenta queries cross-módulo ("últimos 10 eventos no posto X") sem schema novo.- Storage abstrato (
ObjectStorage) serve também as fotos de defeitos do QCP. - Polling pattern em
admin-webgeneraliza-se trivialmente para a vista de roteamento RFS→operador.
9. Plano de implementação (passos pequenos, ordenados)
Cada passo é mergeable independentemente, com critério de aceitação testável.
Passo 1 — Schema + extension
Faz: Adicionar MaintenanceRequest + enum + relations + indexes ao schema. Acrescentar 'MaintenanceRequest' aos modelos tenant-scoped. Criar migration.
AC: pnpm db:migrate corre sem erro; prisma studio mostra a tabela vazia; select * from "MaintenanceRequest" where "tenantId"='x' funciona.
Passo 2 — Seed alargado
Faz: Estender db:seed para criar 3 OPERATOR users (op1@demo.local, op2@demo.local, op3@demo.local) e 3 Workstations (CTR04, QVN_RTL_2, MTG_01).
AC: Após pnpm db:seed, picker em http://localhost:3000 lista 3 operadores; dropdown de posto lista 3 postos.
Passo 3 — Pacote @repo/storage
Faz: Criar packages/storage com a interface ObjectStorage e impl MinioStorage usando AWS SDK v3. Adicionar env vars S3_*. Adicionar serviço MinIO + minio-init ao docker-compose.yml.
AC: Script ad-hoc pnpm tsx scripts/storage-smoke.ts consegue: gerar presigned PUT, fazer upload com curl, gerar presigned GET, descarregar o ficheiro de volta.
Passo 4 — Role middleware em tRPC
Faz: Acrescentar requireRole(...roles) em packages/api/src/trpc.ts que estende protectedProcedure.
AC: Procedure de teste com requireRole('ADMIN') devolve 403 para OPERATOR; passa para ADMIN.
Passo 5 — Router workstation + user
Faz: Criar workstation.list (todos) e user.listOperators (filter role=OPERATOR). Montar em _app.ts.
AC: Chamada por tRPC client devolve seeds do Passo 2.
Passo 6 — Router storage
Faz: Criar storage.signPhotoUpload (mutation) e storage.signPhotoDownload (query). Validar Zod (content-type, size). TTL: 5min upload, 1min download.
AC: Chamada por tRPC devolve URL válida; upload direto ao MinIO com a URL funciona.
Passo 7 — Router maintenanceRequest
Faz: Procedures create, claim, resolve, queue, myRecent, getById. Cada transição emite DomainEvent na mesma $transaction (usar pattern de scoped tx do header de tenant-extension.ts). Tratar P2002 em create (unique violation em clientRequestId) como sucesso devolvendo a request existente.
AC: Teste integração: cria-se um pedido por op1; claim por admin; resolve por admin; queue mostra na ordem certa. Repetir create com mesmo clientRequestId devolve a mesma row sem erro.
Passo 8 — Picker de operador
Faz: Ecrã /select-operator na operator-pwa. Lista user.listOperators. Tap → signIn('credentials', { email, redirect: false }). Redirect a /. Middleware middleware.ts redirecciona a /select-operator se não houver sessão e não houver autologin.
AC: Demo flow: limpar cookies, abrir /, é redirigido ao picker, escolher op1, voltar a /, header mostra op1@demo.local.
Passo 9 — Home da PWA
Faz: Substituir / actual por: header com operador + botão trocar; botão "Pedir manutenção" → /maintenance/new; lista "Os meus pedidos" via myRecent.
AC: Botão visível, navega correctamente.
Passo 10 — Ecrã novo pedido (online-only primeiro)
Faz: /maintenance/new com posto (Combobox), foto (input capture, compress canvas), descrição. Submissão directa ao servidor (sem fila ainda). Confirmação em /maintenance/sent.
AC: Submeter um pedido (com e sem foto) cria a row, MinIO tem o objecto, redirect mostra ID.
Passo 11 — Admin-web /maintenance
Faz: Page com lista (cards), filtros simples (status/area), polling 5s, botões claim/resolve (este último abre dialog com note opcional). Thumbnails via signed GET. Badge no title. AC: Pedido criado no Passo 10 aparece em ≤5s; claim move o card para CLAIMED; resolve move para RESOLVED.
Passo 12 — IndexedDB + service worker + sync
Faz: Instalar Dexie + Workbox. Refactorizar E3 para enfileirar em IndexedDB e devolver controlo imediatamente. Service worker faz precache de app shell + StaleWhileRevalidate para workstation.list e user.listOperators. Worker de sync com retry/backoff. UI mostra "N por enviar" e reage via BroadcastChannel.
AC: Cenário offline: Chrome DevTools → Network=Offline → criar 3 pedidos com 1 foto cada → vê-los listados como "por enviar" → Network=Online → em ≤30s os 3 estão no admin-web com fotos.
Passo 13 — Polish + happy-path E2E
Faz: Tratamento de erros visíveis (toasts), loading states, empty states. Um único teste Playwright que percorre operador-cria → admin-claim → admin-resolve.
AC: pnpm test:e2e verde.
Passo 14 — README + runbook curto
Faz: Atualizar README com (a) como subir o stack (docker compose up + pnpm db:migrate + pnpm db:seed + pnpm dev); (b) credenciais MinIO/console em http://localhost:9001; (c) limitações conhecidas v0.1 (dev-autologin, sem auth real, demo only).
AC: Outro dev clona o repo, segue o README, vê o demo flow a funcionar em <15 min.
Sequência crítica: Passos 1→7 são backend e dão-se naturalmente em PRs pequenos. Passo 8 é o pivot para frontend. Passos 9-11 podem ir em paralelo (operator-pwa vs admin-web) se houver 2 devs. Passo 12 é o passo mais arriscado/longo — deixar para o fim, depois de o fluxo online estar verificado, para não misturar bugs de sync com bugs de domínio.
Risco principal não-mitigado: comportamento do service worker no Safari iOS (caso a fábrica use iPads). Workbox lida com a maioria mas Background Sync API não é suportada — cai-se no fallback "sync quando o tab está visível". Aceitar e seguir.