# Plano — MAI CALL v0.1 (ciclo mínimo) > **ESTADO: SHIPPED (2026-05-16).** Os 14 passos estão concluídos e commitados. Desde então: Auth v0.2, relatório v0.3, testes E2E e i18n PT/EN foram adicionados por cima (ver os outros planos em `docs/plans/` e a memory `project-phase`). > > 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. ```prisma 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: ```ts 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; } 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: ```yaml 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): ``. Preview redimensionado client-side com `` 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`) + `