FieldOps/docs/plans/mai-call-v0.1.md
2026-05-16 15:21:27 +01:00

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/domain vazio. Auth = dev-autologin sem password.

Decisões fixadas (não revisitar sem motivo forte)

  1. 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.
  2. MinIO em Docker como storage de fotos, acedido via AWS S3 SDK v3 (portável para cloud).
  3. 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.
  4. 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 fazem claim; grava claimedByUserId, claimedAt)
  • RESOLVED (apenas a partir de CLAIMED; grava resolvedByUserId, resolvedAt, resolutionNote opcional)

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 sobre AUTH_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.list com 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:
    1. Gerar clientRequestId = crypto.randomUUID().
    2. Gerar photoKey deterministicamente: tenants/{tenantId}/maintenance/{clientRequestId}.jpg.
    3. Enfileirar em IndexedDB (Dexie): { clientRequestId, photoKey, photoBlob, workstationId, description, queuedAt }.
    4. Marcar SW para sync.
    5. Navegar a /maintenance/sent?cid={clientRequestId} (E4) sem esperar pelo servidor.

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.list e user.listOperators com 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".
    • 409 (duplicate clientRequestId) é tratado como sucesso — robustez face a retries duplicados.
  • 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 (mutation claim).
      • CLAIMED (por mim ou outro): mostra "Aceite por {user} há {Xm}" + botão Marcar resolvido (abre dialog com resolutionNote opcional, mutation resolve).
      • 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:

  1. 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.
  2. Migrar para SSE depois é trivial e isolado a esta query (tRPC subscriptions ou 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):

  1. 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-se OperatorSession(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ã.
  2. DomainEvent já registado alimenta queries cross-módulo ("últimos 10 eventos no posto X") sem schema novo.
  3. Storage abstrato (ObjectStorage) serve também as fotos de defeitos do QCP.
  4. Polling pattern em admin-web generaliza-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.