Apostila de arquivo único — Nível 3 — Frameworks Modernos. Uma aula por vez; use j/k ou o menu lateral. Voltar ao índice.

Nível 3Unidade 1 · Fundamentos de front-end com Vue.js3 aulas de 50 min + 1 h EAD

Aula 01 — Apresentação da disciplina e revisão de JavaScript

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Descrever a estrutura da disciplina: unidades, cronograma, avaliações e regras de aprovação.
  • Configurar o ambiente de desenvolvimento (Node.js 22 LTS, VS Code, Git) e validar a instalação.
  • Explicar por que frameworks front-end existem, comparando manipulação manual do DOM com um paradigma declarativo.
  • Escrever e ler código JavaScript moderno (ES2015+): let/const, arrow functions, desestruturação, spread/rest, template literals, optional chaining e nullish coalescing.
  • Transformar coleções de dados com map, filter, reduce, find, some, every e sort sem mutar o array original.
  • Organizar código em módulos ES (import/export) e em classes.
  • Consumir uma API pública com fetch usando async/await e tratamento de erro com try/catch.
  • Definir o tema do seu projeto autoral e modelar as entidades iniciais em um README.md.

📋 Pré-requisitos desta aula

Esta é a primeira aula — não há pré-requisito de conteúdo da disciplina. Você precisa apenas de:

  • Um notebook capaz de rodar Node.js 22 e o VS Code (Windows, Linux ou macOS).
  • Conta no GitHub criada antes da aula.
  • Conhecimento prévio de lógica de programação e alguma exposição a HTML/CSS/JS (pré-requisito formal: FACET-SNP-307).

⚠️ Atenção Se você nunca escreveu uma linha de JavaScript, não entre em pânico — a Seção 3 desta aula é uma revisão completa. Mas reserve um tempo extra para os laboratórios em casa.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Apresentação da disciplina, cronograma, avaliações, instalação do ambiente
2 50 min Por que frameworks existem: DOM manual vs. paradigma declarativo
3 50 min Revisão de JavaScript moderno (ES2015+) com foco no domínio de dados do curso

1. Apresentação da disciplina

1.1 Quem sou eu e como essa disciplina funciona

Meu nome é Ivan Luiz Pedroso Pires, professor da FACET/UNEMAT — Campus Sinop. Esta disciplina, FACET-SNP-310 — Frameworks Modernos para Desenvolvimento de Sistemas, tem carga horária de 60 horas: 45h de aulas síncronas presenciais (15 encontros de 3 aulas de 50 minutos, às noites, horário 4N234) e 15h de atividades assíncronas — uma hora de trabalho fora de sala a cada encontro.

A ementa oficial do curso é:

"Desenvolvimento com uso de frameworks; padrões: criacionais, estruturais e comportamentais; aplicação conjunta das abordagens de frameworks e componentes no desenvolvimento de software."

Isso significa duas coisas na prática: (1) você vai aprender a construir aplicações reais com um framework front-end moderno e depois integrá-las a um back-end; (2) ao longo do caminho, vamos identificar padrões de projeto clássicos (GoF) escondidos dentro das ferramentas que usamos — Vue, Vuetify, Pinia, Express — porque a ementa exige isso explicitamente, e porque entender o padrão por trás da ferramenta é o que separa quem usa framework de quem entende framework.

1.2 As três unidades

Unidade Conteúdo Aulas
U1 Fundamentos de front-end com Vue.js 01–04
U2 Vue.js avançado: Vuetify, Axios, Vue Router, Pinia 04–06
U3 Integração front-end/back-end: Firebase, Supabase, Express, autenticação, banco de dados, deploy 07–15

1.3 Cronograma completo

Guarde esta tabela — ela é o mapa do semestre inteiro.

# Data Unidade Tema
01 12/08/2026 U1 Apresentação da disciplina e revisão de JavaScript
02 19/08/2026 U1 Introdução ao Vue: lifecycle, instância, data e methods, diretivas básicas
03 26/08/2026 U1 Vue: v-if, v-else, v-for, computed e onMounted
04 02/09/2026 U1 Introdução a Vuetify e Vue Router — entrega da Avaliação 1
05 16/09/2026 U2 Componentes, Vue Router e Vuetify
06 23/09/2026 U2 Axios e Pinia
07 30/09/2026 U3 Firebase, Node.js e Express
08 07/10/2026 U3 Endpoints e middlewares — entrega da Avaliação 2
09 21/10/2026 U3 Integração com MySQL
10 28/10/2026 U3 Requisições autenticadas com Firebase
11 11/11/2026 U3 CRUD front-end + back-end
12 18/11/2026 U3 CRUD com banco de dados em nuvem (Supabase)
13 25/11/2026 U3 Desenvolvimento do back-end em camadas
14 09/12/2026 U3 Documentação com Swagger
15 16/12/2026 U3 Apresentação dos resultados — entrega da Avaliação 3

Todas as aulas 01 a 15 constroem, em sala, uma aplicação de referência chamada UniEventos — uma plataforma de divulgação e inscrição em eventos acadêmicos. Você vai acompanhar essa construção passo a passo, mas seu projeto autoral (o que você entrega nas avaliações) terá a mesma arquitetura aplicada a um domínio diferente, escolhido por você. Falamos disso na Seção 1.6.

1.4 Avaliações

A nota final é a média aritmética simples das três avaliações.

Avaliação Escopo Entrega
Avaliação 1 Vue 3 com CLI: estrutura de projeto, componentes, diretivas até 02/09/2026, 23h59
Avaliação 2 Vue avançado: Vuetify + Axios + Vue Router + Pinia até 07/10/2026, 23h59
Avaliação 3 Back-end: Firebase/Express/Supabase, banco de dados, autenticação até 16/12/2026, 23h59

Todas as entregas são feitas via SIGAA, dentro do prazo. As instruções detalhadas de cada avaliação (escopo exato, rubrica, formato de entrega) serão publicadas na aula correspondente — a Avaliação 1 será detalhada na Aula 04.

📌 Na prova Existe também um exame final: prova teórica, presencial e individual, cobrindo as três unidades. Ele é aplicado a quem não atinge a média mínima de aprovação direta pelas três avaliações, conforme o regimento da UNEMAT. Estude a teoria, não só a prática — o exame não é sobre "rodar o código", é sobre entender os conceitos.

1.5 Frequência e comunicação

A frequência é obrigatória e verificada em cada um dos 15 encontros presenciais — o mínimo de 75% de presença é exigência regimental para aprovação, independente da nota. Encontros faltados não podem ser compensados só com a atividade assíncrona: a atividade assíncrona é conteúdo adicional, não substituto de presença.

Canais oficiais de comunicação:

  • SIGAA — avisos oficiais, entrega de avaliações, notas.
  • E-mail institucional — dúvidas individuais e assuntos administrativos.
  • GitHub — cada estudante mantém um repositório público do projeto autoral; é ali que o professor acompanha o progresso semana a semana.

💡 Dica Comite no seu repositório do projeto autoral toda semana, mesmo que pouco. Um histórico de commits ao longo do semestre vale mais, na avaliação de processo, do que um único commit gigante na véspera da entrega.

1.6 O projeto autoral

Cada estudante escolhe, ainda nesta primeira semana, um domínio de aplicação diferente do UniEventos (o projeto que construiremos em sala), mas segue exatamente a mesma arquitetura técnica: Vue 3 → Vuetify + Vue Router → Axios + Pinia → back-end Express → banco de dados → autenticação → deploy.

Exemplos de temas válidos:

  • Catálogo de plantas do Pantanal, com filtro por bioma e época de floração.
  • Agenda de quadras esportivas do bairro, com reserva de horário.
  • Mural de estágios e vagas para estudantes da FACET.
  • Brechó colaborativo, com peças, categorias e reserva.
  • Controle de pescarias, com espécies, rio e datas.
  • Cardápio digital de um restaurante, com categorias de prato e pedidos.

Regras para o tema:

  1. Precisa ter pelo menos duas entidades relacionadas (ex.: "Evento" e "Inscrição", "Quadra" e "Reserva") — um cadastro único sem relacionamento não sustenta as três unidades.
  2. Precisa ter uma tela de listagem com filtro, uma tela de detalhe e uma área que exija autenticação — isso espelha as telas do UniEventos (Home, Detalhe, Minhas inscrições, Login, Área administrativa).
  3. Não pode ser o próprio UniEventos copiado — o domínio precisa ser outro.

1.7 Ambiente de desenvolvimento

Vamos instalar, nesta aula, tudo que será usado até o fim do semestre. As versões abaixo foram testadas em 12/08/2026 no ambiente real da disciplina — use exatamente estas.

Ferramenta Versão usada na disciplina
Node.js 22.22.2 LTS
npm 10.9.7 (vem com o Node)
VS Code versão estável mais recente
Git versão estável mais recente

Passo a passo:

  1. Node.js 22 LTS. Baixe em nodejs.org a versão "LTS" (não a "Current"). No Linux, você também pode usar o gerenciador de versões nvm:
Terminal
# instalar nvm (se ainda não tiver)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

# instalar e usar o Node 22 LTS
nvm install 22
nvm use 22
  1. Verifique a instalação:
Terminal
node -v
# esperado: v22.22.2 (ou outra 22.x LTS)

npm -v
# esperado: 10.9.7 (ou próxima)

⚠️ Atenção Se node -v mostrar uma versão 16, 18 ou 20, desinstale-a ou troque com o nvm antes de continuar. Ferramentas que usaremos mais à frente, como o create-vue, exigem Node ^22.18.0 ou >=24.12.0 — versões antigas simplesmente falham na instalação.

  1. VS Code. Baixe em code.visualstudio.com. Instale estas extensões (aba Extensions, Ctrl+Shift+X):
  • Vue - Official (antigo Volar) — suporte a arquivos .vue, autocomplete, checagem de tipos no template.
  • ESLint — aponta erros e más práticas enquanto você digita.
  • Prettier - Code formatter — formatação automática e consistente.
  1. Navegador com DevTools. Use Chrome, Edge ou Firefox — qualquer um com um bom painel de DevTools (F12). Vamos usar a aba Elements (inspecionar DOM), Console e Network (ver requisições) o semestre inteiro.

  2. Git e GitHub.

Terminal
git --version
# se não tiver, instale: sudo apt install git (Linux) ou baixe em git-scm.com

git config --global user.name "Seu Nome"
git config --global user.email "seu-email@exemplo.com"

Crie uma conta em github.com se ainda não tiver. Vamos usar o GitHub para hospedar o código do projeto autoral e, mais adiante no semestre, para o deploy.

💡 Dica Depois de instalar tudo, rode node -v, npm -v e git --version e tire um print. Cole no seu README como evidência de ambiente pronto — é o primeiro item do checkpoint desta aula.

2. Por que frameworks existem

2.1 Um problema concreto: lista de eventos com filtro

Imagine que você precisa mostrar uma lista de eventos acadêmicos na tela, com um campo de busca por texto e um filtro por categoria. Sem framework nenhum, em JavaScript puro manipulando o DOM diretamente, o código fica assim:

HTML
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <title>Eventos — DOM manual</title>
</head>
<body>
  <input id="busca" type="text" placeholder="Buscar evento..." />
  <select id="categoria">
    <option value="">Todas as categorias</option>
    <option value="palestra">Palestra</option>
    <option value="minicurso">Minicurso</option>
    <option value="workshop">Workshop</option>
  </select>

  <ul id="lista-eventos"></ul>

  <script src="app-dom-manual.js"></script>
</body>
</html>
JavaScript
// app-dom-manual.js
const eventos = [
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25 },
  { id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60 },
  { id: 4, titulo: 'Introdução a IA', categoria: 'palestra', vagas: 80 },
]

const inputBusca = document.getElementById('busca')
const selectCategoria = document.getElementById('categoria')
const listaEl = document.getElementById('lista-eventos')

// Função que decide QUAIS eventos mostrar e depois
// PRECISA, na mão, apagar o DOM antigo e reconstruir tudo.
function renderizar() {
  const termo = inputBusca.value.toLowerCase()
  const categoria = selectCategoria.value

  const filtrados = eventos.filter((evento) => {
    const bateTexto = evento.titulo.toLowerCase().includes(termo)
    const bateCategoria = categoria === '' || evento.categoria === categoria
    return bateTexto && bateCategoria
  })

  // Passo manual 1: limpar o que já estava na tela.
  listaEl.innerHTML = ''

  // Passo manual 2: recriar cada item, um por um.
  if (filtrados.length === 0) {
    const li = document.createElement('li')
    li.textContent = 'Nenhum evento encontrado.'
    listaEl.appendChild(li)
    return
  }

  filtrados.forEach((evento) => {
    const li = document.createElement('li')
    li.textContent = `${evento.titulo} (${evento.categoria}) — ${evento.vagas} vagas`
    listaEl.appendChild(li)
  })
}

// Passo manual 3: escutar cada evento de interação e chamar renderizar() de novo.
inputBusca.addEventListener('input', renderizar)
selectCategoria.addEventListener('change', renderizar)

renderizar()

Funciona. Mas repare no que você, programador, teve que fazer manualmente:

  1. Escutar cada evento de UI (input, change) e lembrar de chamar renderizar().
  2. Dentro de renderizar(), apagar o HTML antigo (innerHTML = '') e reconstruir do zero.
  3. Manter sincronizado, na sua cabeça, o estado (eventos, o termo de busca, a categoria) com o que está na tela.

Em uma lista de 4 eventos isso é trivial. Em uma aplicação real — com dezenas de componentes, cada um reagindo a mudanças de estado de outros — esse sincronismo manual vira a maior fonte de bugs: tela desatualizada, elementos duplicados, listeners que vazam memória.

2.2 A mesma ideia, em paradigma declarativo

Compare com o que faremos a partir da Aula 02, em Vue:

Vue SFC
<!-- Antecipação — ainda NÃO é o que vamos escrever hoje, é só para criar contraste -->
<template>
  <input v-model="busca" type="text" placeholder="Buscar evento..." />
  <select v-model="categoria">
    <option value="">Todas as categorias</option>
    <option value="palestra">Palestra</option>
    <option value="minicurso">Minicurso</option>
    <option value="workshop">Workshop</option>
  </select>

  <ul>
    <li v-for="evento in eventosFiltrados" :key="evento.id">
      {{ evento.titulo }} ({{ evento.categoria }}) — {{ evento.vagas }} vagas
    </li>
    <li v-if="eventosFiltrados.length === 0">Nenhum evento encontrado.</li>
  </ul>
</template>

Note a diferença de raciocínio: no código Vue você descreve o resultado desejado ("a lista deve mostrar eventosFiltrados") e o framework decide, sozinho, quando e como atualizar o DOM. Você não escreve innerHTML = '', não escreve addEventListener, não gerencia manualmente qual elemento criar ou remover. Isso é programação declarativa: você declara o "o quê", o framework resolve o "como".

Essa é a promessa central de um framework front-end reativo como o Vue — e é o fio condutor do semestre inteiro. Ainda não vamos escrever Vue hoje (isso começa na Aula 02); hoje construímos a base de JavaScript que sustenta tudo isso.

2.3 Conceitos que você vai ouvir o semestre inteiro

  • SPA (Single Page Application) vs. MPA (Multi Page Application). Uma MPA tradicional recarrega o HTML inteiro do servidor a cada navegação. Uma SPA carrega um único HTML inicial e, depois, troca apenas os pedaços de tela necessários via JavaScript — é o modelo que o Vue Router (Aula 04) implementa.
  • Client-side rendering (CSR). O HTML final da página é montado no navegador do usuário, em JavaScript, a partir de dados — em vez de vir pronto do servidor. É o padrão que usaremos com Vue + Vite.
  • Bundler. Ferramenta que pega seus arquivos-fonte (.vue, .js, .css, módulos separados) e os empacota em arquivos otimizados para o navegador. Usaremos o Vite a partir da Aula 02.
  • Transpilação. Processo de converter uma sintaxe moderna (ES2022+, ou até TypeScript) em um JavaScript que rode em navegadores mais antigos ou que corresponda ao que o navegador entende nativamente. O Vite faz isso por baixo dos panos.
  • npm e package.json. O npm (Node Package Manager) instala bibliotecas de terceiros. O package.json é o arquivo que lista essas dependências e os scripts do projeto (npm run dev, por exemplo).
  • SemVer (versionamento semântico). Versões no formato MAIOR.MENOR.PATCH (ex.: 3.5.41). Mudanças de MAIOR podem quebrar compatibilidade; MENOR adiciona funcionalidade sem quebrar; PATCH corrige bugs. É por isso que, nesta disciplina, fixamos versões exatas — evita que seu projeto quebre por uma atualização automática no meio do semestre.

3. Revisão de JavaScript moderno (ES2015+)

Esta é a espinha dorsal da aula de hoje. O Vue é, por baixo, "só" JavaScript — cada recurso que revisamos aqui reaparece dentro de um <script setup> já na próxima aula.

3.1 let, const e escopo de bloco

JavaScript
// Antes do ES2015 só existia `var`, com escopo de função (confuso).
// Hoje: use `const` por padrão, `let` só quando o valor precisa mudar.

const nomeEvento = 'Semana da Computação' // não pode ser reatribuída
let vagasDisponiveis = 40                 // pode ser reatribuída

vagasDisponiveis = vagasDisponiveis - 1
console.log(vagasDisponiveis) // 39

// `let` e `const` respeitam escopo de bloco { }
if (vagasDisponiveis > 0) {
  const mensagem = 'ainda há vagas'
  console.log(mensagem)
}
// console.log(mensagem) aqui daria ReferenceError: mensagem não existe fora do bloco

⚠️ Atenção Nunca use var em código novo. var "vaza" para fora de blocos if/for, o que gera bugs difíceis de rastrear. A disciplina inteira usa apenas let e const.

3.2 Template literals

JavaScript
const evento = { titulo: 'Oficina de Vue.js', vagas: 25 }

// Antes: concatenação com +
const antigo = 'Evento: ' + evento.titulo + ' (' + evento.vagas + ' vagas)'

// Agora: template literal, com crase e ${}
const moderno = `Evento: ${evento.titulo} (${evento.vagas} vagas)`

// Suporta múltiplas linhas sem precisar de \n
const bloco = `
  Título: ${evento.titulo}
  Vagas: ${evento.vagas}
`
console.log(moderno)

3.3 Arrow functions e this

JavaScript
// Função tradicional
function dobrar(numero) {
  return numero * 2
}

// Arrow function equivalente
const dobrarArrow = (numero) => numero * 2

// Com múltiplos parâmetros e corpo de bloco
const somarVagas = (a, b) => {
  const total = a + b
  return total
}

// Sem parâmetros, precisa dos parênteses vazios
const gerarId = () => Math.floor(Math.random() * 1000)

A diferença mais importante não é a sintaxe curta — é o comportamento do this.

JavaScript
const contador = {
  vagas: 10,
  // `function` tradicional: `this` é o objeto que CHAMA o método (contador).
  reduzirComFunction: function () {
    setTimeout(function () {
      // aqui `this` NÃO é mais `contador` — em modo estrito, é `undefined`.
      console.log(this?.vagas) // undefined
    }, 100)
  },
  // arrow function: `this` é herdado do escopo onde a arrow foi DEFINIDA.
  reduzirComArrow: function () {
    setTimeout(() => {
      // aqui `this` continua sendo `contador`, porque a arrow "pega emprestado"
      // o `this` do método externo.
      console.log(this.vagas) // 10
    }, 100)
  },
}

contador.reduzirComFunction()
contador.reduzirComArrow()

🔎 Por baixo do capô Arrow functions não têm seu próprio this — em vez de criar um novo, elas capturam o this do escopo léxico onde foram escritas. É exatamente por isso que, dentro de um <script setup> do Vue (Aula 02), quase sempre usamos arrow functions ou funções normais no nível do módulo: o comportamento de this deixa de ser um problema porque a Composition API não depende dele.

3.4 Desestruturação de objetos e arrays

JavaScript
const evento = {
  id: 2,
  titulo: 'Oficina de Vue.js',
  categoria: 'minicurso',
  vagas: 25,
  local: 'Bloco B, sala 12',
}

// Desestruturação de objeto: extrai propriedades para variáveis
const { titulo, vagas } = evento
console.log(titulo, vagas) // Oficina de Vue.js 25

// Renomear ao desestruturar
const { titulo: nomeDoEvento } = evento
console.log(nomeDoEvento) // Oficina de Vue.js

// Valor default se a propriedade não existir
const { imagemUrl = '/img/padrao.png' } = evento
console.log(imagemUrl) // /img/padrao.png

// Desestruturação de array: extrai por posição
const coordenadas = [-16.0736, -57.6789]
const [latitude, longitude] = coordenadas
console.log(latitude, longitude) // -16.0736 -57.6789

// Desestruturação em parâmetros de função — muito comum no Vue com props
function exibirEvento({ titulo, vagas }) {
  return `${titulo}: ${vagas} vagas`
}
console.log(exibirEvento(evento)) // Oficina de Vue.js: 25 vagas

3.5 Spread e rest

JavaScript
// Spread (...) em array: "espalha" os elementos
const categoriasBase = ['palestra', 'minicurso']
const todasCategorias = [...categoriasBase, 'workshop']
console.log(todasCategorias) // ['palestra', 'minicurso', 'workshop']

// Spread em objeto: cria uma CÓPIA com propriedades sobrescritas
// (importante: nunca mutar o objeto original em Vue)
const eventoOriginal = { id: 1, titulo: 'Semana da Computação', vagas: 40 }
const eventoAtualizado = { ...eventoOriginal, vagas: 39 }
console.log(eventoOriginal.vagas)   // 40 — original intocado
console.log(eventoAtualizado.vagas) // 39 — cópia com a mudança

// Rest (...) em parâmetros: agrupa "o resto" dos argumentos em array
function somarTodasAsVagas(...quantidades) {
  return quantidades.reduce((total, atual) => total + atual, 0)
}
console.log(somarTodasAsVagas(10, 20, 30)) // 60

// Rest em desestruturação: agrupa "o resto" das propriedades
const { id, ...detalhesDoEvento } = eventoOriginal
console.log(id)              // 1
console.log(detalhesDoEvento) // { titulo: 'Semana da Computação', vagas: 40 }

3.6 Parâmetros default

JavaScript
function criarEvento(titulo, categoria = 'palestra', vagas = 30) {
  return { titulo, categoria, vagas }
}

console.log(criarEvento('Minicurso de Git'))
// { titulo: 'Minicurso de Git', categoria: 'palestra', vagas: 30 }

console.log(criarEvento('Workshop de Testes', 'workshop', 15))
// { titulo: 'Workshop de Testes', categoria: 'workshop', vagas: 15 }

3.7 Optional chaining (?.) e nullish coalescing (??)

JavaScript
const evento = {
  titulo: 'Semana da Computação',
  local: {
    predio: 'Bloco A',
    // sala não foi informada
  },
}

// Sem optional chaining, acessar uma propriedade aninhada ausente quebra:
// console.log(evento.organizador.nome) // TypeError: Cannot read properties of undefined

// Com optional chaining: retorna `undefined` em vez de lançar erro
console.log(evento.organizador?.nome) // undefined
console.log(evento.local?.sala)       // undefined
console.log(evento.local?.predio)     // Bloco A

// Funciona também para chamar métodos que podem não existir
const relatorio = null
console.log(relatorio?.gerar?.()) // undefined, sem quebrar

// Nullish coalescing (??): fornece um valor padrão SOMENTE quando o
// valor à esquerda é null ou undefined (diferente do || , que também
// cai no padrão para 0, '' ou false — o que costuma ser um bug).
const vagasInformadas = 0
console.log(vagasInformadas || 10) // 10 — ERRADO: 0 é um valor válido de vagas!
console.log(vagasInformadas ?? 10) // 0  — CORRETO: só usa o padrão se for null/undefined

const sala = evento.local?.sala ?? 'a definir'
console.log(sala) // a definir

⚠️ Atenção || e ?? parecem intercambiáveis, mas não são. Use ?? sempre que 0, '' ou false forem valores legítimos que você não quer substituir pelo padrão. É um erro comum em formulários (campo numérico zerado sendo tratado como "vazio").

3.8 Métodos de array: map, filter, reduce, find, some, every, sort

Vamos usar o mesmo array de eventos em todos os exemplos — é o dado que sustentará o UniEventos a partir da Aula 02.

JavaScript
const eventos = [
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', dataHora: '2026-09-10T19:00:00', vagas: 40, inscritos: 12 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', dataHora: '2026-08-20T14:00:00', vagas: 25, inscritos: 25 },
  { id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', dataHora: '2026-10-05T08:00:00', vagas: 60, inscritos: 18 },
  { id: 4, titulo: 'Introdução a IA', categoria: 'palestra', dataHora: '2026-08-18T19:30:00', vagas: 80, inscritos: 55 },
]

map — transforma cada item em outra coisa, sem mudar o tamanho do array:

JavaScript
const titulos = eventos.map((evento) => evento.titulo)
console.log(titulos)
// ['Semana da Computação', 'Oficina de Vue.js', 'Hackathon FACET', 'Introdução a IA']

// map devolvendo objetos NOVOS (sem mutar os originais) — padrão que
// vamos repetir sempre que precisarmos "decorar" dados para a tela
const eventosComVagasRestantes = eventos.map((evento) => ({
  ...evento,
  vagasRestantes: evento.vagas - evento.inscritos,
}))
console.log(eventosComVagasRestantes[0].vagasRestantes) // 28

filter — seleciona um subconjunto:

JavaScript
const palestras = eventos.filter((evento) => evento.categoria === 'palestra')
console.log(palestras.length) // 2

const comVagas = eventos.filter((evento) => evento.inscritos < evento.vagas)
console.log(comVagas.map((e) => e.titulo)) // exclui a "Oficina de Vue.js" (lotada)

reduce — acumula os itens em um único valor:

JavaScript
const totalDeVagas = eventos.reduce((acumulado, evento) => acumulado + evento.vagas, 0)
console.log(totalDeVagas) // 205

// reduce também serve para agrupar por categoria
const porCategoria = eventos.reduce((grupos, evento) => {
  const chave = evento.categoria
  if (!grupos[chave]) {
    grupos[chave] = []
  }
  grupos[chave].push(evento)
  return grupos
}, {})
console.log(Object.keys(porCategoria)) // ['palestra', 'minicurso', 'workshop']

find — retorna o primeiro item que bate na condição (ou undefined):

JavaScript
const eventoBuscado = eventos.find((evento) => evento.id === 3)
console.log(eventoBuscado.titulo) // Hackathon FACET

const inexistente = eventos.find((evento) => evento.id === 999)
console.log(inexistente) // undefined

some e every — testam a coleção e retornam boolean:

JavaScript
const existeEventoLotado = eventos.some((evento) => evento.inscritos >= evento.vagas)
console.log(existeEventoLotado) // true (a Oficina de Vue.js está lotada)

const todosTemVagas = eventos.every((evento) => evento.inscritos < evento.vagas)
console.log(todosTemVagas) // false

sort — ordena o array (⚠️ muta o array original):

JavaScript
// sort() muda o array ORIGINAL. Para não afetar `eventos`, copie antes com spread.
const eventosPorData = [...eventos].sort(
  (a, b) => new Date(a.dataHora) - new Date(b.dataHora),
)
console.log(eventosPorData.map((e) => e.titulo))
// ['Oficina de Vue.js', 'Introdução a IA', 'Semana da Computação', 'Hackathon FACET']

console.log(eventos.map((e) => e.titulo))
// ainda na ordem original — porque ordenamos a CÓPIA, não `eventos`

Encadeando métodos — o padrão mais comum no dia a dia:

JavaScript
const resumoDePalestrasComVaga = eventos
  .filter((evento) => evento.categoria === 'palestra')
  .filter((evento) => evento.inscritos < evento.vagas)
  .map((evento) => `${evento.titulo} (${evento.vagas - evento.inscritos} vagas livres)`)

console.log(resumoDePalestrasComVaga)
// ['Semana da Computação (28 vagas livres)', 'Introdução a IA (25 vagas livres)']

3.9 Objetos e shorthand

JavaScript
const titulo = 'Semana da Computação'
const vagas = 40

// Antes: repetir a chave e o valor
const eventoAntigo = { titulo: titulo, vagas: vagas }

// Shorthand: quando o nome da variável é igual ao nome da propriedade
const eventoModerno = { titulo, vagas }
console.log(eventoModerno) // { titulo: 'Semana da Computação', vagas: 40 }

// Shorthand também funciona para métodos
const gerenciadorDeEventos = {
  eventos: [],
  adicionar(evento) {          // em vez de: adicionar: function (evento) { ... }
    this.eventos.push(evento)
  },
  contar() {
    return this.eventos.length
  },
}
gerenciadorDeEventos.adicionar({ titulo: 'Novo evento' })
console.log(gerenciadorDeEventos.contar()) // 1

// Nomes de propriedade computados
const chave = 'categoria'
const filtro = { [chave]: 'palestra' }
console.log(filtro) // { categoria: 'palestra' }

3.10 Módulos ES: import e export

Organizar código em módulos é essencial — é assim que um projeto Vue inteiro é estruturado, um arquivo por responsabilidade.

JavaScript
// arquivo: eventos.js
// export nomeado: pode haver vários por arquivo
export const CATEGORIAS = ['palestra', 'minicurso', 'workshop']

export function filtrarPorCategoria(eventos, categoria) {
  if (!categoria) return eventos
  return eventos.filter((evento) => evento.categoria === categoria)
}

export function calcularVagasRestantes(evento) {
  return evento.vagas - evento.inscritos
}

// export default: no máximo um por arquivo — geralmente a "coisa principal"
export default class GerenciadorDeEventos {
  constructor(eventosIniciais = []) {
    this.eventos = eventosIniciais
  }

  adicionar(evento) {
    this.eventos.push(evento)
  }
}
JavaScript
// arquivo: main.js
// import nomeado: usa chaves { } e o mesmo nome do export
import { CATEGORIAS, filtrarPorCategoria } from './eventos.js'

// import default: sem chaves, você escolhe o nome
import GerenciadorDeEventos from './eventos.js'

// import combinando os dois
import GerenciadorDeEventos2, { calcularVagasRestantes } from './eventos.js'

console.log(CATEGORIAS) // ['palestra', 'minicurso', 'workshop']

const gerenciador = new GerenciadorDeEventos()
gerenciador.adicionar({ titulo: 'Minicurso de Git', categoria: 'minicurso', vagas: 20, inscritos: 5 })
console.log(calcularVagasRestantes(gerenciador.eventos[0])) // 15

Para rodar módulos ES direto no navegador (sem bundler ainda), o HTML precisa declarar type="module":

HTML
<script type="module" src="main.js"></script>

📌 Na prova A partir da Aula 02, todo componente .vue é, por baixo, um módulo ES: ele importa outros componentes com import e é importado por quem o usa. Entender import/export agora evita confusão depois.

3.11 Classes

JavaScript
class Evento {
  // Campos de instância (sintaxe moderna, sem precisar declarar no constructor)
  inscritos = 0

  constructor(titulo, categoria, vagas) {
    this.titulo = titulo
    this.categoria = categoria
    this.vagas = vagas
  }

  // Método de instância
  inscrever() {
    if (this.inscritos >= this.vagas) {
      throw new Error('Evento lotado')
    }
    this.inscritos += 1
  }

  // Getter: parece uma propriedade, mas é calculado
  get vagasRestantes() {
    return this.vagas - this.inscritos
  }
}

// Herança com extends
class Minicurso extends Evento {
  constructor(titulo, vagas, cargaHoraria) {
    super(titulo, 'minicurso', vagas) // chama o constructor da classe-mãe
    this.cargaHoraria = cargaHoraria
  }
}

const oficina = new Minicurso('Oficina de Vue.js', 25, 4)
oficina.inscrever()
oficina.inscrever()
console.log(oficina.vagasRestantes) // 23
console.log(oficina.cargaHoraria)   // 4
console.log(oficina instanceof Evento) // true

3.12 JSON: parse e stringify

JavaScript
const evento = { id: 1, titulo: 'Semana da Computação', vagas: 40 }

// Objeto JavaScript → texto JSON (para enviar em uma requisição, por exemplo)
const textoJson = JSON.stringify(evento)
console.log(textoJson) // '{"id":1,"titulo":"Semana da Computação","vagas":40}'

// Com indentação, útil para debug/log
console.log(JSON.stringify(evento, null, 2))

// Texto JSON → objeto JavaScript (o inverso — comum ao ler resposta de API)
const textoRecebido = '{"id":2,"titulo":"Oficina de Vue.js","vagas":25}'
const objetoRecebido = JSON.parse(textoRecebido)
console.log(objetoRecebido.titulo) // Oficina de Vue.js

3.13 Assíncrono: de callback a async/await

O JavaScript é de thread única, então operações demoradas (rede, temporizadores) precisam de um jeito de "avisar quando terminar" sem travar tudo. A linguagem evoluiu em três estágios.

Estágio 1 — callback (o jeito antigo, difícil de encadear):

JavaScript
function buscarEventoComCallback(id, aoTerminar) {
  setTimeout(() => {
    aoTerminar({ id, titulo: 'Semana da Computação' })
  }, 500)
}

buscarEventoComCallback(1, (evento) => {
  console.log('recebido:', evento.titulo)
  // se precisasse buscar outra coisa depois, teria que aninhar
  // outro callback aqui dentro — o famoso "callback hell"
})

Estágio 2 — Promise (representa um valor que existirá no futuro):

JavaScript
function buscarEventoComPromise(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        resolve({ id, titulo: 'Semana da Computação' })
      } else {
        reject(new Error('id inválido'))
      }
    }, 500)
  })
}

buscarEventoComPromise(1)
  .then((evento) => console.log('recebido:', evento.titulo))
  .catch((erro) => console.error('deu erro:', erro.message))

Estágio 3 — async/await (mesma Promise por baixo, sintaxe que lê como código síncrono):

JavaScript
async function carregarEvento() {
  try {
    const evento = await buscarEventoComPromise(1)
    console.log('recebido:', evento.titulo)
  } catch (erro) {
    console.error('deu erro:', erro.message)
  }
}

carregarEvento()

🔎 Por baixo do capô async/await não é uma tecnologia nova e diferente de Promise — é açúcar sintático sobre Promise. await pausa a execução da função async até a Promise resolver ou rejeitar, sem bloquear o restante do programa. Todo await precisa estar dentro de uma função marcada async.

fetch com async/await e try/catch — o padrão que vamos usar o semestre inteiro:

JavaScript
async function buscarEventosDaApi() {
  try {
    const resposta = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')

    if (!resposta.ok) {
      throw new Error(`Erro HTTP: ${resposta.status}`)
    }

    const dados = await resposta.json()
    console.log('eventos recebidos:', dados.length)
    return dados
  } catch (erro) {
    console.error('falha ao buscar eventos:', erro.message)
    return []
  }
}

buscarEventosDaApi()

Promise.all — disparar várias requisições em paralelo e esperar todas:

JavaScript
async function carregarDadosDaHome() {
  try {
    const [respostaEventos, respostaCategorias] = await Promise.all([
      fetch('https://jsonplaceholder.typicode.com/posts?_limit=5'),
      fetch('https://jsonplaceholder.typicode.com/users?_limit=3'),
    ])

    const eventos = await respostaEventos.json()
    const categorias = await respostaCategorias.json()

    console.log('eventos:', eventos.length, 'categorias:', categorias.length)
  } catch (erro) {
    // Promise.all rejeita assim que QUALQUER uma das promises falhar
    console.error('alguma requisição falhou:', erro.message)
  }
}

carregarDadosDaHome()

⚠️ Atenção Promise.all falha rápido: se uma das promises rejeitar, todas as outras são "abandonadas" do ponto de vista do .catch/try-catch, mesmo que já estivessem resolvidas. Quando precisar do resultado de todas independentemente de falha, use Promise.allSettled (não obrigatório nesta disciplina, mas bom saber que existe).

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Module / Revealing Module

O padrão Module organiza código relacionado (estado + comportamento) dentro de um único bloco, escondendo detalhes internos e expondo apenas uma interface pública. Antes dos módulos ES nativos, isso era feito com uma IIFE (função invocada imediatamente) que retornava um objeto com as partes públicas — o Revealing Module Pattern:

```js // Revealing Module Pattern — jeito pré-ES2015 de encapsular const GerenciadorDeEventos = (function () { // "privado": só existe dentro deste escopo de função let eventos = []

function adicionar(evento) { eventos.push(evento) }

function contarVagas() { return eventos.reduce((total, e) => total + e.vagas, 0) }

// "revela" (expõe) só o que deve ser público return { adicionar, contarVagas, } })()

GerenciadorDeEventos.adicionar({ titulo: 'Semana da Computação', vagas: 40 }) console.log(GerenciadorDeEventos.contarVagas()) // 40 // GerenciadorDeEventos.eventos não existe aqui fora — está encapsulado ```

Os módulos ES (import/export, Seção 3.10) resolvem o mesmo problema de forma nativa e sem a necessidade da IIFE: tudo que não é exportado com export é automaticamente privado ao arquivo. É o mesmo padrão de projeto, com sintaxe de linguagem em vez de truque de engenharia. Todo componente .vue que você vai escrever a partir da Aula 02 é, conceitualmente, um Module: estado interno + funções, expondo ao <template> só o que for necessário.

💻 Mão na massa — configurando o primeiro arquivo de revisão

Vamos consolidar tudo em um único exercício guiado, rodado no navegador.

Passo 1 — crie a pasta e os arquivos.

Terminal
mkdir -p ~/fds-aula01 && cd ~/fds-aula01
touch index.html eventos.js main.js

Passo 2 — o HTML que carrega o módulo:

HTML
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <title>Aula 01 — Revisão de JavaScript</title>
</head>
<body>
  <h1>Abra o Console do navegador (F12) para ver os resultados</h1>
  <ul id="saida"></ul>

  <script type="module" src="main.js"></script>
</body>
</html>

Passo 3 — o módulo com os dados e as funções:

JavaScript
// eventos.js
export const eventos = [
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', dataHora: '2026-09-10T19:00:00', vagas: 40, inscritos: 12 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', dataHora: '2026-08-20T14:00:00', vagas: 25, inscritos: 25 },
  { id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', dataHora: '2026-10-05T08:00:00', vagas: 60, inscritos: 18 },
  { id: 4, titulo: 'Introdução a IA', categoria: 'palestra', dataHora: '2026-08-18T19:30:00', vagas: 80, inscritos: 55 },
]

export function filtrarPorCategoria(lista, categoria) {
  if (!categoria) return lista
  return lista.filter((evento) => evento.categoria === categoria)
}

export function ordenarPorData(lista) {
  return [...lista].sort((a, b) => new Date(a.dataHora) - new Date(b.dataHora))
}

export function totalDeVagas(lista) {
  return lista.reduce((total, evento) => total + evento.vagas, 0)
}

export function formatarData(dataIso) {
  const data = new Date(dataIso)
  return new Intl.DateTimeFormat('pt-BR', {
    day: '2-digit',
    month: 'long',
    hour: '2-digit',
    minute: '2-digit',
  }).format(data)
}

Passo 4 — consumir o módulo, renderizar no DOM e buscar dados de uma API:

JavaScript
// main.js
import { eventos, filtrarPorCategoria, ordenarPorData, totalDeVagas, formatarData } from './eventos.js'

const listaEl = document.getElementById('saida')

function renderizarLista(lista) {
  listaEl.innerHTML = ''
  lista.forEach((evento) => {
    const item = document.createElement('li')
    item.textContent = `${evento.titulo}${formatarData(evento.dataHora)} (${evento.vagas} vagas)`
    listaEl.appendChild(item)
  })
}

const palestras = filtrarPorCategoria(eventos, 'palestra')
const ordenados = ordenarPorData(eventos)

console.log('total de palestras:', palestras.length)
console.log('total de vagas em todos os eventos:', totalDeVagas(eventos))
renderizarLista(ordenados)

// buscando dados externos de verdade
async function buscarPostsDeExemplo() {
  try {
    const resposta = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=3')
    if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`)
    const posts = await resposta.json()
    console.log('posts de exemplo recebidos da API:', posts)
  } catch (erro) {
    console.error('não foi possível buscar os posts:', erro.message)
  }
}

buscarPostsDeExemplo()

Passo 5 — abra index.html com a extensão Live Server do VS Code (ou qualquer servidor local — módulos ES não funcionam abrindo o arquivo direto com file:// por causa de CORS). Abra o DevTools (F12) e confira o Console.

🧪 Laboratório

Use o array eventos do Passo 3 acima para os exercícios. Crie um arquivo lab.js, importe o que precisar de eventos.js e teste cada exercício no console.

1. Filtrar por categoria — escreva uma função apenasWorkshops(lista) que retorne só os eventos de categoria 'workshop', usando filter.

Resultado esperado: array com 1 item (Hackathon FACET).

Dica

lista.filter((evento) => evento.categoria === 'workshop')

2. Ordenar por vagas restantes — escreva ordenarPorVagasRestantes(lista) que devolva uma cópia do array ordenada da maior para a menor quantidade de vagas restantes (vagas - inscritos), sem mutar o array original.

Resultado esperado: o array original (eventos) mantém a mesma ordem depois de chamar a função.

Dica

Copie primeiro com [...lista], depois use .sort((a, b) => (b.vagas - b.inscritos) - (a.vagas - a.inscritos)).

3. Calcular vagas totais com reduce — escreva vagasRestantesTotais(lista) que retorne a soma de vagas - inscritos de todos os eventos.

Resultado esperado: 55 (para o array de exemplo: 28 + 0 + 42 + 25).

Dica

lista.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0)

4. Formatar datas em pt-BR — usando Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }), formate a dataHora de cada evento e monte um array de strings como "10 de setembro de 2026".

Resultado esperado: 4 strings de data em português.

Dica
JavaScript
const formatador = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' })
eventos.map((evento) => formatador.format(new Date(evento.dataHora)))

5. Buscar dados de uma API pública e renderizar — usando fetch e async/await, busque https://jsonplaceholder.typicode.com/users (sem parâmetro de limite), pegue apenas os 3 primeiros com .slice(0, 3), e renderize o name de cada um em uma lista <ul> no HTML.

Resultado esperado: 3 nomes de usuários aparecendo na página.

Dica

const usuarios = await (await fetch(url)).json(), depois usuarios.slice(0, 3).forEach(...) criando <li> como no Passo 4.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
this é undefined dentro de um callback Usou function tradicional dentro de um método, perdendo o this do objeto Troque por arrow function, que herda o this do escopo externo
Array original mudou de ordem sem eu querer .sort() muta o array original Copie antes: [...array].sort(...)
if (vagas == '40') deu true mas os tipos são diferentes Uso de == faz coerção de tipo Use sempre === e !==
TypeError: Cannot read properties of undefined Esqueceu de colocar await antes de uma Promise Confira se toda chamada assíncrona tem await dentro de uma função async
fetch falha com erro de CORS no console A API não libera requisições vindas do seu domínio local Use uma API que libere CORS publicamente (como JSONPlaceholder) ou rode via proxy/back-end nas aulas futuras

🏠 Atividade assíncrona (1 h)

  1. Escolha o tema do seu projeto autoral seguindo as regras da Seção 1.6.
  2. Crie um repositório público no GitHub chamado <seu-tema>-web (ex.: pantanal-plantas-web).
  3. Escreva um README.md na raiz do repositório contendo: - Uma descrição de 3 a 5 linhas do problema que o projeto resolve. - As entidades do domínio (no mínimo duas relacionadas) com seus campos, no mesmo estilo da Seção 3 do plano de curso (compare com Evento/Inscricao/Usuario do UniEventos). - As telas previstas: pelo menos listagem com filtro, detalhe, e uma área autenticada.
  4. Envie o link do repositório pelo canal indicado no SIGAA.

Critério de pronto: repositório público criado, README.md com descrição, modelo de dados (entidades + campos) e lista de telas.

✅ Checkpoint do projeto autoral

Ao final desta aula, seu repositório deve ter:

  • [ ] Repositório criado no GitHub, nomeado <tema>-web, com visibilidade pública.
  • [ ] README.md com descrição do projeto, entidades e campos, e telas previstas.
  • [ ] Ambiente instalado e verificado: node -v mostrando Node 22 LTS, VS Code com as extensões Vue - Official, ESLint e Prettier.
  • [ ] Git configurado localmente (git config --global user.name/user.email).

📚 Para aprofundar


Próxima aula (02, 19/08/2026): começamos o Vue de verdade — createApp, instância, data/methods (Options API) e Composition API com <script setup>, além das diretivas básicas (v-bind, v-on, v-model, v-if, v-for). Traga o ambiente instalado e o repositório do projeto autoral criado.

Nível 3Unidade 1 · Fundamentos de front-end com Vue.js3 aulas de 50 min + 1 h EAD

Aula 02 — Introdução ao Vue: instância, ciclo de vida e diretivas

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 01 revisamos JavaScript moderno e comparamos DOM manual com o estilo declarativo. Hoje esse estilo declarativo ganha nome: Vue 3.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Explicar o que é o Vue 3, o que significa "framework progressivo" e a diferença entre Options API e Composition API.
  • Criar uma instância Vue via CDN com createApp e entender o ciclo montar/renderizar.
  • Criar um projeto Vue com Vite usando npm create vue@latest e explicar cada arquivo gerado.
  • Distinguir ref() de reactive() e explicar por que .value existe.
  • Usar as diretivas v-bind, v-on, v-model, v-if/v-else-if/v-else, v-show, v-for (com :key) e v-text/v-html corretamente, cada uma com seus casos de uso e armadilhas.
  • Descrever as fases do ciclo de vida de um componente e usar os hooks onMounted e onUnmounted.
  • Construir a primeira versão navegável do UniEventos: lista, busca, filtro e inscrição.

📋 Pré-requisitos desta aula

  • Ambiente instalado na Aula 01: Node 22 LTS, VS Code com Vue - Official/ESLint/Prettier, Git.
  • Conforto com let/const, arrow functions, desestruturação, map/filter, import/export (Aula 01, Seção 3).
  • Repositório do projeto autoral criado com README.md.

⚠️ Atenção Verifique agora, antes de começar: node -v precisa mostrar uma versão 22.18.0 ou superior (ou 24.12.0+). O create-vue desta aula exige isso.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min O que é Vue, Options API vs Composition API, primeiro contato via CDN
2 50 min Projeto Vite real, reatividade (ref/reactive), diretivas de vinculação e eventos
3 50 min Diretivas condicionais/lista, ciclo de vida, mão na massa no UniEventos

1. O que é o Vue 3

Vue é um framework progressivo para construir interfaces de usuário. "Progressivo" significa que você pode adotá-lo aos poucos: usar só para uma parte reativa de uma página HTML existente (como faremos daqui a pouco, via CDN) ou para uma aplicação inteira, com build, roteamento e gerenciamento de estado (como faremos a partir de hoje mesmo, com Vite).

O ecossistema Vue que usaremos no semestre:

Peça Papel Quando entra
Vue 3 (core) Reatividade + renderização de componentes Hoje
Vite Servidor de desenvolvimento e bundler Hoje
Vue Router Navegação entre "páginas" da SPA Aula 04
Pinia Estado compartilhado entre componentes Aula 06
Vuetify Biblioteca de componentes visuais prontos (Material Design) Aula 04
Axios Cliente HTTP para consumir APIs Aula 06

Vamos usar a versão 3.5.41 do Vue, instalada via Vite 8.2.1 com o plugin @vitejs/plugin-vue 6.0.8 — as mesmas versões testadas e listadas no início do curso.

1.1 Options API vs. Composition API

O Vue 3 oferece duas formas de escrever a lógica de um componente. Elas produzem o mesmo resultado; mudam a organização do código.

Options API — organiza o componente em "opções" fixas: data() (estado), methods (funções), computed (Aula 03), mounted() (ciclo de vida) etc. É o estilo herdado do Vue 2, ainda muito usado e citado no plano de curso.

JavaScript
// Options API — cada preocupação vai em uma "caixa" pré-definida
export default {
  data() {
    return {
      contador: 0,
    }
  },
  methods: {
    incrementar() {
      this.contador++
    },
  },
  mounted() {
    console.log('componente montado, contador =', this.contador)
  },
}

Composition API — organiza o componente por funções que você importa e compõe livremente (ref, reactive, onMounted...), agrupando por funcionalidade em vez de por tipo de opção. É o padrão do Vue 3 moderno e o que o create-vue gera por padrão, dentro da sintaxe açucarada <script setup>.

Vue SFC
<script setup>
// Composition API com <script setup> — tudo neste bloco já é
// automaticamente exposto ao <template>, sem "return" manual
import { ref, onMounted } from 'vue'

const contador = ref(0)

function incrementar() {
  contador.value++
}

onMounted(() => {
  console.log('componente montado, contador =', contador.value)
})
</script>

📌 Na prova Esta disciplina usa Composition API com <script setup> do início ao fim, porque é o padrão gerado pelo create-vue e o que você vai encontrar em qualquer projeto Vue 3 novo. Nos primeiros exemplos de hoje mostramos o equivalente em Options API lado a lado — o plano de curso cita explicitamente "instância vue, data e methods" — mas a partir da Aula 03 falamos só Composition API.

2. Primeiro contato: Vue via CDN

Antes de qualquer ferramenta de build, vamos ver o Vue rodando com o mínimo possível: um único arquivo HTML.

HTML
<!-- cdn/index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <title>Primeiro contato com Vue</title>
</head>
<body>
  <div id="app">
    <h1>{{ titulo }}</h1>
    <p>Você clicou {{ contador }} vez(es).</p>
    <button v-on:click="incrementar">Clicar</button>
  </div>

  <script src="https://unpkg.com/vue@3.5.41/dist/vue.global.js"></script>
  <script>
    const { createApp } = Vue

    createApp({
      data() {
        return {
          titulo: 'Olá, Vue!',
          contador: 0,
        }
      },
      methods: {
        incrementar() {
          this.contador++
        },
      },
    }).mount('#app')
  </script>
</body>
</html>

Abra este arquivo direto no navegador (funciona com file://, sem precisar de servidor, porque não há módulos ES aqui). Três ideias novas:

  1. createApp({...}) recebe um objeto de configuração — no estilo Options API — e devolve uma instância da aplicação Vue.
  2. .mount('#app') diz ao Vue: "assuma o controle deste elemento do DOM e tudo dentro dele". A partir daqui, o Vue passa a gerenciar esse pedaço de página.
  3. {{ titulo }} é interpolação de texto: insere o valor da variável reativa titulo no HTML. Sempre que titulo muda, o texto na tela muda sozinho — sem innerHTML, sem addEventListener manual.

🔎 Por baixo do capô {{ }} só funciona dentro do elemento montado (#app e seus descendentes). Fora dele, o Vue nem olha para o HTML — por isso o <h1> do exemplo fica dentro de <div id="a02-app">.

O mesmo exemplo, agora em <script setup> (o estilo que usaremos a partir de agora), ainda via CDN mas em módulo ES:

HTML
<!-- cdn/index-composition.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <title>Primeiro contato — Composition API</title>
</head>
<body>
  <div id="app"></div>

  <script type="module">
    import { createApp, ref } from 'https://unpkg.com/vue@3.5.41/dist/vue.esm-browser.js'

    createApp({
      setup() {
        const titulo = ref('Olá, Vue!')
        const contador = ref(0)

        function incrementar() {
          contador.value++
        }

        return { titulo, contador, incrementar }
      },
      template: `
        <h1>{{ titulo }}</h1>
        <p>Você clicou {{ contador }} vez(es).</p>
        <button v-on:click="incrementar">Clicar</button>
      `,
    }).mount('#app')
  </script>
</body>
</html>

Note que aqui, sem <script setup> (que só existe dentro de arquivos .vue compilados pelo Vite), precisamos declarar setup() manualmente e retornar o que o template usa. É exatamente esse return que o <script setup> elimina automaticamente — daí o nome "açúcar sintático".

3. Migrando para um projeto Vite de verdade

CDN é ótimo para aprender o conceito, mas nenhuma aplicação real do semestre vai ser um único HTML. A partir de agora usamos Vite com Single File Components (.vue).

3.1 Criando o projeto

Terminal
npm create vue@latest

O create-vue (versão 3.23.0) pergunta interativamente o nome do projeto e quais recursos incluir. Para o UniEventos que construiremos em sala, as respostas são:

Texto
✔ Project name: … unieventos-web
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes

Ou, sem o modo interativo, direto com flags:

Terminal
npx create-vue@latest unieventos-web --router --pinia --eslint --prettier
cd unieventos-web
npm install
npm run dev

💡 Dica Já habilitamos --router e --pinia mesmo sem usá-los ainda — eles só entram em cena nas Aulas 04 e 06, mas evita reconfigurar o projeto depois. Os arquivos que eles geram (src/router/index.js, src/stores/counter.js) ficam parados até lá.

3.2 Estrutura gerada

Texto
unieventos-web/
├─ .vscode/
├─ public/favicon.ico
├─ src/
│  ├─ App.vue
│  ├─ main.js
│  ├─ router/index.js
│  └─ stores/counter.js
├─ index.html
├─ jsconfig.json
├─ package.json
└─ vite.config.js
Arquivo Papel
index.html HTML raiz — único ponto de entrada real da SPA, contém <div id="a02-app">
src/main.js Ponto de entrada JS: cria a aplicação, registra plugins, monta no DOM
src/App.vue Componente raiz — tudo que renderizamos começa aqui
src/router/index.js Configuração de rotas (usada a partir da Aula 04)
src/stores/counter.js Exemplo de store Pinia gerado pelo scaffold (usado a partir da Aula 06)
vite.config.js Configuração do Vite: plugins, aliases de importação
package.json Dependências e scripts (npm run dev, npm run build)
jsconfig.json Ajuda o VS Code a resolver imports como @/components/...

src/main.js gerado:

JavaScript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

Compare com o createApp(...).mount('#app') do exemplo CDN: é a mesma API. A diferença é que aqui App vem de um componente .vue importado, e app.use(...) registra plugins (Pinia, Router) que ainda não vamos usar hoje.

Rode o projeto:

Terminal
npm run dev

O Vite sobe um servidor local (normalmente http://localhost:5173) com hot module replacement: você edita um arquivo .vue e a tela atualiza sozinha, sem recarregar a página inteira.

3.3 Anatomia de um Single File Component (SFC)

Abra src/App.vue gerado pelo scaffold — ele vem com bastante conteúdo de boas-vindas. Vamos substituí-lo por algo mínimo para entender a estrutura:

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'

const titulo = ref('UniEventos')
const contador = ref(0)

function incrementar() {
  contador.value++
}
</script>

<template>
  <main>
    <h1>{{ titulo }}</h1>
    <p>Cliques: {{ contador }}</p>
    <button @click="incrementar">Clicar</button>
  </main>
</template>

<style scoped>
main {
  font-family: sans-serif;
  padding: 2rem;
}

h1 {
  color: #2c3e50;
}
</style>

Um .vue tem até três blocos:

  • <script setup> — lógica do componente em Composition API. Tudo declarado aqui (variáveis, funções) fica automaticamente disponível no <template>, sem return explícito — é isso que o setup: '<script setup>' do compilador do Vite faz por você.
  • <template> — o HTML do componente, com as diretivas do Vue.
  • <style scoped> — CSS que se aplica somente a este componente (o Vue adiciona um atributo único a cada elemento na hora do build, isolando o CSS). Sem scoped, o estilo vaza para a aplicação inteira.

⚠️ Atenção <script setup> só existe dentro de arquivos .vue processados pelo Vite — não existe fora desse contexto. Por isso o exemplo CDN da Seção 2 usou setup() { return {...} } explícito.

4. Reatividade: ref() e reactive()

4.1 Por que .value

Vue SFC
<script setup>
import { ref } from 'vue'

// ref() envolve um valor primitivo (ou qualquer valor) em um objeto reativo
const contadorVagas = ref(40)

function reduzirVaga() {
  // DENTRO do <script>, é preciso acessar/alterar via .value
  contadorVagas.value--
  console.log(contadorVagas.value)
}
</script>

<template>
  <!-- NO <template>, o Vue "desembrulha" automaticamente — sem .value -->
  <p>Vagas: {{ contadorVagas }}</p>
  <button @click="reduzirVaga">Inscrever</button>
</template>

🔎 Por baixo do capô Um valor primitivo em JavaScript (number, string, boolean) não tem como "avisar" ninguém quando muda — não há como interceptar contador = contador + 1 para uma variável solta. ref() resolve isso guardando o valor dentro de um objeto ({ value: 40 }) e tornando esse objeto reativo. É por isso que, no script, você sempre acessa .value. No <template>, o compilador do Vue já sabe que uma variável vinda de ref() precisa ser desembrulhada e faz isso por você automaticamente.

4.2 reactive() — para objetos e arrays

Vue SFC
<script setup>
import { reactive } from 'vue'

// reactive() torna um OBJETO inteiro reativo, sem precisar de .value
const evento = reactive({
  titulo: 'Semana da Computação',
  vagas: 40,
  inscritos: 12,
})

function inscrever() {
  // acesso direto às propriedades, sem .value
  evento.inscritos++
}
</script>

<template>
  <p>{{ evento.titulo }}: {{ evento.inscritos }}/{{ evento.vagas }}</p>
  <button @click="inscrever">Inscrever</button>
</template>

🔎 Por baixo do capô reactive() usa um Proxy do JavaScript (recurso nativo do ES2015) para interceptar leituras e escritas nas propriedades do objeto. Toda vez que você lê evento.titulo, o Proxy registra "alguém depende disso"; toda vez que você escreve evento.inscritos = ..., o Proxy avisa "isso mudou, quem depende precisa atualizar". Vamos detalhar esse mecanismo na Aula 03, no box de padrão de projeto Proxy.

4.3 Quando usar cada um

Situação Use
Valor único (número, string, boolean) ref()
Objeto ou array com várias propriedades relacionadas reactive() (ou ref() também funciona para objetos — é uma escolha de estilo)
Precisa substituir o valor inteiro depois (ex.: eventos = novaLista) ref()reactive() não permite reatribuir a variável inteira sem perder a reatividade
Vue SFC
<script setup>
import { ref } from 'vue'

// Para uma LISTA que será recarregada inteira (ex.: vinda de uma API),
// ref() é mais seguro: dá para trocar o array inteiro sem perder reatividade.
const eventos = ref([])

async function carregarEventos() {
  eventos.value = [
    { id: 1, titulo: 'Semana da Computação' },
    { id: 2, titulo: 'Oficina de Vue.js' },
  ]
}
</script>

⚠️ Atenção Se você usasse reactive([]) e depois tentasse eventos = [...] (reatribuir a variável), perderia a conexão reativa — o template continuaria olhando para o array antigo. Com reactive, mude o conteúdo (eventos.push(...), eventos.splice(...)), nunca a referência.

Equivalência com Options API — o data() que você viu na Seção 1.1 usa reatividade automática em tudo que ele retorna, sem você escolher entre ref e reactive:

JavaScript
// Options API — Vue decide a reatividade por trás das cortinas
export default {
  data() {
    return {
      contadorVagas: 40, // equivale a um ref
      evento: { titulo: 'Semana da Computação', inscritos: 12 }, // equivale a um reactive
    }
  },
  methods: {
    inscrever() {
      this.evento.inscritos++ // this.<propriedade>, sem .value
    },
  },
}

5. Diretivas

Diretivas são atributos especiais do Vue, prefixados com v-, que ligam o template ao estado reativo. Vamos ver cada uma com exemplo próprio.

5.1 v-bind — vincular atributos HTML

Vue SFC
<script setup>
import { ref } from 'vue'

const evento = ref({
  titulo: 'Oficina de Vue.js',
  imagemUrl: '/img/oficina-vue.jpg',
  linkInativo: true,
})
</script>

<template>
  <!-- forma completa -->
  <img v-bind:src="evento.imagemUrl" v-bind:alt="evento.titulo" />

  <!-- atalho ":" — é o que se usa no dia a dia -->
  <img :src="evento.imagemUrl" :alt="evento.titulo" />

  <!-- vinculando um atributo booleano -->
  <button :disabled="evento.linkInativo">Ver detalhes</button>
</template>

v-bind conecta um atributo do HTML a uma expressão JavaScript reativa. Sem ele, src="evento.imagemUrl" seria só o texto literal "evento.imagemUrl" — não avaliaria a expressão.

5.2 v-on — escutar eventos

Vue SFC
<script setup>
import { ref } from 'vue'

const contador = ref(0)

function incrementar() {
  contador.value++
}

function tratarEnvio() {
  console.log('formulário enviado, sem recarregar a página')
}
</script>

<template>
  <!-- forma completa -->
  <button v-on:click="incrementar">+1</button>

  <!-- atalho "@" — o que se usa no dia a dia -->
  <button @click="incrementar">+1</button>

  <!-- modificador .prevent: chama event.preventDefault() automaticamente -->
  <form @submit.prevent="tratarEnvio">
    <button type="submit">Enviar</button>
  </form>

  <!-- modificador .stop: chama event.stopPropagation() -->
  <div @click="console.log('clique no pai')">
    <button @click.stop="console.log('clique só no botão')">Não propaga</button>
  </div>

  <!-- modificador .once: o handler roda só na primeira vez -->
  <button @click.once="console.log('só uma vez')">Clique único</button>

  <!-- modificador de tecla: só dispara com Enter -->
  <input @keyup.enter="incrementar" placeholder="Pressione Enter" />
</template>

5.3 v-model — vinculação bidirecional em formulários

v-model é açúcar sintático que combina v-bind (mostra o valor) com v-on (atualiza o valor a cada mudança), poupando você de escrever os dois manualmente.

Vue SFC
<script setup>
import { ref } from 'vue'

const busca = ref('')
const observacoes = ref('')
const aceitaTermos = ref(false)
const categoriasEscolhidas = ref([])
const categoriaSelecionada = ref('palestra')
const email = ref('')
</script>

<template>
  <!-- input de texto -->
  <input v-model="busca" type="text" placeholder="Buscar evento..." />
  <p>Buscando por: {{ busca }}</p>

  <!-- textarea -->
  <textarea v-model="observacoes" placeholder="Observações"></textarea>

  <!-- checkbox único: liga a uma variável boolean -->
  <label>
    <input v-model="aceitaTermos" type="checkbox" />
    Aceito os termos
  </label>

  <!-- vários checkboxes: liga a um array — cada "value" marcado entra no array -->
  <label><input v-model="categoriasEscolhidas" type="checkbox" value="palestra" /> Palestra</label>
  <label><input v-model="categoriasEscolhidas" type="checkbox" value="minicurso" /> Minicurso</label>
  <p>Selecionadas: {{ categoriasEscolhidas }}</p>

  <!-- radio: só um valor por grupo de "name" implícito pelo v-model -->
  <label><input v-model="categoriaSelecionada" type="radio" value="palestra" /> Palestra</label>
  <label><input v-model="categoriaSelecionada" type="radio" value="workshop" /> Workshop</label>

  <!-- select -->
  <select v-model="categoriaSelecionada">
    <option value="palestra">Palestra</option>
    <option value="minicurso">Minicurso</option>
    <option value="workshop">Workshop</option>
  </select>

  <!-- modificadores -->
  <!-- .trim: remove espaços das pontas automaticamente -->
  <input v-model.trim="email" type="email" placeholder="seu@email.com" />

  <!-- .number: converte o valor digitado para Number -->
  <input v-model.number="categoriaSelecionada" type="number" />

  <!-- .lazy: sincroniza no evento "change" (ao sair do campo), não a cada tecla -->
  <input v-model.lazy="busca" type="text" />
</template>

💡 Dica v-model é o par perfeito para o formulário de inscrição do UniEventos que vamos montar hoje: o valor do campo de busca já fica disponível como variável reativa, sem escrever um único addEventListener.

5.4 v-if, v-else-if, v-else

Vue SFC
<script setup>
import { ref } from 'vue'

const vagasRestantes = ref(0)
</script>

<template>
  <p v-if="vagasRestantes > 10">Vagas disponíveis</p>
  <p v-else-if="vagasRestantes > 0">Últimas vagas!</p>
  <p v-else>Evento lotado</p>
</template>

v-if (e seus complementos) adiciona ou remove o elemento do DOM conforme a condição — quando falso, o elemento simplesmente não existe na página.

5.5 v-show — a alternativa que só esconde

Vue SFC
<script setup>
import { ref } from 'vue'

const mostrarDetalhes = ref(false)
</script>

<template>
  <button @click="mostrarDetalhes = !mostrarDetalhes">Alternar detalhes</button>

  <!-- o elemento SEMPRE existe no DOM; v-show só alterna display: none -->
  <div v-show="mostrarDetalhes">
    <p>Estes são os detalhes completos do evento.</p>
  </div>
</template>
v-if v-show
Como funciona Remove/insere o elemento no DOM Alterna display: none via CSS
Custo de alternar Mais caro (recria o elemento) Mais barato (só troca CSS)
Custo inicial se falso Mais barato (nem renderiza) Mais caro (sempre renderiza)
Quando usar Condição muda raramente Condição alterna com frequência (ex.: abrir/fechar painel)

5.6 v-for e a importância da :key

Vue SFC
<script setup>
import { ref } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra' },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso' },
  { id: 3, titulo: 'Hackathon FACET', categoria: 'workshop' },
])
</script>

<template>
  <ul>
    <!-- CORRETO: key única e estável (o id do dado, nunca o índice) -->
    <li v-for="evento in eventos" :key="evento.id">
      {{ evento.titulo }} — {{ evento.categoria }}
    </li>
  </ul>

  <!-- v-for também expõe o índice, como segundo parâmetro -->
  <ol>
    <li v-for="(evento, indice) in eventos" :key="evento.id">
      {{ indice + 1 }}. {{ evento.titulo }}
    </li>
  </ol>
</template>

O bug do índice como :key:

Vue SFC
<script setup>
import { ref } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação' },
  { id: 2, titulo: 'Oficina de Vue.js' },
  { id: 3, titulo: 'Hackathon FACET' },
])

function removerPrimeiro() {
  eventos.value.shift() // remove o item do início
}
</script>

<template>
  <!-- ERRADO: usar o índice como key -->
  <div v-for="(evento, indice) in eventos" :key="indice">
    <input type="checkbox" /> {{ evento.titulo }}
  </div>

  <button @click="removerPrimeiro">Remover o primeiro</button>
</template>

Se cada <input type="checkbox"> tiver estado próprio (marcado por quem interage) e você remover o primeiro item da lista, o Vue reaproveitará os elementos DOM pelo índice: o segundo item (índice 0 agora) herda o checkbox que estava marcado no antigo primeiro item, mesmo sendo um dado diferente. O texto atualiza corretamente, mas o estado interno do elemento (checkbox marcado, valor de input, foco) fica errado, porque o Vue pensa que é "o mesmo elemento" da posição 0.

⚠️ Atenção Use sempre um identificador estável e único do dado (evento.id) como :key, nunca o índice do v-for. O índice muda quando a lista é reordenada, filtrada ou tem itens removidos — e o Vue usa a key exatamente para saber "isso é o mesmo item de antes ou é outro?".

5.7 v-text e v-html

Vue SFC
<script setup>
import { ref } from 'vue'

const descricaoSimples = ref('Evento sobre Vue.js')
const descricaoComHtml = ref('<strong>Evento</strong> sobre Vue.js')
</script>

<template>
  <!-- v-text é equivalente a {{ }}, mas substitui TODO o conteúdo do elemento -->
  <p v-text="descricaoSimples"></p>

  <!-- interpolação normal: sempre trata o conteúdo como TEXTO puro (escapa HTML) -->
  <p>{{ descricaoComHtml }}</p>
  <!-- renderiza literalmente: <strong>Evento</strong> sobre Vue.js -->

  <!-- v-html: injeta HTML de verdade, interpretado pelo navegador -->
  <p v-html="descricaoComHtml"></p>
  <!-- renderiza: Evento (em negrito) sobre Vue.js -->
</template>

⚠️ Atenção — risco de XSS v-html injeta HTML bruto na página, sem escapar. Se o conteúdo vier de um usuário (comentário, campo de formulário, dado de API não confiável) e contiver <script> ou atributos como onerror=, isso executa no navegador de quem visualiza a página — um ataque de Cross-Site Scripting (XSS). Use v-html só com conteúdo que você controla (texto formatado vindo do seu próprio backend, sanitizado). Para exibir texto de usuário, use interpolação {{ }} normal, que sempre escapa.

6. Ciclo de vida do componente

Todo componente Vue passa por fases previsíveis, do momento em que é criado até ser destruído.

Texto
criação do componente
        │
        ▼
  onBeforeMount()   ← ainda não existe no DOM real
        │
        ▼
   [Vue monta o componente no DOM]
        │
        ▼
    onMounted()      ← já existe no DOM, pode acessar elementos, disparar fetch
        │
        ▼
  (o componente vive aqui — reage a mudanças de estado)
        │
        ├──► dado reativo muda
        │         │
        │         ▼
        │   onBeforeUpdate()  ← estado já mudou, DOM ainda não
        │         │
        │         ▼
        │   [Vue re-renderiza o DOM]
        │         │
        │         ▼
        │      onUpdated()    ← DOM já reflete o novo estado
        │         │
        │         └──► volta a "viver" aqui
        │
        ▼
  onBeforeUnmount()  ← componente prestes a ser removido
        │
        ▼
   [Vue remove o componente do DOM]
        │
        ▼
    onUnmounted()     ← já foi removido; hora de limpar recursos
Vue SFC
<!-- src/components/DemoCicloDeVida.vue -->
<script setup>
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'

const segundos = ref(0)
let intervaloId = null

onBeforeMount(() => {
  console.log('[ciclo] onBeforeMount — ainda não está no DOM')
})

onMounted(() => {
  console.log('[ciclo] onMounted — já está no DOM, iniciando o relógio')

  // setInterval é um recurso "externo" ao Vue — precisa ser limpo manualmente
  intervaloId = setInterval(() => {
    segundos.value++
  }, 1000)
})

onBeforeUpdate(() => {
  console.log('[ciclo] onBeforeUpdate — segundos mudou para', segundos.value, 'mas o DOM ainda não')
})

onUpdated(() => {
  console.log('[ciclo] onUpdated — DOM já mostra', segundos.value)
})

onBeforeUnmount(() => {
  console.log('[ciclo] onBeforeUnmount — componente prestes a sumir')
})

onUnmounted(() => {
  console.log('[ciclo] onUnmounted — limpando o setInterval')
  // ESSENCIAL: sem isso, o timer continua rodando mesmo após o
  // componente sumir da tela — um vazamento de memória clássico.
  clearInterval(intervaloId)
})
</script>

<template>
  <p>Segundos desde a montagem: {{ segundos }}</p>
</template>

Equivalência com Options API:

Composition API Options API
onBeforeMount beforeMount()
onMounted mounted()
onBeforeUpdate beforeUpdate()
onUpdated updated()
onBeforeUnmount beforeUnmount()
onUnmounted unmounted()
JavaScript
// Options API — os mesmos hooks, como métodos especiais do objeto
export default {
  data() {
    return { segundos: 0, intervaloId: null }
  },
  mounted() {
    console.log('mounted')
    this.intervaloId = setInterval(() => { this.segundos++ }, 1000)
  },
  unmounted() {
    clearInterval(this.intervaloId)
  },
}

📌 Na prova onMounted é, de longe, o hook mais usado na prática — é onde disparamos requisições fetch (Aula 03) porque é o primeiro momento em que temos garantia de que o DOM existe. onUnmounted é onde limpamos qualquer recurso externo (setInterval, addEventListener em window, conexões abertas) para não vazar memória quando o componente sai de cena.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Observer (comportamental)

O padrão Observer define uma relação um-para-muitos entre um objeto (o subject, que muda de estado) e vários observers, que são notificados automaticamente sempre que o subject muda — sem que o subject precise conhecer os observers individualmente.

Um Observer "na mão", em JavaScript puro:

```js // Um EventTarget simplificado — a base do Observer em JS puro class ContadorObservavel { constructor() { this.valor = 0 this.observadores = [] }

observar(funcaoCallback) { this.observadores.push(funcaoCallback) }

incrementar() { this.valor++ // notifica TODOS os observadores registrados this.observadores.forEach((callback) => callback(this.valor)) } }

const contador = new ContadorObservavel() contador.observar((valor) => console.log('UI A atualizada:', valor)) contador.observar((valor) => console.log('UI B atualizada:', valor)) contador.incrementar() // dispara os dois observadores ```

É exatamente isso que o sistema de reatividade do Vue faz por baixo dos panos. Quando você escreve {{ contador }} no template, o Vue registra esse trecho do DOM como um "observador" da variável contador. Quando você escreve contador.value++, o Vue percorre a lista de observadores daquela variável (os pedaços de template que a usam) e re-renderiza só eles — sem você escrever observar() ou notificar() manualmente. ref e reactive são, na essência, subjects observáveis; cada trecho do template que os lê vira, automaticamente, um observer. Vamos abrir esse mecanismo com mais detalhe na Aula 03, quando falarmos do padrão Proxy.

💻 Mão na massa — primeira versão do UniEventos

Vamos construir, dentro do projeto unieventos-web criado na Seção 3, a primeira tela funcional: lista de eventos com busca, filtro por categoria e inscrição.

Passo 1 — dados de exemplo. Crie um arquivo separado só com os dados, para manter o componente organizado (o mesmo raciocínio do módulo eventos.js da Aula 01).

JavaScript
// src/data/eventos.js
export const eventosIniciais = [
  {
    id: 1,
    titulo: 'Semana da Computação',
    categoria: 'palestra',
    dataHora: '2026-09-10T19:00:00',
    local: 'Auditório Central',
    vagas: 40,
    inscritos: 12,
  },
  {
    id: 2,
    titulo: 'Oficina de Vue.js',
    categoria: 'minicurso',
    dataHora: '2026-08-20T14:00:00',
    local: 'Laboratório 3',
    vagas: 25,
    inscritos: 25,
  },
  {
    id: 3,
    titulo: 'Hackathon FACET',
    categoria: 'workshop',
    dataHora: '2026-10-05T08:00:00',
    local: 'Bloco B',
    vagas: 60,
    inscritos: 18,
  },
  {
    id: 4,
    titulo: 'Introdução a IA',
    categoria: 'palestra',
    dataHora: '2026-08-18T19:30:00',
    local: 'Auditório Central',
    vagas: 80,
    inscritos: 55,
  },
]

Passo 2 — o componente principal. Ainda usamos filter "na mão" dentro de uma função (vamos trocar por computed, que faz cache, na Aula 03 — por hoje o objetivo é praticar diretivas).

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
import { eventosIniciais } from './data/eventos.js'

const eventos = ref(eventosIniciais)
const busca = ref('')
const categoriaFiltro = ref('')

// Função comum (não computed ainda) — recalculada manualmente a cada uso.
// Repare que ela SEMPRE cria um array novo com filter, sem mutar `eventos`.
function obterEventosFiltrados() {
  return eventos.value
    .filter((evento) => evento.titulo.toLowerCase().includes(busca.value.toLowerCase()))
    .filter((evento) => categoriaFiltro.value === '' || evento.categoria === categoriaFiltro.value)
}

function inscrever(eventoId) {
  const evento = eventos.value.find((item) => item.id === eventoId)
  if (!evento) return

  if (evento.inscritos >= evento.vagas) {
    alert('Este evento está lotado.')
    return
  }

  evento.inscritos++
}

function vagasRestantes(evento) {
  return evento.vagas - evento.inscritos
}
</script>

<template>
  <main class="pagina">
    <h1>UniEventos</h1>
    <p>Encontre e inscreva-se em eventos acadêmicos.</p>

    <div class="filtros">
      <input
        v-model.trim="busca"
        type="text"
        placeholder="Buscar por título..."
      />

      <select v-model="categoriaFiltro">
        <option value="">Todas as categorias</option>
        <option value="palestra">Palestra</option>
        <option value="minicurso">Minicurso</option>
        <option value="workshop">Workshop</option>
      </select>
    </div>

    <ul class="lista-eventos">
      <li
        v-for="evento in obterEventosFiltrados()"
        :key="evento.id"
        class="card-evento"
      >
        <h2>{{ evento.titulo }}</h2>
        <p>Categoria: {{ evento.categoria }}</p>
        <p>Local: {{ evento.local }}</p>
        <p v-if="vagasRestantes(evento) > 0">
          {{ vagasRestantes(evento) }} vaga(s) restante(s)
        </p>
        <p v-else class="lotado">Evento lotado</p>

        <button
          :disabled="vagasRestantes(evento) === 0"
          @click="inscrever(evento.id)"
        >
          Inscrever-se
        </button>
      </li>
    </ul>

    <p v-if="obterEventosFiltrados().length === 0" class="vazio">
      Nenhum evento encontrado com estes filtros.
    </p>
  </main>
</template>

<style scoped>
.pagina {
  max-width: 720px;
  margin: 0 auto;
  padding: 2rem;
  font-family: sans-serif;
}

.filtros {
  display: flex;
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.lista-eventos {
  list-style: none;
  padding: 0;
  display: grid;
  gap: 1rem;
}

.card-evento {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1rem;
}

.lotado {
  color: #c0392b;
  font-weight: bold;
}

.vazio {
  text-align: center;
  color: #666;
}
</style>

⚠️ Atenção Repare que obterEventosFiltrados() é chamada três vezes no template (na v-for, e de novo para checar se está vazio). Cada chamada refaz o filter duas vezes do zero — funciona, mas é desperdício de processamento e, pior, dificulta manter os resultados sincronizados. Vamos resolver isso com computed() já na próxima aula.

🧪 Laboratório

1. Contador de inscritos totais — adicione, logo abaixo do <h1>, um parágrafo mostrando quantas pessoas estão inscritas somando todos os eventos, usando reduce (Aula 01).

Resultado esperado: um número que aumenta a cada clique em "Inscrever-se".

Dica

eventos.value.reduce((total, evento) => total + evento.inscritos, 0) dentro de uma função chamada no template, ou direto em uma expressão de interpolação.

2. Botão de limpar filtros — adicione um botão que zera busca e categoriaFiltro de uma vez.

Resultado esperado: clicar no botão limpa o campo de texto e volta o select para "Todas as categorias".

Dica
JavaScript
function limparFiltros() {
  busca.value = ''
  categoriaFiltro.value = ''
}

3. Destacar evento quase lotado com v-show — mostre um aviso "Últimas vagas!" com v-show (não v-if) quando vagasRestantes(evento) <= 5 && vagasRestantes(evento) > 0.

Resultado esperado: o aviso aparece/some conforme inscrições, sem recriar o elemento no DOM (confira no DevTools, aba Elements).

Dica

<span v-show="vagasRestantes(evento) <= 5 && vagasRestantes(evento) > 0">Últimas vagas!</span>

4. Modificador .once em uma mensagem de boas-vindas — adicione um botão "Ver dica" que mostra um alerta apenas na primeira vez que for clicado, usando @click.once.

Resultado esperado: cliques seguintes não fazem nada.

Dica

<button @click.once="alert('Dica: use os filtros para encontrar eventos mais rápido!')">Ver dica</button>

5. Corrigir uma :key proposital — troque temporariamente :key="evento.id" por :key="indiceDoLoop" (usando a forma v-for="(evento, indiceDoLoop) in ..."), adicione um <input type="checkbox"> dentro de cada card, marque alguns, filtre por categoria e observe o comportamento estranho dos checkboxes. Depois desfaça a mudança.

Resultado esperado: você reproduz em sala o bug descrito na Seção 5.6 antes de corrigi-lo.

Dica

O bug aparece quando a lista filtrada muda de tamanho/ordem — os checkboxes "grudam" na posição, não no evento.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
Tela em branco, console mostra erro sobre .vue Arquivo .vue com erro de sintaxe, ou <script setup> mal fechado Confira se os três blocos (script, template, style) estão bem formados e balanceados
Alterei contador no script mas a tela não atualiza Esqueceu o .value ao alterar um ref Sempre variavel.value = novoValor dentro do <script setup>
v-model não atualiza nada Esqueceu de declarar a variável com ref(), ou digitou o nome errado no template Confira se a variável usada no v-model existe e foi criada com ref()
Checkbox/input "gruda" em posição errada após filtrar/remover :key usando o índice do v-for Use um identificador estável do dado, como evento.id
v-html mostra texto cru com as tags <...> aparecendo Trocou v-html por interpolação {{ }} sem querer {{ }} sempre escapa HTML; use v-html só quando o objetivo é renderizar HTML de verdade

🏠 Atividade assíncrona (1 h)

No repositório do seu projeto autoral:

  1. Rode npm create vue@latest (ou npx create-vue@latest <seu-tema>-web --router --pinia --eslint --prettier) dentro da pasta do seu projeto.
  2. Crie um arquivo src/data/<entidade principal>.js com um array de pelo menos 4 itens de exemplo do seu domínio (equivalente ao eventos.js de hoje).
  3. Em src/App.vue, monte uma primeira listagem usando v-for com :key correto, um campo de busca com v-model e pelo menos um filtro (v-model em <select>).
  4. Adicione uma ação (ex.: "reservar", "favoritar", "adicionar ao carrinho" — o verbo do seu domínio) usando v-on/@click.
  5. Faça commit e push.

Critério de pronto: npm run dev abre a aplicação, a lista aparece, busca e filtro funcionam, :key usa um identificador estável.

✅ Checkpoint do projeto autoral

  • [ ] Projeto Vite criado com create-vue (flags --router --pinia) no repositório do tema autoral.
  • [ ] src/data/*.js com dados de exemplo do domínio escolhido.
  • [ ] Listagem funcionando com v-for e :key estável (nunca o índice).
  • [ ] Busca com v-model e pelo menos um filtro funcionando.
  • [ ] Uma ação de interação implementada com v-on/@click.
  • [ ] Commit enviado ao GitHub.

📚 Para aprofundar


Próxima aula (03, 26/08/2026): aprofundamos v-for, resolvemos o antipadrão v-for + v-if juntos, introduzimos computed() (com cache de verdade) e usamos onMounted() para carregar dados de uma fonte assíncrona, com estados de carregando/erro/vazio.

Nível 3Unidade 1 · Fundamentos de front-end com Vue.js3 aulas de 50 min + 1 h EAD

Aula 03 — Vue: listas, computed e ciclo de vida

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 02 criamos a primeira versão do UniEventos: lista, busca, filtro e inscrição, tudo com diretivas básicas e uma função de filtro chamada manualmente três vezes no template. Hoje resolvemos exatamente esse desperdício com computed(), aprofundamos v-for e passamos a carregar os eventos de forma assíncrona dentro de onMounted().

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Usar v-for sobre arrays, objetos e ranges, inclusive em <template> e listas aninhadas.
  • Explicar por que combinar v-for com v-if no mesmo elemento é um antipadrão e resolvê-lo com computed.
  • Criar propriedades computed(), entender seu cache e diferenciar computed de método e de watch.
  • Usar watch() e watchEffect() com as opções immediate e deep, e parar um watcher.
  • Carregar dados assincronamente dentro de onMounted(), implementando os três estados de tela: carregando, erro e vazio.
  • Formatar datas e números em pt-BR com Intl, e aplicar classes/estilos condicionais com :class e :style.
  • Entregar uma versão do UniEventos com filtros combinados resolvidos por computed, carregamento assíncrono e destaque visual condicional.

📋 Pré-requisitos desta aula

  • Projeto unieventos-web funcionando, com a listagem, busca e filtro da Aula 02.
  • Domínio de ref(), v-model, v-for+:key, v-if/v-show e dos hooks onMounted/onUnmounted (Aula 02).

⚠️ Atenção Se seu App.vue da Aula 02 ainda não estiver rodando com npm run dev sem erros, resolva isso antes de continuar — hoje vamos editar esse mesmo arquivo.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min v-for avançado, o antipadrão v-for+v-if, introdução a computed()
2 50 min computed com getter/setter, watch/watchEffect, onMounted com os três estados
3 50 min Formatação Intl, :class/:style, mão na massa: UniEventos com filtros combinados

1. v-for avançado

Na Aula 02 vimos v-for sobre um array simples. O Vue também itera sobre objetos, ranges numéricos, e permite estruturas mais ricas.

1.1 v-for sobre objetos

Vue SFC
<script setup>
import { ref } from 'vue'

const evento = ref({
  titulo: 'Semana da Computação',
  categoria: 'palestra',
  local: 'Auditório Central',
  vagas: 40,
})
</script>

<template>
  <!-- (valor, chave) — nessa ordem -->
  <ul>
    <li v-for="(valor, chave) in evento" :key="chave">
      <strong>{{ chave }}:</strong> {{ valor }}
    </li>
  </ul>

  <!-- também existe (valor, chave, indice) com o terceiro parâmetro opcional -->
  <ul>
    <li v-for="(valor, chave, indice) in evento" :key="chave">
      {{ indice }}. {{ chave }} = {{ valor }}
    </li>
  </ul>
</template>

1.2 v-for sobre um range numérico

Vue SFC
<template>
  <!-- v-for="n in 5" gera n = 1, 2, 3, 4, 5 (começa em 1, não em 0) -->
  <span v-for="n in 5" :key="n" class="estrela">⭐</span>

  <!-- útil para paginação simples -->
  <button v-for="pagina in 4" :key="pagina">{{ pagina }}</button>
</template>

1.3 v-for em <template> — repetir um grupo sem elemento extra

Vue SFC
<script setup>
import { ref } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra' },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso' },
])
</script>

<template>
  <dl>
    <!-- <template> com v-for não gera elemento HTML próprio no DOM final —
         só repete o que está dentro dele. Útil quando você precisa de
         MAIS de um elemento irmão por item, sem um <div> desnecessário. -->
    <template v-for="evento in eventos" :key="evento.id">
      <dt>{{ evento.titulo }}</dt>
      <dd>{{ evento.categoria }}</dd>
    </template>
  </dl>
</template>

1.4 Listas aninhadas

Vue SFC
<script setup>
import { ref } from 'vue'

const programacao = ref([
  {
    dia: '10/09/2026',
    sessoes: [
      { horario: '19h', titulo: 'Abertura' },
      { horario: '20h', titulo: 'Palestra magna' },
    ],
  },
  {
    dia: '11/09/2026',
    sessoes: [
      { horario: '14h', titulo: 'Oficina de Vue.js' },
      { horario: '16h', titulo: 'Mesa redonda' },
    ],
  },
])
</script>

<template>
  <div v-for="dia in programacao" :key="dia.dia" class="dia-programacao">
    <h3>{{ dia.dia }}</h3>
    <!-- v-for aninhado: a key interna só precisa ser única DENTRO do
         v-for externo, mas usar algo que combine as duas chaves evita
         qualquer ambiguidade em listas grandes -->
    <ul>
      <li v-for="sessao in dia.sessoes" :key="`${dia.dia}-${sessao.horario}`">
        {{ sessao.horario }} — {{ sessao.titulo }}
      </li>
    </ul>
  </div>
</template>

2. O antipadrão v-for + v-if juntos

Vue SFC
<script setup>
import { ref } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
])
</script>

<template>
  <!-- ⚠️ EVITE: v-for e v-if no MESMO elemento -->
  <li
    v-for="evento in eventos"
    v-if="evento.inscritos < evento.vagas"
    :key="evento.id"
  >
    {{ evento.titulo }}
  </li>
</template>

Por que isso é um problema:

  1. Precedência confusa. No Vue 3, quando v-if e v-for estão no mesmo elemento, v-if tem prioridade mais alta na avaliação, mas isso significa que ele tenta avaliar a condição antes da variável do v-for (evento) estar disponível no escopo — um erro fácil de disparar sem perceber.
  2. Desempenho. O Vue recria a checagem v-if a cada item, em todo re-render da lista, mesmo quando o critério do filtro não teve nenhuma relação com a mudança que disparou a atualização.
  3. Legibilidade. Misturar "o que iterar" com "o que exibir" no mesmo atributo deixa o template difícil de ler.

A solução: filtre antes, com computed, e itere sobre o resultado já filtrado.

Vue SFC
<script setup>
import { ref, computed } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
])

// computed: calcula UMA VEZ, o template só itera sobre o resultado
const eventosComVaga = computed(() =>
  eventos.value.filter((evento) => evento.inscritos < evento.vagas),
)
</script>

<template>
  <!-- correto: v-for sozinho, sobre uma lista já pronta -->
  <li v-for="evento in eventosComVaga" :key="evento.id">
    {{ evento.titulo }}
  </li>
</template>

Isso nos leva ao assunto central da aula de hoje: computed().

3. computed(): cache de verdade

3.1 O problema que computed resolve

Na Aula 02, obterEventosFiltrados() era uma função comum, chamada manualmente no template. Toda chamada refaz o cálculo do zero — não importa se os dados mudaram ou não.

Vue SFC
<script setup>
import { ref } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', vagas: 40, inscritos: 12 },
  { id: 2, titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 },
])

// MÉTODO: recalcula toda vez que é chamado, mesmo sem nada ter mudado
function totalDeVagasComMetodo() {
  console.log('calculando total de vagas (método)...')
  return eventos.value.reduce((total, evento) => total + evento.vagas, 0)
}
</script>

<template>
  <!-- se este valor aparecer 3 vezes no template, o log acima roda 3 vezes -->
  <p>{{ totalDeVagasComMetodo() }}</p>
  <p>{{ totalDeVagasComMetodo() }}</p>
</template>
Vue SFC
<script setup>
import { ref, computed } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', vagas: 40, inscritos: 12 },
  { id: 2, titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 },
])

// COMPUTED: calcula uma vez e GUARDA o resultado em cache.
// Só recalcula quando uma dependência reativa (eventos, neste caso) muda.
const totalDeVagas = computed(() => {
  console.log('calculando total de vagas (computed)...')
  return eventos.value.reduce((total, evento) => total + evento.vagas, 0)
})
</script>

<template>
  <!-- mesmo aparecendo 2 vezes, o log acima roda 1 vez só,
       porque o segundo acesso lê o valor já em cache -->
  <p>{{ totalDeVagas }}</p>
  <p>{{ totalDeVagas }}</p>
</template>

🔎 Por baixo do capô Um computed sabe exatamente quais variáveis reativas ele lê durante sua execução (aqui, eventos) — o mesmo mecanismo de rastreamento de dependências do padrão Observer que vimos na Aula 02. Enquanto nenhuma dessas dependências mudar, o Vue devolve o valor guardado em cache, sem executar a função de novo. Isso é diferente de um método, que roda de novo a cada chamada, sempre, sem cache algum.

3.2 computed com getter e setter

Por padrão, um computed é somente leitura. Mas é possível criar um que também aceita escrita, definindo get e set:

Vue SFC
<script setup>
import { ref, computed } from 'vue'

const nome = ref('Ivan')
const sobrenome = ref('Pires')

// forma somente leitura (a mais comum)
const nomeCompleto = computed(() => `${nome.value} ${sobrenome.value}`)

// forma com getter E setter
const nomeCompletoEditavel = computed({
  get() {
    return `${nome.value} ${sobrenome.value}`
  },
  set(novoValor) {
    const partes = novoValor.split(' ')
    nome.value = partes[0]
    sobrenome.value = partes.slice(1).join(' ')
  },
})

function renomear() {
  // escrever em um computed com setter dispara o "set" acima,
  // que por sua vez atualiza nome e sobrenome
  nomeCompletoEditavel.value = 'Maria Silva'
}
</script>

<template>
  <p>{{ nomeCompleto }}</p>
  <button @click="renomear">Renomear</button>
</template>

3.3 Computed encadeadas

Vue SFC
<script setup>
import { ref, computed } from 'vue'

const eventos = ref([
  { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
  { id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
  { id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60, inscritos: 18 },
])

// primeira computed: filtra por vaga disponível
const eventosComVaga = computed(() =>
  eventos.value.filter((evento) => evento.inscritos < evento.vagas),
)

// segunda computed: DEPENDE da primeira — encadeamento
const totalDeVagasLivres = computed(() =>
  eventosComVaga.value.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0),
)
</script>

<template>
  <p>Eventos com vaga: {{ eventosComVaga.length }}</p>
  <p>Total de vagas livres: {{ totalDeVagasLivres }}</p>
</template>

3.4 Computed × método × watch

computed método watch
Tem cache? Sim Não Não se aplica (não retorna valor)
Quando roda Só quando uma dependência muda Toda vez que é chamado Quando a fonte observada muda
Uso típico Derivar um valor a partir de outro estado Reagir a um evento de UI (clique) Executar um efeito colateral (chamar API, logar, sincronizar localStorage)
Retorna valor usável no template? Sim Sim (se chamado) Não diretamente

4. watch() e watchEffect()

computed deriva um valor. watch/watchEffect executam um efeito colateral (algo que não é "calcular e devolver", como fazer uma requisição, gravar em localStorage, exibir um alerta) em reação a uma mudança.

4.1 watch() — observa uma fonte específica

Vue SFC
<script setup>
import { ref, watch } from 'vue'

const busca = ref('')
const totalDeBuscas = ref(0)

// watch(fonte, callback) — só roda quando "busca" muda
watch(busca, (valorNovo, valorAntigo) => {
  console.log(`busca mudou de "${valorAntigo}" para "${valorNovo}"`)
  totalDeBuscas.value++
})

// watch com { immediate: true } — roda também na primeira vez,
// mesmo sem a fonte ainda ter mudado
const categoriaFiltro = ref('')
watch(
  categoriaFiltro,
  (valor) => {
    console.log('categoria selecionada:', valor || '(nenhuma)')
  },
  { immediate: true },
)
</script>

4.2 watch com { deep: true } — observar objetos/arrays por dentro

Vue SFC
<script setup>
import { reactive, watch } from 'vue'

const filtros = reactive({
  busca: '',
  categoria: '',
  apenasComVaga: false,
})

// por padrão, watch em um objeto reactive só dispara se a REFERÊNCIA mudar.
// como alterações em filtros.busca são mudanças INTERNAS ao objeto,
// precisamos de { deep: true } para o watch perceber.
watch(
  filtros,
  (valorNovo) => {
    console.log('algum filtro mudou:', valorNovo)
  },
  { deep: true },
)
</script>

4.3 watchEffect() — roda de novo automaticamente, sem declarar a fonte

Vue SFC
<script setup>
import { ref, watchEffect } from 'vue'

const busca = ref('')
const categoriaFiltro = ref('')

// watchEffect executa a função IMEDIATAMENTE (não precisa de immediate: true)
// e registra sozinho, ao rodar, quais variáveis reativas ela leu —
// depois reexecuta sempre que qualquer uma delas mudar.
watchEffect(() => {
  console.log(`filtro atual → busca: "${busca.value}", categoria: "${categoriaFiltro.value}"`)
})
</script>

4.4 Parando um watcher

Vue SFC
<script setup>
import { ref, watch } from 'vue'

const contador = ref(0)

// watch() e watchEffect() retornam uma função para PARAR de observar
const pararDeObservar = watch(contador, (valor) => {
  console.log('contador:', valor)
})

function pararObservacao() {
  pararDeObservar() // a partir daqui, mudanças em `contador` não disparam mais o log
}
</script>
watch watchEffect
Declara a fonte explicitamente? Sim — watch(fonte, callback) Não — descobre sozinho lendo o corpo da função
Roda na criação, por padrão? Não (a menos que immediate: true) Sim, sempre
Acesso ao valor antigo? Sim ((novo, antigo) => ...) Não
Quando usar Precisa saber o valor anterior, ou observar só uma fonte específica Quer reagir a "qualquer coisa que a função usa", de forma mais enxuta

📌 Na prova Regra prática: se você precisa de um valor derivado para usar no template, use computed. Se precisa fazer algo (chamar API, gravar em disco, mostrar um alerta) quando um dado muda, use watch ou watchEffect.

5. onMounted() e carregamento de dados

Até agora, eventos nasce pronto, direto de um array local. Na prática, dados vêm de uma API ou arquivo remoto — e isso é assíncrono. O lugar certo para disparar essa busca é o hook onMounted (Aula 02), porque é aí que temos garantia de que o componente já existe.

5.1 O padrão dos três estados de tela

Toda tela que depende de dados assíncronos deveria tratar três situações:

  1. Carregando — a requisição está em andamento.
  2. Erro — a requisição falhou (rede caiu, servidor retornou erro).
  3. Vazio — a requisição funcionou, mas não há dados para mostrar.
Vue SFC
<!-- src/components/ListaEventosAssincrona.vue -->
<script setup>
import { ref, onMounted } from 'vue'

const eventos = ref([])
const carregando = ref(true)
const erro = ref(null)

async function carregarEventos() {
  carregando.value = true
  erro.value = null

  try {
    const resposta = await fetch('/eventos.json')

    if (!resposta.ok) {
      throw new Error(`Erro HTTP: ${resposta.status}`)
    }

    const dados = await resposta.json()
    eventos.value = dados
  } catch (erroCapturado) {
    erro.value = 'Não foi possível carregar os eventos. Tente novamente mais tarde.'
    console.error(erroCapturado)
  } finally {
    carregando.value = false
  }
}

onMounted(() => {
  carregarEventos()
})
</script>

<template>
  <div class="lista-eventos">
    <!-- estado 1: carregando -->
    <p v-if="carregando">Carregando eventos...</p>

    <!-- estado 2: erro -->
    <div v-else-if="erro" class="erro">
      <p>{{ erro }}</p>
      <button @click="carregarEventos">Tentar novamente</button>
    </div>

    <!-- estado 3: vazio (sem erro, sem carregar, mas sem itens) -->
    <p v-else-if="eventos.length === 0">Nenhum evento cadastrado no momento.</p>

    <!-- estado 4 (implícito): sucesso com dados -->
    <ul v-else>
      <li v-for="evento in eventos" :key="evento.id">{{ evento.titulo }}</li>
    </ul>
  </div>
</template>

Crie o arquivo de dados simulando uma API, em public/eventos.json (a pasta public/ do Vite é servida como está, sem processamento):

JSON
[
  {
    "id": 1,
    "titulo": "Semana da Computação",
    "categoria": "palestra",
    "dataHora": "2026-09-10T19:00:00",
    "local": "Auditório Central",
    "vagas": 40,
    "inscritos": 12
  },
  {
    "id": 2,
    "titulo": "Oficina de Vue.js",
    "categoria": "minicurso",
    "dataHora": "2026-08-20T14:00:00",
    "local": "Laboratório 3",
    "vagas": 25,
    "inscritos": 25
  },
  {
    "id": 3,
    "titulo": "Hackathon FACET",
    "categoria": "workshop",
    "dataHora": "2026-10-05T08:00:00",
    "local": "Bloco B",
    "vagas": 60,
    "inscritos": 18
  },
  {
    "id": 4,
    "titulo": "Introdução a IA",
    "categoria": "palestra",
    "dataHora": "2026-08-18T19:30:00",
    "local": "Auditório Central",
    "vagas": 80,
    "inscritos": 55
  }
]

💡 Dica Para testar o estado de erro de propósito, troque a URL do fetch para algo que não existe (/eventos-inexistente.json) e veja a tela de erro com o botão "Tentar novamente" funcionando.

6. Formatação com Intl e ligação de classes/estilos

6.1 Intl.DateTimeFormat e Intl.NumberFormat

Vue SFC
<script setup>
const dataEvento = new Date('2026-09-10T19:00:00')
const valorInscricao = 45.9

const dataFormatada = new Intl.DateTimeFormat('pt-BR', {
  day: '2-digit',
  month: 'long',
  year: 'numeric',
  hour: '2-digit',
  minute: '2-digit',
}).format(dataEvento)

const valorFormatado = new Intl.NumberFormat('pt-BR', {
  style: 'currency',
  currency: 'BRL',
}).format(valorInscricao)
</script>

<template>
  <p>Data: {{ dataFormatada }}</p>
  <!-- 10 de setembro de 2026 19:00 -->

  <p>Valor: {{ valorFormatado }}</p>
  <!-- R$ 45,90 -->
</template>

💡 Dica Em vez de instanciar Intl.DateTimeFormat/Intl.NumberFormat de novo a cada uso, crie uma função utilitária reaproveitável (src/utils/formatadores.js) — é o que faremos na seção de "Mão na massa" a seguir.

6.2 :class — objeto e array

Vue SFC
<script setup>
import { ref, computed } from 'vue'

const evento = ref({ titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 })

const estaLotado = computed(() => evento.value.inscritos >= evento.value.vagas)
</script>

<template>
  <!-- :class com OBJETO: cada chave é uma classe, o valor decide se ela é aplicada -->
  <div :class="{ 'card-lotado': estaLotado, 'card-disponivel': !estaLotado }">
    {{ evento.titulo }}
  </div>

  <!-- :class com ARRAY: combina classes fixas e condicionais -->
  <div :class="['card', estaLotado ? 'card-lotado' : 'card-disponivel']">
    {{ evento.titulo }}
  </div>

  <!-- misturando classe estática (sem :) com classe dinâmica (com :) -->
  <div class="card" :class="{ 'card-lotado': estaLotado }">
    {{ evento.titulo }}
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ccc;
  padding: 1rem;
}
.card-lotado {
  border-color: #c0392b;
  background-color: #fdecea;
}
.card-disponivel {
  border-color: #27ae60;
}
</style>

6.3 :style

Vue SFC
<script setup>
import { ref, computed } from 'vue'

const vagas = ref(40)
const inscritos = ref(30)

const percentualOcupado = computed(() => Math.round((inscritos.value / vagas.value) * 100))
</script>

<template>
  <!-- :style com objeto -->
  <div
    class="barra-progresso"
    :style="{ width: percentualOcupado + '%', backgroundColor: percentualOcupado > 80 ? '#c0392b' : '#27ae60' }"
  ></div>

  <!-- :style com array de objetos: combina múltiplos conjuntos de estilo -->
  <p :style="[{ fontWeight: 'bold' }, { color: percentualOcupado > 80 ? 'red' : 'black' }]">
    {{ percentualOcupado }}% ocupado
  </p>
</template>

<style scoped>
.barra-progresso {
  height: 8px;
  border-radius: 4px;
  transition: width 0.3s ease;
}
</style>

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Proxy (estrutural)

O padrão Proxy cria um objeto substituto que controla o acesso a outro objeto — interceptando leituras, escritas ou chamadas, e adicionando comportamento extra sem que quem usa o objeto perceba a diferença.

Um Proxy simplificado, em JavaScript puro, para logar todo acesso a um objeto:

```js const evento = { titulo: 'Semana da Computação', vagas: 40 }

const eventoComLog = new Proxy(evento, { get(alvo, propriedade) { console.log([leitura] alguém acessou "${propriedade}") return alvo[propriedade] }, set(alvo, propriedade, novoValor) { console.log([escrita] "${propriedade}" mudou de "${alvo[propriedade]}" para "${novoValor}") alvo[propriedade] = novoValor return true }, })

console.log(eventoComLog.titulo) // dispara o "get" -> loga e retorna o valor eventoComLog.vagas = 39 // dispara o "set" -> loga e altera o valor real ```

É exatamente este mecanismo que reactive() usa por dentro. Quando você chama reactive(objeto), o Vue devolve um Proxy que envolve o objeto original. Toda leitura de propriedade (evento.titulo) passa pelo get do Proxy, que registra "este trecho de template/computed depende de titulo" (o rastreamento de dependências que sustenta o padrão Observer da Aula 02). Toda escrita (evento.vagas = 39) passa pelo set, que dispara a notificação para quem depende daquele valor, disparando a re-renderização. ref() usa uma técnica um pouco diferente por baixo (um objeto com getter/setter na propriedade .value, sem precisar de um Proxy completo, já que só precisa interceptar uma única propriedade), mas o princípio — interceptar acesso para adicionar comportamento reativo — é o mesmo padrão Proxy.

💻 Mão na massa — UniEventos com filtros combinados

Vamos consolidar tudo em uma versão mais completa: busca por texto + categoria + "apenas com vagas", ordenação, contadores derivados, carregamento assíncrono no onMounted e destaque visual para eventos lotados ou que acontecem nos próximos 7 dias.

Passo 1 — utilitário de formatação, reaproveitável em todo o projeto.

JavaScript
// src/utils/formatadores.js
export function formatarDataHora(dataIso) {
  return new Intl.DateTimeFormat('pt-BR', {
    day: '2-digit',
    month: 'long',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  }).format(new Date(dataIso))
}

export function formatarPercentual(valor) {
  return new Intl.NumberFormat('pt-BR', {
    style: 'percent',
    maximumFractionDigits: 0,
  }).format(valor / 100)
}

export function ocorreNosProximosDias(dataIso, dias) {
  const agora = new Date()
  const dataDoEvento = new Date(dataIso)
  const diferencaEmMs = dataDoEvento.getTime() - agora.getTime()
  const diferencaEmDias = diferencaEmMs / (1000 * 60 * 60 * 24)
  return diferencaEmDias >= 0 && diferencaEmDias <= dias
}

Passo 2 — mantenha public/eventos.json da Seção 5.1 (ou ajuste as datas para ficarem próximas da data atual, se quiser testar o destaque de "próximos 7 dias").

Passo 3 — o componente completo.

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { formatarDataHora, formatarPercentual, ocorreNosProximosDias } from './utils/formatadores.js'

const eventos = ref([])
const carregando = ref(true)
const erro = ref(null)

const busca = ref('')
const categoriaFiltro = ref('')
const apenasComVaga = ref(false)
const criterioOrdenacao = ref('data') // 'data' | 'vagas'

async function carregarEventos() {
  carregando.value = true
  erro.value = null

  try {
    const resposta = await fetch('/eventos.json')
    if (!resposta.ok) {
      throw new Error(`Erro HTTP: ${resposta.status}`)
    }
    eventos.value = await resposta.json()
  } catch (erroCapturado) {
    erro.value = 'Não foi possível carregar os eventos. Tente novamente mais tarde.'
    console.error(erroCapturado)
  } finally {
    carregando.value = false
  }
}

onMounted(() => {
  carregarEventos()
})

// computed principal: aplica os TRÊS filtros de uma vez, em cadeia
const eventosFiltrados = computed(() => {
  return eventos.value
    .filter((evento) => evento.titulo.toLowerCase().includes(busca.value.trim().toLowerCase()))
    .filter((evento) => categoriaFiltro.value === '' || evento.categoria === categoriaFiltro.value)
    .filter((evento) => !apenasComVaga.value || evento.inscritos < evento.vagas)
})

// computed encadeada: ordena o resultado já filtrado
const eventosOrdenados = computed(() => {
  const copia = [...eventosFiltrados.value]

  if (criterioOrdenacao.value === 'data') {
    return copia.sort((a, b) => new Date(a.dataHora) - new Date(b.dataHora))
  }

  // ordenar por vagas restantes, da maior para a menor
  return copia.sort((a, b) => (b.vagas - b.inscritos) - (a.vagas - a.inscritos))
})

// contadores derivados — cada um é barato de calcular porque
// eventosFiltrados já está em cache
const totalFiltrado = computed(() => eventosFiltrados.value.length)
const totalVagasLivres = computed(() =>
  eventosFiltrados.value.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0),
)

function vagasRestantes(evento) {
  return evento.vagas - evento.inscritos
}

function percentualOcupacao(evento) {
  return Math.round((evento.inscritos / evento.vagas) * 100)
}

function inscrever(eventoId) {
  const evento = eventos.value.find((item) => item.id === eventoId)
  if (!evento || evento.inscritos >= evento.vagas) return
  evento.inscritos++
}

function limparFiltros() {
  busca.value = ''
  categoriaFiltro.value = ''
  apenasComVaga.value = false
}
</script>

<template>
  <main class="pagina">
    <h1>UniEventos</h1>
    <p>Encontre e inscreva-se em eventos acadêmicos.</p>

    <!-- estado: carregando -->
    <p v-if="carregando">Carregando eventos...</p>

    <!-- estado: erro -->
    <div v-else-if="erro" class="erro">
      <p>{{ erro }}</p>
      <button @click="carregarEventos">Tentar novamente</button>
    </div>

    <!-- estado: sucesso -->
    <template v-else>
      <div class="filtros">
        <input v-model.trim="busca" type="text" placeholder="Buscar por título..." />

        <select v-model="categoriaFiltro">
          <option value="">Todas as categorias</option>
          <option value="palestra">Palestra</option>
          <option value="minicurso">Minicurso</option>
          <option value="workshop">Workshop</option>
        </select>

        <label>
          <input v-model="apenasComVaga" type="checkbox" />
          Apenas com vagas
        </label>

        <select v-model="criterioOrdenacao">
          <option value="data">Ordenar por data</option>
          <option value="vagas">Ordenar por vagas livres</option>
        </select>

        <button @click="limparFiltros">Limpar filtros</button>
      </div>

      <p class="resumo">
        {{ totalFiltrado }} evento(s) encontrado(s) — {{ totalVagasLivres }} vaga(s) livre(s) no total
      </p>

      <!-- estado: vazio (sucesso, mas sem itens após o filtro) -->
      <p v-if="eventosOrdenados.length === 0" class="vazio">
        Nenhum evento encontrado com estes filtros.
      </p>

      <ul v-else class="lista-eventos">
        <li
          v-for="evento in eventosOrdenados"
          :key="evento.id"
          class="card-evento"
          :class="{
            'card-lotado': vagasRestantes(evento) === 0,
            'card-em-breve': ocorreNosProximosDias(evento.dataHora, 7),
          }"
        >
          <h2>{{ evento.titulo }}</h2>
          <p>{{ formatarDataHora(evento.dataHora) }} — {{ evento.local }}</p>
          <p>Categoria: {{ evento.categoria }}</p>

          <p v-if="ocorreNosProximosDias(evento.dataHora, 7)" class="selo-em-breve">
            Acontece em breve!
          </p>

          <div
            class="barra-progresso-fundo"
            :style="{ '--percentual': percentualOcupacao(evento) + '%' }"
          >
            <div
              class="barra-progresso"
              :style="{
                width: percentualOcupacao(evento) + '%',
                backgroundColor: vagasRestantes(evento) === 0 ? '#c0392b' : '#27ae60',
              }"
            ></div>
          </div>
          <p class="texto-ocupacao">{{ formatarPercentual(percentualOcupacao(evento)) }} ocupado</p>

          <p v-if="vagasRestantes(evento) > 0">{{ vagasRestantes(evento) }} vaga(s) restante(s)</p>
          <p v-else class="lotado">Evento lotado</p>

          <button :disabled="vagasRestantes(evento) === 0" @click="inscrever(evento.id)">
            Inscrever-se
          </button>
        </li>
      </ul>
    </template>
  </main>
</template>

<style scoped>
.pagina {
  max-width: 780px;
  margin: 0 auto;
  padding: 2rem;
  font-family: sans-serif;
}

.filtros {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: center;
  margin-bottom: 1rem;
}

.resumo {
  color: #555;
  margin-bottom: 1.5rem;
}

.lista-eventos {
  list-style: none;
  padding: 0;
  display: grid;
  gap: 1rem;
}

.card-evento {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1rem;
}

.card-lotado {
  border-color: #c0392b;
  background-color: #fdecea;
}

.card-em-breve {
  border-left: 4px solid #f39c12;
}

.selo-em-breve {
  color: #d68910;
  font-weight: bold;
}

.barra-progresso-fundo {
  background-color: #eee;
  border-radius: 4px;
  height: 8px;
  overflow: hidden;
  margin-top: 0.5rem;
}

.barra-progresso {
  height: 100%;
  transition: width 0.3s ease;
}

.texto-ocupacao {
  font-size: 0.85rem;
  color: #666;
}

.lotado {
  color: #c0392b;
  font-weight: bold;
}

.erro,
.vazio {
  text-align: center;
  color: #666;
}
</style>

📌 Na prova Observe que eventosFiltrados e eventosOrdenados são duas computed encadeadas, e totalFiltrado/totalVagasLivres dependem de eventosFiltrados. Se você mudar busca, o Vue recalcula eventosFiltrados (porque ela lê busca), o que por sua vez invalida o cache de eventosOrdenados, totalFiltrado e totalVagasLivres — tudo automático, seguindo a cadeia de dependências. Você não escreve nenhuma chamada manual de "atualizar".

🧪 Laboratório

1. Refatorar método em computed — pegue esta função e transforme-a em computed:

JavaScript
function eventosPalestrasComVaga() {
  return eventos.value.filter((e) => e.categoria === 'palestra' && e.inscritos < e.vagas)
}

Resultado esperado: uma constante eventosPalestrasComVaga criada com computed(() => ...), usada no template sem parênteses (v-for="evento in eventosPalestrasComVaga", não eventosPalestrasComVaga()).

Dica

const eventosPalestrasComVaga = computed(() => eventos.value.filter((e) => e.categoria === 'palestra' && e.inscritos < e.vagas))

2. Corrigir uma lista sem :key — dado este trecho com um bug proposital, corrija-o:

Vue SFC
<li v-for="evento in eventosOrdenados">{{ evento.titulo }}</li>

Resultado esperado: :key="evento.id" adicionado, e o console do navegador sem o aviso Elements in iteration expect to have 'v-bind:key'.

Dica

Abra o DevTools (Console) — o Vue avisa explicitamente quando falta :key em um v-for.

3. watch para persistir o filtro — use watch sobre categoriaFiltro para gravar a categoria escolhida em localStorage.setItem('ultimaCategoria', valor), e leia esse valor com localStorage.getItem para definir o valor inicial de categoriaFiltro.

Resultado esperado: recarregar a página mantém a última categoria filtrada.

Dica
JavaScript
const categoriaFiltro = ref(localStorage.getItem('ultimaCategoria') || '')
watch(categoriaFiltro, (valor) => localStorage.setItem('ultimaCategoria', valor))

4. Computed com getter e setter — crie uma computed buscaEmMaiusculas que exiba busca sempre em maiúsculas ao ler, mas ao escrever converta para minúsculas antes de gravar em busca.

Resultado esperado: digitar "VUE" em um campo ligado a buscaEmMaiusculas faz busca.value valer "vue".

Dica
JavaScript
const buscaEmMaiusculas = computed({
  get: () => busca.value.toUpperCase(),
  set: (valor) => { busca.value = valor.toLowerCase() },
})

5. Estado de erro proposital — troque a URL do fetch em carregarEventos para /eventos-inexistente.json, confirme que a tela de erro aparece com o botão "Tentar novamente", depois desfaça a mudança.

Resultado esperado: você reproduz e depois corrige o estado de erro descrito na Seção 5.

Dica

O catch do try/catch precisa capturar tanto falha de rede quanto resposta.ok === false.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
computed não atualiza quando eu esperava A função do computed não lê nenhuma variável reativa (ex.: lê uma variável comum, não um ref/reactive) Garanta que tudo que o computed depende é reativo
v-for com v-if no mesmo elemento gera erro ou resultado estranho Antipadrão descrito na Seção 2 Mova o filtro para um computed e itere sobre o resultado
watch não dispara ao mudar uma propriedade interna de um objeto Faltou { deep: true } ao observar um reactive/objeto Adicione a opção deep: true
Tela fica presa em "Carregando eventos..." para sempre Esqueceu de setar carregando.value = false no finally, ou uma exceção interrompeu antes de chegar lá Sempre use try/catch/finally, com carregando.value = false no finally
Data aparece como Invalid Date dataIso no formato errado, ou new Date() recebendo undefined Confira o formato ISO (YYYY-MM-DDTHH:mm:ss) vindo do JSON

🏠 Atividade assíncrona (1 h)

No repositório do seu projeto autoral:

  1. Substitua a listagem estática do seu domínio por dados carregados via fetch dentro de onMounted, a partir de um arquivo em public/<entidade>.json (siga o modelo da Seção 5.1).
  2. Implemente os três estados de tela: carregando, erro (com botão de tentar novamente) e vazio.
  3. Transforme pelo menos uma função de filtro em computed, e combine dois ou mais critérios de filtro na mesma computed (texto + categoria/tipo, como fizemos hoje).
  4. Adicione formatação de datas e/ou valores com Intl para os campos do seu domínio.
  5. Adicione um destaque visual condicional (:class) para algum estado relevante do seu domínio (ex.: "esgotado", "últimas unidades", "encerra em breve").
  6. Faça commit e push.

Critério de pronto: os três estados de tela funcionam (teste forçando um erro), os filtros combinados funcionam via computed, e existe pelo menos um destaque visual condicional.

✅ Checkpoint do projeto autoral

  • [ ] Dados carregados via fetch dentro de onMounted, a partir de um JSON em public/.
  • [ ] Estados de carregando, erro e vazio implementados e testados.
  • [ ] Pelo menos um computed combinando dois ou mais critérios de filtro.
  • [ ] Formatação de data e/ou valor com Intl aplicada em pelo menos um campo.
  • [ ] Destaque visual condicional com :class em pelo menos um cenário do domínio.
  • [ ] Todo v-for do projeto usa :key com um identificador estável (nunca o índice).
  • [ ] Commit enviado ao GitHub.

Na próxima aula você vai reestruturar seu projeto com Vuetify e Vue Router — os filtros e listas que você já tem hoje continuam valendo, só ganham um visual pronto e navegação entre telas.

📝 Aviso — Avaliação 1 se aproxima

A Avaliação 1 (implementação introdutória de Vue 3 com CLI: estrutura, componentes, diretivas) tem entrega até 02/09/2026, 23h59, e as instruções completas — escopo, rubrica e formato de entrega — serão publicadas na Aula 04 (02/09/2026), junto com Vuetify e Vue Router. Use esta semana assíncrona também para revisar: estrutura do projeto Vite, diretivas da Aula 02 e computed/onMounted de hoje são a base de tudo que será cobrado.

📚 Para aprofundar


Próxima aula (04, 02/09/2026): introdução a Vuetify e Vue Router, transformando o UniEventos em uma SPA navegável com componentes visuais prontos — e publicação das instruções completas da Avaliação 1.

Nível 3Unidade 1 · Fundamentos de front-end com Vue.js3 aulas de 50 min + 1 h EADFecha a unidade · Avaliação 1

Aula 04 — Introdução a Vuetify e Vue Router

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Nas Aulas 02 e 03 você construiu o UniEventos em Vue puro: listagem com v-for, filtros com computed(), carregamento assíncrono em onMounted() e HTML/CSS escritos à mão. Hoje ele ganha interface profissional com Vuetify e navegação real com Vue Router, virando uma SPA de verdade — e você recebe as instruções completas da Avaliação 1.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Explicar o que é um design system e por que um framework de UI acelera (e padroniza) o desenvolvimento de interfaces.
  • Instalar e configurar o Vuetify 4 em um projeto Vite/Vue 3 seguindo exatamente os passos testados nesta disciplina.
  • Estruturar uma aplicação com v-app, v-app-bar, v-navigation-drawer, v-main e o sistema de grid v-container/v-row/v-col.
  • Usar os componentes essenciais do Vuetify (v-card, v-btn, v-chip, v-icon, v-list, v-alert, v-dialog, v-snackbar, v-text-field, v-select, v-img, v-progress-circular) para montar telas reais.
  • Configurar um tema institucional customizado e implementar um alternador de tema claro/escuro.
  • Criar rotas com Vue Router 5 (createRouter, createWebHistory), navegar com <RouterLink>/<RouterView>, ler parâmetros de rota e tratar rota 404.
  • Transformar o UniEventos de página única em uma SPA navegável com múltiplas views.

📋 Pré-requisitos desta aula

Antes de começar, confirme que você tem:

  • [ ] O projeto UniEventos das aulas 02–03 rodando localmente com npm run dev (lista de eventos com filtro, v-for, v-if, computed, onMounted, carregamento assíncrono com fetch).
  • [ ] Node.js 22.22.2 LTS instalado (node -v). O create-vue exige ^22.18.0 || >=24.12.0.
  • [ ] Git configurado e uma conta no GitHub — hoje você vai precisar de um repositório público para a Avaliação 1.
  • [ ] Revisão rápida: <script setup>, ref, computed, v-bind, v-on, v-model, v-if/v-for com :key, onMounted.

📌 Na prova: hoje marca o fim da Unidade 1. Tudo que vier depois — Vuetify avançado, Axios, Pinia — pressupõe que você sabe montar uma SPA com rotas. Não pule esta aula.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Design system, Material Design 3, instalação do Vuetify 4, estrutura de aplicação e grid
2 50 min Componentes essenciais do Vuetify, tema customizado, Vue Router 5 (rotas, parâmetros, navegação)
3 50 min Mão na massa: migração do UniEventos para SPA com layout, views e tema institucional + instruções da Avaliação 1

1. Por que usar um framework de UI

Nas aulas 02 e 03 você escreveu HTML e CSS à mão para estilizar os cards de evento. Funciona, mas em um projeto real isso significa reinventar, para cada tela nova, decisões que já foram tomadas mil vezes por outras equipes: como um botão deve reagir ao toque, quanto de espaçamento um card precisa, que cor de texto garante contraste suficiente sobre um fundo azul.

Um framework de UI (ou biblioteca de componentes) resolve isso entregando componentes prontos — botões, cartões, campos de formulário, tabelas, diálogos — que já implementam essas decisões de forma consistente. Isso é diferente de um framework como o Vue, que resolve como a interface reage a dados; um framework de UI resolve como a interface se parece e se comporta visualmente.

A vantagem central é o design system: um conjunto de regras (cores, tipografia, espaçamento, elevação, animação) aplicado uniformemente em toda a aplicação. Sem um design system, cada componente vira uma ilha visual, e a interface fica com "cara de colcha de retalhos". Com um, o card de evento, o formulário de inscrição e o painel administrativo compartilham a mesma linguagem visual — mesmo que tenham sido escritos em dias diferentes por pessoas diferentes.

Material Design 3 em uma página

O Vuetify 4 implementa o Material Design 3 (MD3), o design system do Google usado no Android e em produtos como Gmail e YouTube. Os pilares que importam para o dia a dia:

  • Color roles — em vez de "azul" e "cinza", você pensa em papéis: primary (ação principal), secondary (ação de apoio), error, success, warning, info, surface (fundo de cartões) e background. Trocar o tema não exige trocar cada componente — só redefinir os papéis.
  • Elevação — sombras indicam hierarquia (o que está "mais perto" do usuário). O MD3 no Vuetify 4 trabalha com uma escala reduzida de 6 níveis (0 a 5), mais sutil que a escala antiga de 0 a 24 do Material Design 2.
  • Tipografia em escala — títulos, corpo e rótulos seguem uma escala tipográfica nomeada (display, headline, title, body, label), cada uma em tamanhos large/medium/small.
  • Forma e espaçamento — cantos arredondados e um sistema de espaçamento em múltiplos de 4px, aplicado por classes utilitárias.

⚠️ Atenção: o Vuetify 4 migrou a tipografia de MD2 para MD3. As classes antigas text-h1text-h6 continuam existindo, mas mudaram de tamanho e semântica. Os equivalentes MD3 são nomes como text-display-large, text-headline-medium, text-title-large, text-body-medium, text-label-large. Nesta disciplina, prefira citar explicitamente a classe MD3 ou definir sua própria tipografia — não assuma que text-h4 de um tutorial antigo vai parecer do jeito que você viu em vídeo.

2. Instalando o Vuetify 4

Vamos instalar o Vuetify no projeto UniEventos que você já tem. Os comandos abaixo são os mesmos testados no ambiente da disciplina — siga exatamente esta ordem.

Terminal
npm install vuetify @mdi/font
npm install -D vite-plugin-vuetify

O primeiro comando instala o Vuetify em si e a fonte de ícones Material Design Icons (MDI), que usaremos em botões, listas e menus. O segundo instala o plugin do Vite que faz o autoimport dos componentes — sem ele, você teria que importar manualmente cada v-card, v-btn etc. em cada arquivo .vue, o que é inviável em um projeto com dezenas de telas.

Configure o vite.config.js:

JavaScript
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'

export default defineConfig({
  plugins: [
    vue({ template: { transformAssetUrls } }),
    vuetify({ autoImport: true }),
  ],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
})

Repare em dois detalhes:

  • transformAssetUrls é passado ao plugin do Vue para que caminhos de imagem usados dentro de props do Vuetify (como src de v-img) sejam resolvidos corretamente pelo Vite.
  • vuetify({ autoImport: true }) é o que permite usar <v-card>, <v-btn> e qualquer outro componente do Vuetify sem importar nada no <script setup>. O plugin varre seus templates em tempo de build, detecta quais componentes e diretivas você usou, e injeta o registro automaticamente. Isso substitui o padrão antigo de fazer import * as components from 'vuetify/components' e registrar tudo manualmente (ou, pior, registrar tudo globalmente e inflar o bundle).

Agora o src/main.js:

JavaScript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'

const vuetify = createVuetify({
  theme: { defaultTheme: 'light' },   // v4: o padrão virou 'system'
})

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')

Três importações merecem atenção:

  1. '@mdi/font/css/materialdesignicons.css' — carrega a fonte de ícones. Sem isso, <v-icon>mdi-account</v-icon> aparece como um quadrado vazio.
  2. 'vuetify/styles' — o CSS base do Vuetify (grid, tipografia, reset parcial).
  3. createVuetify(...) — cria a instância do Vuetify, análoga ao createPinia() ou createRouter(): você a registra na aplicação com app.use(vuetify).

⚠️ Atenção: no Vuetify 4, o tema padrão passou a ser 'system' — ou seja, se você não configurar nada, a aplicação vai seguir a preferência de tema (claro/escuro) do sistema operacional do usuário. Isso é ótimo em produção, mas péssimo para dar aula: metade da turma veria uma tela clara e a outra metade, escura, sem que ninguém tivesse mudado nada. Por isso declaramos defaultTheme: 'light' explicitamente — mais adiante, na seção de tema, vamos configurar isso de verdade com cores institucionais.

🔎 Por baixo do capô: app.use(vuetify) funciona exatamente como app.use(router) ou app.use(pinia) — é o mecanismo de plugin do Vue. Um plugin é um objeto com um método install(app, options) que o Vue chama internamente. Isso é o mesmo padrão que você vai usar para instalar qualquer biblioteca de terceiros no ecossistema Vue.

Depois de configurar os dois arquivos, rode:

Terminal
npm run dev

Se a tela carregar sem erros no console, o Vuetify está funcionando. Um teste rápido: coloque <v-btn color="primary">Teste</v-btn> em qualquer template e veja se aparece um botão estilizado (não um <button> cru do navegador).

3. Estrutura de aplicação Vuetify

Toda aplicação Vuetify é envolvida por um componente raiz obrigatório: <v-app>. Ele injeta o contexto de tema, o sistema de layout responsivo e o container onde diálogos e menus são renderizados (via teleport). Sem v-app, nada no Vuetify funciona direito — nem cores de tema, nem posicionamento de v-dialog.

Dentro de v-app, os blocos estruturais mais comuns são:

Componente Papel
v-app-bar barra superior fixa — logotipo, título, ações, botão de menu
v-navigation-drawer menu lateral (fixo ou retrátil)
v-main área de conteúdo principal — se ajusta automaticamente ao espaço ocupado por app-bar e drawer
v-footer rodapé
Vue SFC
<!-- src/App.vue (esqueleto conceitual — vamos completar na seção Mão na massa) -->
<template>
  <v-app>
    <v-app-bar color="primary">
      <v-app-bar-title>UniEventos</v-app-bar-title>
    </v-app-bar>

    <v-navigation-drawer>
      <!-- itens de menu aqui -->
    </v-navigation-drawer>

    <v-main>
      <v-container>
        <!-- conteúdo da página aqui -->
      </v-container>
    </v-main>

    <v-footer app color="primary">
      <span>UNEMAT · FACET · 2026</span>
    </v-footer>
  </v-app>
</template>

Note que v-main já "sabe" que existe um v-app-bar acima dele e um v-navigation-drawer ao lado — o Vuetify calcula o espaçamento automaticamente. Você não precisa (e não deve) definir margin-top manualmente para compensar a barra fixa.

Grid: v-container / v-row / v-col

O Vuetify usa um grid de 12 colunas, parecido com o Bootstrap, mas com props reativas a breakpoints:

Vue SFC
<v-container>
  <v-row>
    <v-col cols="12" sm="6" md="4">
      <!-- ocupa 12/12 no celular, 6/12 em tablet, 4/12 em desktop -->
    </v-col>
  </v-row>
</v-container>

Os breakpoints do Vuetify 4 mudaram de valor em relação a versões anteriores — use os números abaixo, não os de tutoriais antigos:

Breakpoint Largura mínima
sm 600px
md 840px
lg 1145px
xl 1545px
xxl 2138px

Um grid de cards de evento responsivo típico:

Vue SFC
<v-row>
  <v-col v-for="evento in eventos" :key="evento.id" cols="12" sm="6" md="4">
    <!-- v-card do evento -->
  </v-col>
</v-row>

Em telas pequenas (cols="12"), um card por linha. A partir de 600px, dois por linha (sm="6"). A partir de 840px, três por linha (md="4"). Essa é a técnica que você vai usar no Mão na massa desta aula.

⚠️ Atenção — duas armadilhas comuns do grid no Vuetify 4: 1. <v-container fill-height> não centraliza mais verticalmente como fazia antes. Se você precisa centralizar conteúdo na tela (por exemplo, uma tela de erro 404), use classes utilitárias: <v-container class="d-flex align-center justify-center" style="min-height: 100vh">. 2. As props align, justify e dense do <v-row> foram removidas. No lugar delas, use classes utilitárias de flexbox (class="justify-space-between", class="align-center") ou a prop density="compact" para reduzir o espaçamento entre colunas. Código copiado de tutoriais do Vuetify 3 que usa <v-row align="center"> vai quebrar silenciosamente — a prop simplesmente é ignorada.

4. Componentes essenciais

Vamos conhecer os componentes que você vai usar em praticamente toda tela do UniEventos.

v-card

O cartão é a unidade básica de conteúdo agrupado — um evento, um resultado de busca, um formulário curto. Ele é composto por subcomponentes:

Vue SFC
<v-card>
  <v-img src="/img/evento.jpg" height="180" cover />
  <v-card-title>Semana Acadêmica de Computação</v-card-title>
  <v-card-subtitle>29/09/2026 · Auditório Central</v-card-subtitle>
  <v-card-text>
    Palestras, minicursos e apresentação de projetos dos estudantes.
  </v-card-text>
  <v-card-actions>
    <v-btn color="primary" variant="text">Ver detalhes</v-btn>
    <v-spacer />
    <v-chip color="success" size="small">32 vagas</v-chip>
  </v-card-actions>
</v-card>

v-card-title, v-card-subtitle, v-card-text e v-card-actions existem para dar estrutura semântica e espaçamento correto — evite substituí-los por <div> com classes manuais.

v-btn e suas variantes

O v-btn tem seis variantes visuais (prop variant), cada uma com um uso recomendado:

Variant Quando usar
elevated ação de destaque, com sombra (padrão visual antigo do Material)
flat ação primária sem sombra — a mais comum em toolbars
tonal ação secundária, fundo suave na cor do tema
outlined ação secundária, apenas borda
text ação terciária, sem fundo — links de ação dentro de cards
plain mínimo destaque visual, quase texto puro
Vue SFC
<v-btn color="primary" variant="elevated">Inscrever-se</v-btn>
<v-btn color="primary" variant="tonal">Ver mais</v-btn>
<v-btn color="error" variant="outlined">Cancelar inscrição</v-btn>
<v-btn variant="text">Voltar</v-btn>

⚠️ Atenção: no Vuetify 4, v-btn não transforma mais o texto em UPPERCASE automaticamente (era o comportamento padrão em versões antigas do Material Design). Se você escrever Inscrever-se, o texto aparece exatamente assim — não INSCREVER-SE. Isso é intencional: o MD3 abandonou a caixa alta como padrão de botão.

v-chip, v-icon, v-list

v-chip é uma etiqueta compacta — categoria do evento, status, tag:

Vue SFC
<v-chip color="primary" size="small" prepend-icon="mdi-tag">Minicurso</v-chip>

v-icon renderiza um ícone MDI (Material Design Icons) — o nome sempre começa com o prefixo mdi-:

Vue SFC
<v-icon icon="mdi-calendar" color="primary" />
<v-icon>mdi-map-marker</v-icon>

v-list organiza itens verticais — menu de navegação, lista de eventos inscritos:

Vue SFC
<v-list>
  <v-list-item
    v-for="evento in eventos"
    :key="evento.id"
    :title="evento.titulo"
    :subtitle="evento.local"
    prepend-icon="mdi-calendar-star"
  />
</v-list>

v-alert, v-dialog, v-snackbar

Três componentes de feedback com propósitos distintos:

  • v-alert — mensagem persistente embutida no fluxo da página (ex.: "nenhum evento encontrado com esse filtro").
  • v-dialog — janela modal que bloqueia a interação até ser fechada (ex.: confirmar exclusão de um evento).
  • v-snackbar — notificação temporária no rodapé da tela, some sozinha (ex.: "inscrição realizada com sucesso").
Vue SFC
<v-alert type="info" variant="tonal" title="Nenhum evento encontrado">
  Tente ajustar os filtros de categoria ou data.
</v-alert>
Vue SFC
<script setup>
import { ref } from 'vue'
const mostrarDialogo = ref(false)
</script>

<template>
  <v-btn color="error" @click="mostrarDialogo = true">Excluir</v-btn>

  <v-dialog v-model="mostrarDialogo" max-width="400">
    <v-card title="Confirmar exclusão">
      <v-card-text>Esta ação não pode ser desfeita.</v-card-text>
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="mostrarDialogo = false">Cancelar</v-btn>
        <v-btn color="error" variant="flat" @click="mostrarDialogo = false">Excluir</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

Repare que v-dialog usa v-model para controlar se está aberto ou fechado — o mesmo padrão de two-way binding que você já usa em v-text-field. Vamos usar v-snackbar de verdade na Aula 06, quando tivermos ações assíncronas (salvar, excluir) que precisam de feedback.

v-text-field, v-select, v-img, v-progress-circular

Vue SFC
<v-text-field
  v-model="busca"
  label="Buscar evento"
  prepend-inner-icon="mdi-magnify"
  variant="outlined"
  clearable
/>

<v-select
  v-model="categoriaFiltro"
  :items="['Todas', 'Palestra', 'Minicurso', 'Workshop']"
  label="Categoria"
  variant="outlined"
/>

<v-img src="/img/banner.jpg" height="200" cover />

<v-progress-circular indeterminate color="primary" />

v-progress-circular com indeterminate é o spinner de carregamento — você já usou um estado de "carregando" na Aula 03 com uma condição simples; agora vamos trocar o texto "Carregando..." por esse componente visual.

5. Tema: cores institucionais e alternador claro/escuro

Um tema no Vuetify é declarado em createVuetify, com um conjunto de cores nomeadas por papel:

JavaScript
// src/main.js (trecho — configuração de tema)
const vuetify = createVuetify({
  theme: {
    defaultTheme: 'light',
    themes: {
      light: {
        dark: false,
        colors: {
          primary: '#1B5E20',    // verde institucional
          secondary: '#F9A825',  // amarelo de destaque
          error: '#B00020',
          success: '#2E7D32',
          warning: '#F57F17',
          info: '#0277BD',
          background: '#F5F5F5',
          surface: '#FFFFFF',
        },
      },
      dark: {
        dark: true,
        colors: {
          primary: '#66BB6A',
          secondary: '#FFCA28',
          error: '#CF6679',
          success: '#66BB6A',
          warning: '#FFB300',
          info: '#4FC3F7',
          background: '#121212',
          surface: '#1E1E1E',
        },
      },
    },
  },
})

Depois de declarado, qualquer componente usa color="primary" e recebe automaticamente a cor certa, seja no tema claro ou escuro — você nunca escreve um valor hexadecimal direto no template.

Para alternar entre os temas em tempo de execução, o Vuetify expõe o composable useTheme():

Vue SFC
<!-- src/components/AlternadorTema.vue -->
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'

const tema = useTheme()

const ehEscuro = computed(() => tema.global.name.value === 'dark')

function alternarTema() {
  tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
</script>

<template>
  <v-btn
    :icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
    variant="text"
    @click="alternarTema"
  />
</template>

useTheme() é um composable — mesma ideia dos composables useRoute()/useRouter() que veremos já já, e dos que você vai escrever na Aula 05. Ele te dá acesso reativo ao estado global de tema: ler tema.global.name.value e escrever nele muda o tema da aplicação inteira instantaneamente.

💡 Dica: guarde a preferência de tema do usuário em localStorage para que ela persista entre visitas. Vamos formalizar esse padrão de persistência com Pinia na Aula 06 — por hoje, é suficiente saber alternar o tema em memória.

6. Classes utilitárias de espaçamento e layout

O Vuetify gera classes utilitárias para espaçamento e flexbox, seguindo a convenção {propriedade}{direção}-{tamanho}:

  • pa-4 — padding em todos os lados, tamanho 4 (múltiplo de 4px → 16px)
  • ma-2 — margin em todos os lados, tamanho 2 (8px)
  • mt-4, mb-2, mx-auto, py-6 — direções específicas (t=top, b=bottom, x=horizontal, y=vertical)
  • d-flexdisplay: flex
  • align-centeralign-items: center (funciona em contêiner flex)
  • justify-space-betweenjustify-content: space-between
Vue SFC
<div class="d-flex align-center justify-space-between pa-4">
  <span class="text-h6">Eventos disponíveis</span>
  <v-btn color="primary" variant="tonal">Novo evento</v-btn>
</div>

Essas classes evitam CSS customizado para casos simples de espaçamento e alinhamento — e, por serem previsíveis, tornam o código mais fácil de ler entre desenvolvedores diferentes.

7. Vue Router 5: transformando páginas em rotas

Até agora o UniEventos era uma única página com tudo dentro de App.vue. Uma aplicação real precisa de navegação: uma URL para a lista de eventos, outra para o detalhe de um evento específico, outra para "sobre". Isso é o papel do Vue Router.

O UniEventos, se você criou o projeto com --router (como recomenda a §4 da especificação), já vem com Vue Router 5.2.0 instalado e configurado. Vamos entender e expandir essa configuração.

Estrutura básica

JavaScript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
  ],
})

export default router
  • createRouter monta a instância do roteador — assim como createVuetify e createPinia, ela é registrada com app.use(router) no main.js (isso já vem pronto no scaffold).
  • createWebHistory usa a API de histórico do navegador (pushState) para gerar URLs "limpas" (/eventos/12) em vez de usar # (hash). Isso exige que o servidor de produção redirecione todas as rotas para index.html — trataremos disso na Unidade 3, ao falar de deploy.
  • routes é um array de objetos { path, name, component }. name permite navegar por nome em vez de string de URL, o que evita erros de digitação espalhados pelo código.

Dois componentes globais, já registrados automaticamente pelo app.use(router):

Vue SFC
<template>
  <nav>
    <RouterLink to="/">Home</RouterLink>
    <RouterLink :to="{ name: 'sobre' }">Sobre</RouterLink>
  </nav>

  <RouterView />
</template>
  • <RouterLink> renderiza um <a> de verdade (importante para acessibilidade e SEO), mas intercepta o clique para trocar de rota sem recarregar a página inteira.
  • <RouterView> é o "buraco" onde o componente da rota ativa é renderizado. Em App.vue, ele normalmente fica dentro de v-main.

Rotas com parâmetros

A tela de detalhe de um evento precisa saber qual evento mostrar. Isso é feito com um segmento dinâmico na URL:

JavaScript
// src/router/index.js (trecho)
{
  path: '/eventos/:id',
  name: 'evento-detalhe',
  component: () => import('../views/EventoDetalheView.vue'),
}

Dentro do componente, o parâmetro é lido com o composable useRoute():

Vue SFC
<!-- src/views/EventoDetalheView.vue (trecho) -->
<script setup>
import { useRoute } from 'vue-router'

const rota = useRoute()
console.log(rota.params.id) // string com o valor de :id na URL atual
</script>

⚠️ Atenção: rota.params.id sempre vem como string, mesmo que o ID no seu array de dados seja um número. Se você comparar com ===, compare string com string ou converta com Number(rota.params.id).

Rota 404 (catch-all)

Toda SPA precisa de uma rota que capture qualquer caminho não mapeado:

JavaScript
// src/router/index.js (trecho — sempre por último no array de routes)
{
  path: '/:pathMatch(.*)*',
  name: 'nao-encontrado',
  component: () => import('../views/NaoEncontradoView.vue'),
}

O padrão /:pathMatch(.*)* é a sintaxe do Vue Router para "qualquer caminho, com qualquer profundidade de segmentos". Ele precisa ficar por último na lista de rotas — o roteador testa as rotas na ordem declarada, e uma rota catch-all no início bloquearia todas as outras.

Navegação programática

Além de <RouterLink>, você pode navegar via código — por exemplo, depois de confirmar uma inscrição:

JavaScript
import { useRouter } from 'vue-router'

const router = useRouter()

function confirmarInscricao() {
  // ... lógica de inscrição
  router.push({ name: 'home' })
}

useRouter() (com R maiúsculo de Router) dá acesso ao roteador inteiro — inclusive ao método push, que navega para uma nova rota, empilhando-a no histórico do navegador (o botão "voltar" funciona). Note a diferença: useRoute() (singular, sem "r" no fim de Route) dá acesso somente à rota atual; useRouter() dá acesso ao roteador, que permite navegar.

Lazy loading de rotas

Repare que, no exemplo de /eventos/:id acima, o componente foi importado como () => import('../views/EventoDetalheView.vue') em vez de um import estático no topo do arquivo. Essa é a técnica de lazy loading (carregamento tardio): o Vite gera um arquivo JavaScript separado para essa view, que só é baixado pelo navegador quando o usuário navega até ela.

Em uma aplicação pequena isso não faz diferença perceptível, mas é o padrão recomendado desde já — conforme o UniEventos cresce (área administrativa, formulários, tabelas), o bundle inicial permanece pequeno porque cada view só é carregada quando necessária.

JavaScript
// src/router/index.js — versão completa recomendada, com lazy loading em tudo
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
    { path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
    { path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue') },
    { path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
  ],
})

export default router

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Composite (estrutural)

A árvore de componentes do Vue é um exemplo direto do padrão Composite: um componente pode conter outros componentes, que por sua vez podem conter outros, formando uma hierarquia onde o "todo" e a "parte" são tratados de forma uniforme. v-app contém v-app-bar, v-main e v-navigation-drawer; v-main contém RouterView; RouterView renderiza uma view, que contém v-container > v-row > v-col > v-card. Em cada nível, você trabalha com a mesma interface (props, slots, eventos) sem precisar saber o que está por dentro.

O Vue Router aplica a mesma lógica na dimensão de navegação: rotas podem ter rotas-filhas (children), formando uma árvore de rotas que espelha uma árvore de RouterViews aninhados. Vamos explorar isso a fundo na Aula 05, quando construirmos a área administrativa com rotas aninhadas.

💻 Mão na massa — migrando o UniEventos para uma SPA de verdade

Vamos transformar o projeto de página única em uma aplicação navegável com layout persistente, tema institucional e quatro views.

Passo 1 — instalar o Vuetify no projeto

Se você ainda não instalou (parte da §2 desta aula), rode dentro da pasta do projeto:

Terminal
npm install vuetify @mdi/font
npm install -D vite-plugin-vuetify

Passo 2 — configurar vite.config.js

JavaScript
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'

export default defineConfig({
  plugins: [
    vue({ template: { transformAssetUrls } }),
    vuetify({ autoImport: true }),
  ],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
})

Passo 3 — criar o plugin do Vuetify com tema institucional

JavaScript
// src/plugins/vuetify.js
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'

export default createVuetify({
  theme: {
    defaultTheme: 'light',
    themes: {
      light: {
        dark: false,
        colors: {
          primary: '#1B5E20',
          secondary: '#F9A825',
          error: '#B00020',
          success: '#2E7D32',
          warning: '#F57F17',
          info: '#0277BD',
          background: '#F5F5F5',
          surface: '#FFFFFF',
        },
      },
      dark: {
        dark: true,
        colors: {
          primary: '#66BB6A',
          secondary: '#FFCA28',
          error: '#CF6679',
          success: '#66BB6A',
          warning: '#FFB300',
          info: '#4FC3F7',
          background: '#121212',
          surface: '#1E1E1E',
        },
      },
    },
  },
})

Separar a configuração do Vuetify em src/plugins/vuetify.js (em vez de deixar tudo dentro de main.js) mantém o ponto de entrada da aplicação enxuto — uma prática que vamos repetir com o Axios na Aula 06.

Passo 4 — atualizar src/main.js

JavaScript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.use(vuetify)

app.mount('#app')

Passo 5 — criar o modelo de dados de eventos

JavaScript
// src/data/eventos.js
export const eventos = [
  { id: 1, titulo: 'Semana Acadêmica de Computação', descricao: 'Palestras e minicursos sobre tendências em tecnologia.', categoria: 'palestra', dataHora: '2026-09-29T19:00:00', local: 'Auditório Central', vagas: 40, imagemUrl: 'https://picsum.photos/seed/evento1/600/300' },
  { id: 2, titulo: 'Minicurso de Vue.js Avançado', descricao: 'Componentização, roteamento e gerenciamento de estado.', categoria: 'minicurso', dataHora: '2026-09-15T18:30:00', local: 'Laboratório 3', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento2/600/300' },
  { id: 3, titulo: 'Workshop de Prototipação em Figma', descricao: 'Fundamentos de design de interfaces para desenvolvedores.', categoria: 'workshop', dataHora: '2026-09-20T14:00:00', local: 'Sala 12', vagas: 30, imagemUrl: 'https://picsum.photos/seed/evento3/600/300' },
  { id: 4, titulo: 'Palestra: Carreira em Dados', descricao: 'Trilhas profissionais em ciência e engenharia de dados.', categoria: 'palestra', dataHora: '2026-10-02T19:30:00', local: 'Auditório Central', vagas: 50, imagemUrl: 'https://picsum.photos/seed/evento4/600/300' },
  { id: 5, titulo: 'Minicurso de Banco de Dados NoSQL', descricao: 'Modelagem de dados com MongoDB na prática.', categoria: 'minicurso', dataHora: '2026-09-22T18:30:00', local: 'Laboratório 2', vagas: 20, imagemUrl: 'https://picsum.photos/seed/evento5/600/300' },
  { id: 6, titulo: 'Workshop de Testes Automatizados', descricao: 'Testes unitários e de integração em aplicações web.', categoria: 'workshop', dataHora: '2026-10-05T14:00:00', local: 'Sala 12', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento6/600/300' },
  { id: 7, titulo: 'Palestra: Ética em Inteligência Artificial', descricao: 'Discussão sobre vieses e responsabilidade em sistemas de IA.', categoria: 'palestra', dataHora: '2026-10-10T19:00:00', local: 'Auditório Central', vagas: 60, imagemUrl: 'https://picsum.photos/seed/evento7/600/300' },
  { id: 8, titulo: 'Minicurso de Node.js e Express', descricao: 'Construindo APIs REST do zero.', categoria: 'minicurso', dataHora: '2026-09-25T18:30:00', local: 'Laboratório 1', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento8/600/300' },
]

Estes são os mesmos oito eventos-base que continuam evoluindo a partir de agora até a Unidade 3, quando virão de uma API de verdade.

Passo 6 — atualizar src/router/index.js

JavaScript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
    { path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
    { path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue') },
    { path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
  ],
})

export default router

Passo 7 — criar o layout em App.vue

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
import { useTheme } from 'vuetify'
import { RouterLink, RouterView } from 'vue-router'

const drawerAberto = ref(false)
const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')

function alternarTema() {
  tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}

const linksMenu = [
  { titulo: 'Início', rota: 'home', icone: 'mdi-home' },
  { titulo: 'Sobre', rota: 'sobre', icone: 'mdi-information' },
]
</script>

<template>
  <v-app>
    <v-app-bar color="primary">
      <v-app-bar-nav-icon @click="drawerAberto = !drawerAberto" />
      <v-app-bar-title>
        <RouterLink to="/" class="text-white text-decoration-none">UniEventos</RouterLink>
      </v-app-bar-title>
      <v-spacer />
      <v-btn
        :icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
        variant="text"
        @click="alternarTema"
      />
    </v-app-bar>

    <v-navigation-drawer v-model="drawerAberto" temporary>
      <v-list>
        <v-list-item
          v-for="link in linksMenu"
          :key="link.rota"
          :to="{ name: link.rota }"
          :title="link.titulo"
          :prepend-icon="link.icone"
        />
      </v-list>
    </v-navigation-drawer>

    <v-main>
      <RouterView />
    </v-main>

    <v-footer color="primary" class="d-flex justify-center pa-4">
      <span class="text-white">UNEMAT · FACET · FACET-SNP-310 · 2026.2</span>
    </v-footer>
  </v-app>
</template>

Repare que v-list-item aceita a prop to, exatamente como RouterLink — internamente, o Vuetify integra os dois. Isso evita ter que envolver cada item de menu em um <RouterLink> manualmente.

Passo 8 — criar HomeView.vue

Vue SFC
<!-- src/views/HomeView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { eventos } from '../data/eventos'

const carregando = ref(true)
const listaEventos = ref([])
const categoriaFiltro = ref('Todas')
const busca = ref('')

onMounted(() => {
  // simula uma chamada assíncrona, como fizemos na Aula 03 com fetch
  setTimeout(() => {
    listaEventos.value = eventos
    carregando.value = false
  }, 400)
})

const categorias = ['Todas', 'Palestra', 'Minicurso', 'Workshop']

const eventosFiltrados = computed(() => {
  return listaEventos.value.filter((evento) => {
    const bateCategoria =
      categoriaFiltro.value === 'Todas' ||
      evento.categoria === categoriaFiltro.value.toLowerCase()
    const bateBusca = evento.titulo
      .toLowerCase()
      .includes(busca.value.toLowerCase())
    return bateCategoria && bateBusca
  })
})

function formatarData(dataIso) {
  return new Date(dataIso).toLocaleDateString('pt-BR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  })
}
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Eventos disponíveis</h1>

    <v-row class="mb-2">
      <v-col cols="12" md="6">
        <v-text-field
          v-model="busca"
          label="Buscar evento"
          prepend-inner-icon="mdi-magnify"
          variant="outlined"
          density="compact"
          clearable
        />
      </v-col>
      <v-col cols="12" md="6">
        <v-select
          v-model="categoriaFiltro"
          :items="categorias"
          label="Categoria"
          variant="outlined"
          density="compact"
        />
      </v-col>
    </v-row>

    <div v-if="carregando" class="d-flex justify-center pa-8">
      <v-progress-circular indeterminate color="primary" size="48" />
    </div>

    <v-alert
      v-else-if="eventosFiltrados.length === 0"
      type="info"
      variant="tonal"
      title="Nenhum evento encontrado"
    >
      Tente ajustar os filtros de categoria ou o termo de busca.
    </v-alert>

    <v-row v-else>
      <v-col
        v-for="evento in eventosFiltrados"
        :key="evento.id"
        cols="12"
        sm="6"
        md="4"
      >
        <v-card :to="{ name: 'evento-detalhe', params: { id: evento.id } }">
          <v-img :src="evento.imagemUrl" height="160" cover />
          <v-card-title>{{ evento.titulo }}</v-card-title>
          <v-card-subtitle>
            {{ formatarData(evento.dataHora) }} · {{ evento.local }}
          </v-card-subtitle>
          <v-card-actions>
            <v-chip color="secondary" size="small">{{ evento.categoria }}</v-chip>
            <v-spacer />
            <v-chip color="success" size="small">{{ evento.vagas }} vagas</v-chip>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

Assim como v-list-item, o v-card aceita a prop to — o card inteiro vira clicável e navega para o detalhe do evento, sem precisar de um @click manual com router.push.

Passo 9 — criar EventoDetalheView.vue

Vue SFC
<!-- src/views/EventoDetalheView.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { eventos } from '../data/eventos'

const rota = useRoute()
const router = useRouter()

const evento = computed(() =>
  eventos.find((e) => e.id === Number(rota.params.id))
)

function formatarDataHora(dataIso) {
  return new Date(dataIso).toLocaleString('pt-BR', {
    dateStyle: 'long',
    timeStyle: 'short',
  })
}

function voltar() {
  router.push({ name: 'home' })
}
</script>

<template>
  <v-container>
    <v-btn variant="text" prepend-icon="mdi-arrow-left" class="mb-4" @click="voltar">
      Voltar para eventos
    </v-btn>

    <v-alert v-if="!evento" type="error" variant="tonal" title="Evento não encontrado">
      Não existe evento com este identificador. Confira o link acessado.
    </v-alert>

    <v-card v-else>
      <v-img :src="evento.imagemUrl" height="280" cover />
      <v-card-title class="text-h5">{{ evento.titulo }}</v-card-title>
      <v-card-subtitle>
        <v-icon icon="mdi-calendar" size="small" class="mr-1" />
        {{ formatarDataHora(evento.dataHora) }}
      </v-card-subtitle>
      <v-card-text>
        <p class="mb-4">{{ evento.descricao }}</p>
        <div class="d-flex align-center mb-2">
          <v-icon icon="mdi-map-marker" class="mr-2" />
          <span>{{ evento.local }}</span>
        </div>
        <div class="d-flex align-center">
          <v-icon icon="mdi-account-group" class="mr-2" />
          <span>{{ evento.vagas }} vagas disponíveis</span>
        </div>
      </v-card-text>
      <v-card-actions>
        <v-chip color="secondary">{{ evento.categoria }}</v-chip>
        <v-spacer />
        <v-btn color="primary" variant="flat">Inscrever-se</v-btn>
      </v-card-actions>
    </v-card>
  </v-container>
</template>

Note o uso de Number(rota.params.id) — como discutido na §7, o parâmetro de rota sempre chega como string, e nossos IDs no array eventos são números.

Passo 10 — criar SobreView.vue e NaoEncontradoView.vue

Vue SFC
<!-- src/views/SobreView.vue -->
<script setup>
</script>

<template>
  <v-container>
    <v-card class="pa-4">
      <v-card-title class="text-h5">Sobre o UniEventos</v-card-title>
      <v-card-text>
        <p class="mb-2">
          O UniEventos é uma plataforma para divulgação e inscrição em eventos
          acadêmicos — palestras, minicursos e workshops.
        </p>
        <p>
          Projeto desenvolvido na disciplina FACET-SNP-310 — Frameworks Modernos
          para Desenvolvimento de Sistemas, UNEMAT/Sinop, 2026.2.
        </p>
      </v-card-text>
    </v-card>
  </v-container>
</template>
Vue SFC
<!-- src/views/NaoEncontradoView.vue -->
<script setup>
import { RouterLink } from 'vue-router'
</script>

<template>
  <v-container class="d-flex flex-column align-center justify-center" style="min-height: 60vh">
    <v-icon icon="mdi-alert-circle-outline" size="80" color="error" class="mb-4" />
    <h1 class="text-h4 mb-2">Página não encontrada</h1>
    <p class="mb-6">O endereço acessado não existe no UniEventos.</p>
    <v-btn color="primary" variant="flat" :to="{ name: 'home' }">Voltar para o início</v-btn>
  </v-container>
</template>

Repare que usamos class="d-flex flex-column align-center justify-center" em vez de fill-height — exatamente o alerta da §6 sobre a mudança de comportamento no Vuetify 4.

Passo 11 — testar a navegação

Terminal
npm run dev

Confira: a home lista os eventos com filtro funcionando; clicar em um card navega para o detalhe com a URL /eventos/3; o menu lateral abre com o ícone de hambúrguer; o botão de sol/lua alterna o tema; acessar uma URL inexistente (/qualquer-coisa) mostra a tela 404.

🧪 Laboratório

1. Chip de vagas esgotadas No HomeView.vue, altere o chip de vagas para mostrar "Esgotado" em vermelho (color="error") quando evento.vagas === 0. Adicione um evento de teste com vagas: 0 no array de dados.

Dica

Use um v-if/v-else dentro do v-card-actions, ou um computed que retorna a cor e o texto do chip com base em evento.vagas.

2. Rota /eventos (lista) separada da rota /eventos/:id (detalhe) Hoje a home (/) já mostra a lista. Crie também uma rota nomeada eventos-lista no caminho /eventos que renderiza o mesmo componente que a home usa para a listagem. Use <RouterLink :to="{ name: 'eventos-lista' }"> em algum lugar do menu.

Dica

Você pode apontar duas entradas de routes para o mesmo component, com path e name diferentes.

3. Contador de eventos no app-bar No App.vue, mostre no v-app-bar (ao lado do título) um v-chip com o total de eventos cadastrados. Você vai precisar importar o array eventos também no App.vue.

Dica

import { eventos } from './data/eventos' e depois {{ eventos.length }} dentro de um v-chip.

4. Tema alternativo com terceira paleta Adicione um terceiro tema chamado contraste, com cores de alto contraste (preto/amarelo), e um botão que cicla entre lightdarkcontrastelight.

Dica

themes: { light: {...}, dark: {...}, contraste: {...} } no createVuetify, e uma função que usa um array ['light', 'dark', 'contraste'] com indexOf para descobrir o próximo tema.

5. Rota protegida por parâmetro inválido No EventoDetalheView.vue, se rota.params.id não for um número válido (ex.: /eventos/abc), redirecione automaticamente para a rota nao-encontrado usando router.push.

Dica

Number.isNaN(Number(rota.params.id)) dentro de um onMounted ou de um watch sobre rota.params.id.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
v-card, v-btn etc. aparecem como tags desconhecidas (sem estilo) vuetify({ autoImport: true }) não foi adicionado ao vite.config.js, ou o servidor não foi reiniciado após editar a config Confira o vite.config.js e reinicie npm run dev
Ícones MDI aparecem como quadrado vazio Faltou importar @mdi/font/css/materialdesignicons.css Adicione o import no arquivo onde createVuetify é chamado
Tela muda de clara para escura sozinha, sem clique defaultTheme não foi definido — Vuetify 4 usa 'system' por padrão Defina theme: { defaultTheme: 'light' } explicitamente
v-row align="center" não centraliza nada Prop removida no Vuetify 4 Troque por class="align-center" no v-row, ou d-flex align-center num <div>
RouterLink/RouterView não reconhecidos no template Componente não importado (fora do padrão de autoimport do Vuetify, que não cobre o Vue Router) Adicione import { RouterLink, RouterView } from 'vue-router' no <script setup>
rota.params.id comparado com === a um número nunca bate Parâmetro de rota sempre é string Converta com Number(rota.params.id) antes de comparar
Rota 404 nunca é acionada, mesmo em URL inválida Rota catch-all /:pathMatch(.*)* não está por último no array routes Mova a rota catch-all para o final da lista

🏠 Atividade assíncrona (1 h)

No seu projeto autoral (definido na Aula 01), aplique exatamente a mesma migração feita hoje no UniEventos:

  1. Instale o Vuetify seguindo os passos da §2.
  2. Crie um layout com v-app-bar, v-navigation-drawer (ou menu simples) e v-main.
  3. Configure um tema com pelo menos primary e secondary customizados, coerente com o domínio do seu projeto.
  4. Crie pelo menos três rotas: uma lista, um detalhe com parâmetro (/:id) e uma rota 404.
  5. Migre seus dados (mínimo 8 registros, já existentes desde a Aula 01/02) para os cards em grid responsivo.

Critério de pronto: npm run dev roda sem erros no console; navegar entre as três rotas funciona; o card de detalhe mostra os dados corretos ao clicar em um item da lista; acessar uma URL inexistente mostra a tela 404. Suba o commit no repositório do projeto autoral.

✅ Checkpoint do projeto autoral

Ao final desta aula, seu repositório deve ter:

  • [ ] Vuetify 4 instalado e funcionando (v-card, v-btn renderizando estilizados).
  • [ ] Tema customizado com defaultTheme: 'light' declarado explicitamente.
  • [ ] Layout com v-app-bar + v-main (e v-navigation-drawer se aplicável).
  • [ ] Vue Router configurado com no mínimo: rota de lista, rota de detalhe com parâmetro :id, rota 404.
  • [ ] Navegação funcionando via <RouterLink> e/ou :to em v-card/v-list-item.
  • [ ] Dados de pelo menos 8 registros do domínio autoral, exibidos em grid responsivo (v-row/v-col com breakpoints).
  • [ ] README atualizado com instruções de instalação e execução (npm install, npm run dev).
  • [ ] Código versionado e enviado ao repositório GitHub público.

📝 Avaliação 1 — instruções de entrega

Escopo

A Avaliação 1 cobre a Unidade 1 inteira: estrutura de um projeto Vue 3 criado com CLI, componentes, diretivas, reatividade, ciclo de vida e — a partir de hoje — Vuetify e Vue Router básico. Você vai entregar o projeto autoral que vem evoluindo desde a Aula 01.

O projeto deve:

  • Ter sido criado com npm create vue@latest (ou npx create-vue@latest), com as flags --router no mínimo.
  • Ser uma SPA de página única transformada em múltiplas views navegáveis (o que fizemos hoje).
  • Usar Vuetify para toda a interface visual (não é permitido CSS puro substituindo os componentes do Vuetify nas telas principais).
  • Ter um domínio de dados diferente do UniEventos construído em sala (ex.: catálogo de plantas do Pantanal, agenda de quadras esportivas, mural de estágios, brechó, controle de pescarias, cardápio de restaurante — ou outro tema aprovado na Aula 01).

Requisitos obrigatórios

  1. Mínimo de 6 componentes .vue próprios (views + componentes reutilizáveis), além do App.vue.
  2. Uso comprovado — em código, não só em teoria — de: v-if/v-else, v-for com :key, v-model, v-bind (ou o atalho :), v-on (ou o atalho @), computed e onMounted.
  3. Dados de pelo menos 8 registros do domínio escolhido, em um arquivo separado (src/data/*.js) ou vindos de fetch a uma API pública/mock.
  4. Roteamento com Vue Router: no mínimo 3 rotas, sendo uma delas com parâmetro dinâmico e uma delas a rota 404.
  5. README.md no repositório, com: nome do projeto, descrição de uma linha, instruções de instalação (npm install) e execução (npm run dev), e print de tela (opcional, mas recomendado).
  6. Repositório GitHub público, com histórico de commits que mostre evolução incremental (não um único commit "projeto final").

Formato e prazo de entrega

Entregue o link do repositório GitHub público via SIGAA, na atividade "Avaliação 1", até 02/09/2026, 23h59. Cole o link diretamente no campo de texto da atividade — não anexe .zip.

Rubrica (10,0 pontos)

Critério Peso
Estrutura e organização do projeto (pastas, nomes, componentização mínima) 2,0
Uso correto de diretivas e reatividade (v-if, v-for, v-model, v-bind, v-on) 3,0
Uso de computed e ciclo de vida (onMounted) de forma coerente com o domínio 2,0
Interface visual com Vuetify e navegação com Vue Router funcionando 1,5
README, versionamento e histórico de commits 1,5

Política de atraso

Entregas após 02/09/2026 23h59 perdem 1,0 ponto por dia corrido de atraso, até o limite de 5 dias. Após esse prazo, a atividade recebe nota zero, salvo justificativa formal protocolada junto à coordenação do curso.

Política de plágio e uso de IA

É permitido usar ferramentas de IA como apoio (explicar erros, sugerir sintaxe, revisar código) — assim como é permitido consultar documentação e tutoriais. Não é permitido entregar um projeto gerado quase integralmente por IA sem compreensão do próprio código: na correção, qualquer estudante pode ser chamado para explicar oralmente uma parte do seu projeto, e a nota é ajustada conforme a clareza da explicação. Cópia integral do projeto de outro colega (mesmo com o domínio "trocado") é considerada plágio e resulta em nota zero para ambos os envolvidos, com encaminhamento ao regimento acadêmico da UNEMAT.

📚 Para aprofundar

Na Aula 05 vamos aprofundar componentização — defineProps, defineEmits, slots, composables — e o Vue Router avançado: rotas aninhadas, guards de navegação e query strings sincronizadas com filtros. É também quando o Vuetify ganha formulários com validação e v-data-table.

🎯 Objetivos de aprendizagem📋 Pré-requisitos desta aula🗺️ Roteiro1. Por que usar um framework de UIMaterial Design 3 em uma página2. Instalando o Vuetify 43. Estrutura de aplicação VuetifyGrid: v-container / v-row / v-col4. Componentes essenciaisv-cardv-btn e suas variantesv-chip, v-icon, v-listv-alert, v-dialog, v-snackbarv-text-field, v-select, v-img, v-progress-circular5. Tema: cores institucionais e alternador claro/escuro6. Classes utilitárias de espaçamento e layout7. Vue Router 5: transformando páginas em rotasEstrutura básica<RouterLink> e <RouterView>Rotas com parâmetrosRota 404 (catch-all)Navegação programáticaLazy loading de rotas🧩 Padrão de projeto em uso🧩 Padrão de projeto em uso — Composite (estrutural)💻 Mão na massa — migrando o UniEventos para uma SPA de verdadePasso 1 — instalar o Vuetify no projetoPasso 2 — configurar vite.config.jsPasso 3 — criar o plugin do Vuetify com tema institucionalPasso 4 — atualizar src/main.jsPasso 5 — criar o modelo de dados de eventosPasso 6 — atualizar src/router/index.jsPasso 7 — criar o layout em App.vuePasso 8 — criar HomeView.vuePasso 9 — criar EventoDetalheView.vuePasso 10 — criar SobreView.vue e NaoEncontradoView.vuePasso 11 — testar a navegação🧪 Laboratório🐛 Erros comuns e como resolver🏠 Atividade assíncrona (1 h)✅ Checkpoint do projeto autoral📝 Avaliação 1 — instruções de entregaEscopoRequisitos obrigatóriosFormato e prazo de entregaRubrica (10,0 pontos)Política de atrasoPolítica de plágio e uso de IA📚 Para aprofundar
Nível 3Unidade 2 · Vue.js avançado: Vuetify, Axios, Router e Pinia3 aulas de 50 min + 1 h EAD

Aula 05 — Componentes, Vue Router e Vuetify avançado

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Decidir quando e como quebrar uma tela em componentes menores, distinguindo componentes "burros" (apresentação) de "inteligentes" (com lógica).
  • Definir contratos de componente com defineProps (tipado, com required/default/validator) e defineEmits, incluindo v-model customizado com defineModel() e a forma clássica.
  • Usar provide/inject para dados compartilhados em profundidade e controlar atributos fallthrough com defineOptions({ inheritAttrs: false }).
  • Aplicar slots (padrão, nomeados e com escopo) para criar componentes de layout reutilizáveis.
  • Extrair lógica reativa reutilizável em composables (use*) e explicar por que isso substitui mixins.
  • Configurar rotas aninhadas, rotas nomeadas, meta, navigation guards e sincronizar filtros com query strings na URL.
  • Construir formulários validados com v-form, listar dados com v-data-table e usar diálogos de confirmação, tabs, menus e skeleton loaders do Vuetify.

📋 Pré-requisitos desta aula

  • [ ] UniEventos da Aula 04 rodando: Vuetify instalado, tema configurado, rotas home, evento-detalhe, sobre, nao-encontrado funcionando.
  • [ ] Avaliação 1 entregue (ou em fase final de entrega).
  • [ ] Domínio confortável de <script setup>, defineProps/defineEmits básicos (vistos rapidamente na Aula 02), computed, onMounted.

Na Aula 04 você transformou o UniEventos em uma SPA navegável. Hoje ele fica modular: em vez de views monolíticas com tudo dentro, cada pedaço de interface vira um componente com contrato próprio — e a área administrativa ganha rotas aninhadas e formulários validados.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Componentização a sério: props, emits, v-model customizado, provide/inject, slots
2 50 min Composables, Vue Router avançado (rotas aninhadas, guards, query strings), formulários e v-data-table
3 50 min Mão na massa: refatoração do UniEventos em componentes + área administrativa

1. Componentização a sério

Até aqui, cada view do UniEventos (HomeView, EventoDetalheView) concentrava template, lógica e estilo em um único arquivo. Isso funciona em uma tela pequena, mas cresce mal: a HomeView já mistura busca, filtro, grid de cards e lógica de carregamento — daqui a duas aulas, com formulário de cadastro e tabela administrativa, o arquivo viraria ilegível.

Componentizar é dividir a interface em peças menores, cada uma com uma responsabilidade única e um contrato explícito de entrada (props) e saída (emits). O benefício não é só organização de arquivo — é reuso (o mesmo EventoCard aparece na home, na busca e na área administrativa) e testabilidade (um componente pequeno é mais fácil de entender isoladamente).

Granularidade: quando quebrar em componente

Não existe regra rígida, mas alguns sinais indicam que é hora de extrair um componente:

  • O mesmo trecho de template se repete em duas ou mais telas (ex.: o card de evento).
  • Um bloco do template tem lógica própria que não interessa ao componente pai (ex.: a lógica de validação de um campo de formulário).
  • O arquivo passou de ~150–200 linhas e virou difícil de escanear visualmente.
  • Você consegue nomear o pedaço com um substantivo claro (EventoCard, FiltroEventos, CabecalhoApp) — se não consegue nomear, talvez não seja um componente coerente ainda.

⚠️ Atenção: granularidade excessiva também é problema. Um projeto com componentes de 5 linhas para cada <span> cria uma "sopa de componentes" difícil de navegar. Componentize quando há repetição ou responsabilidade clara — não por dogma.

Componente burro vs. componente inteligente

Uma distinção útil (não uma regra do Vue, mas um padrão de arquitetura comum em SPAs):

  • Componente burro (presentational / dumb) — só recebe dados via props e emite eventos. Não sabe de onde vêm os dados nem o que acontece depois do evento. Fácil de reutilizar e testar. Exemplo: EventoCard, que recebe um objeto evento e emite @inscrever.
  • Componente inteligente (container / smart) — busca dados, decide o que fazer com eventos emitidos pelos filhos, conversa com store/API. Exemplo: HomeView, que carrega a lista de eventos e passa cada um para um EventoCard.

Essa separação evita que a lógica de negócio (como buscar dados, como filtrar) fique espalhada em componentes visuais pequenos — o que dificultaria trocar, por exemplo, a fonte de dados sem tocar em uma dezena de arquivos.

defineProps com contrato completo

Você já usou defineProps de forma simples nas aulas anteriores. Um contrato robusto de props declara tipo, obrigatoriedade, valor padrão e validação:

Vue SFC
<script setup>
const props = defineProps({
  evento: {
    type: Object,
    required: true,
  },
  destaque: {
    type: Boolean,
    default: false,
  },
  tamanhoImagem: {
    type: Number,
    default: 160,
    validator: (valor) => valor >= 100 && valor <= 400,
  },
})
</script>
  • type habilita checagem em tempo de desenvolvimento — o Vue avisa no console se você passar um tipo errado.
  • required: true faz o Vue emitir um aviso se a prop não for passada.
  • default define um valor quando a prop não é informada (obrigatório para props opcionais que não são required).
  • validator é uma função que recebe o valor e retorna true/false — útil para restringir um número a uma faixa, ou uma string a um conjunto de valores permitidos (enum informal).

💡 Dica: props são somente leitura dentro do componente filho — nunca faça props.evento = outraCoisa. Se o filho precisa "mudar" algo que veio do pai, ele deve emitir um evento pedindo a mudança, e é o pai quem decide se atende.

defineEmits e comunicação filho → pai

Vue SFC
<!-- src/components/EventoCard.vue (trecho) -->
<script setup>
const props = defineProps({
  evento: { type: Object, required: true },
})

const emit = defineEmits({
  inscrever: (idEvento) => typeof idEvento === 'number',
  favoritar: null, // sem validação
})

function aoClicarInscrever() {
  emit('inscrever', props.evento.id)
}
</script>

<template>
  <v-card>
    <!-- ... -->
    <v-btn color="primary" @click="aoClicarInscrever">Inscrever-se</v-btn>
  </v-card>
</template>

defineEmits declarado como objeto (em vez de array de strings) permite validar o payload de cada evento — assim como defineProps valida entradas, isso valida saídas. O componente pai escuta o evento normalmente:

Vue SFC
<EventoCard :evento="evento" @inscrever="tratarInscricao" />

v-model em componente customizado

Você já usa v-model em v-text-field e v-dialog — isso é possível porque esses componentes implementam o contrato de v-model. Você pode implementar o mesmo contrato nos seus próprios componentes, de duas formas.

Forma moderna — defineModel() (Vue 3.4+):

Vue SFC
<!-- src/components/CampoBusca.vue -->
<script setup>
const modelo = defineModel({ type: String, default: '' })
</script>

<template>
  <v-text-field
    v-model="modelo"
    label="Buscar"
    prepend-inner-icon="mdi-magnify"
    variant="outlined"
    clearable
  />
</template>
Vue SFC
<CampoBusca v-model="termoBusca" />

defineModel() cria automaticamente uma prop modelValue e um evento update:modelValue por baixo dos panos, expondo tudo como uma única variável reativa (modelo) que você lê e escreve como se fosse um ref comum. É a forma recomendada para código novo.

Forma clássica — modelValue / update:modelValue:

Vue SFC
<!-- src/components/CampoBusca.vue (equivalente, forma clássica) -->
<script setup>
const props = defineProps({
  modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <v-text-field
    :model-value="modelValue"
    label="Buscar"
    prepend-inner-icon="mdi-magnify"
    variant="outlined"
    clearable
    @update:model-value="emit('update:modelValue', $event)"
  />
</template>

As duas formas produzem exatamente o mesmo comportamento externo — <CampoBusca v-model="termoBusca" /> funciona igual nos dois casos. defineModel() é mais curto e é o padrão desta disciplina daqui em diante, mas você vai encontrar a forma clássica em muito código existente (inclusive em bibliotecas), então precisa reconhecê-la.

🔎 Por baixo do capô: v-model="x" em um componente é açúcar sintático para :model-value="x" @update:model-value="x = $event". É exatamente o mesmo mecanismo de prop + evento que você já usa manualmente — só que com uma sintaxe mais curta, reconhecida pelo compilador do Vue.

provide/inject para dados profundos

Passar props por 3 ou 4 níveis de componentes só para chegar a um neto profundo (prop drilling) é doloroso de manter. Para dados amplamente compartilhados — tema, usuário logado, configuração global —, o Vue oferece provide/inject:

Vue SFC
<!-- src/App.vue (trecho) -->
<script setup>
import { provide, ref } from 'vue'

const usuarioLogado = ref({ nome: 'Convidado' })
provide('usuarioLogado', usuarioLogado)
</script>
Vue SFC
<!-- src/components/PainelPerfil.vue (qualquer nível abaixo de App.vue) -->
<script setup>
import { inject } from 'vue'

const usuarioLogado = inject('usuarioLogado')
</script>

<template>
  <span>Olá, {{ usuarioLogado.nome }}</span>
</template>

inject encontra o valor mais próximo fornecido por um ancestral, não importa quantos níveis de componentes existam entre eles. Não é um substituto para comunicação local (props/emits continuam sendo a opção certa entre pai e filho diretos) — é uma ferramenta específica para dados "ambientais". Na Aula 06, o Pinia vai resolver a maior parte desses casos de forma mais estruturada; provide/inject ainda é útil para configuração de componentes de biblioteca (é assim, inclusive, que o próprio Vuetify propaga o tema).

Atributos fallthrough e inheritAttrs

Quando você passa um atributo a um componente que não está declarado como prop, o Vue aplica automaticamente esse atributo à raiz do template do componente — isso se chama fallthrough:

Vue SFC
<EventoCard :evento="evento" class="destaque" data-testid="card-evento" />

Se EventoCard não declara class nem data-testid como props, o Vue aplica os dois diretamente no elemento raiz do template de EventoCard (por exemplo, no <v-card>). Isso é conveniente na maioria dos casos — mas quando o componente tem múltiplos elementos raiz, ou quando você quer redirecionar o atributo para um elemento interno específico (não o raiz), use:

Vue SFC
<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <div class="wrapper">
    <v-card v-bind="$attrs">
      <!-- conteúdo -->
    </v-card>
  </div>
</template>

defineOptions({ inheritAttrs: false }) desliga o comportamento automático; v-bind="$attrs" aplica manualmente todos os atributos não declarados como props no elemento que você escolher.

2. Slots: componentes de layout reutilizáveis

Props resolvem "que dados entram". Slots resolvem "que conteúdo/template entra" — permitem que um componente pai injete HTML/componentes dentro de um "buraco" definido pelo componente filho.

Slot padrão

Vue SFC
<!-- src/components/CartaoBase.vue -->
<template>
  <v-card class="pa-4">
    <slot />
  </v-card>
</template>
Vue SFC
<CartaoBase>
  <h3>Qualquer conteúdo aqui</h3>
  <p>O CartaoBase não sabe nem precisa saber o que vai dentro.</p>
</CartaoBase>

Slots nomeados

Um componente pode ter vários "buracos" com papéis diferentes:

Vue SFC
<!-- src/components/CartaoBase.vue -->
<template>
  <v-card>
    <v-card-title>
      <slot name="titulo">Sem título</slot>
    </v-card-title>
    <v-card-text>
      <slot />
    </v-card-text>
    <v-card-actions>
      <slot name="acoes" />
    </v-card-actions>
  </v-card>
</template>
Vue SFC
<CartaoBase>
  <template #titulo>Semana Acadêmica</template>

  Conteúdo do corpo do card, vai para o slot padrão.

  <template #acoes>
    <v-btn color="primary">Inscrever-se</v-btn>
  </template>
</CartaoBase>

#titulo é o atalho para v-slot:titulo. Um slot sem name é chamado de slot padrão (default), e recebe qualquer conteúdo que não esteja explicitamente marcado com <template #algumNome>.

Slots com escopo (scoped slots)

Às vezes o componente filho tem dados que o pai precisa usar dentro do conteúdo injetado. Um slot com escopo passa dados do filho para o template do pai:

Vue SFC
<!-- src/components/EventoLista.vue (trecho) -->
<template>
  <div v-for="evento in eventos" :key="evento.id">
    <slot name="item" :evento="evento" :formatarData="formatarData" />
  </div>
</template>
Vue SFC
<EventoLista :eventos="listaEventos">
  <template #item="{ evento, formatarData }">
    <v-card>
      <v-card-title>{{ evento.titulo }}</v-card-title>
      <v-card-subtitle>{{ formatarData(evento.dataHora) }}</v-card-subtitle>
    </v-card>
  </template>
</EventoLista>

O componente EventoLista controla a iteração (v-for) e a lógica auxiliar (formatarData), mas delega ao componente pai como cada item é desenhado. Isso é poderoso: o mesmo EventoLista pode ser reaproveitado em uma tela que mostra cards e em outra que mostra uma tabela — só o slot #item muda.

3. Composables: extraindo lógica reutilizável

Um composable é uma função que usa a Composition API (ref, computed, watch, onMounted etc.) para encapsular um pedaço de lógica reativa reutilizável, seguindo a convenção de nome use*.

JavaScript
// src/composables/useEventos.js
import { ref, computed, onMounted } from 'vue'
import { eventos as eventosBase } from '../data/eventos'

export function useEventos() {
  const carregando = ref(true)
  const eventos = ref([])
  const categoriaFiltro = ref('Todas')
  const busca = ref('')

  onMounted(() => {
    setTimeout(() => {
      eventos.value = eventosBase
      carregando.value = false
    }, 300)
  })

  const eventosFiltrados = computed(() => {
    return eventos.value.filter((evento) => {
      const bateCategoria =
        categoriaFiltro.value === 'Todas' ||
        evento.categoria === categoriaFiltro.value.toLowerCase()
      const bateBusca = evento.titulo
        .toLowerCase()
        .includes(busca.value.toLowerCase())
      return bateCategoria && bateBusca
    })
  })

  return {
    carregando,
    eventos,
    categoriaFiltro,
    busca,
    eventosFiltrados,
  }
}

Qualquer componente que precise dessa lógica simplesmente chama a função:

Vue SFC
<script setup>
import { useEventos } from '../composables/useEventos'

const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>

Cada chamada de useEventos() cria seu próprio estado isolado (as variáveis ref são criadas de novo a cada chamada) — diferente de uma store Pinia, que é compartilhada globalmente (veremos essa distinção com clareza na Aula 06).

Por que composables substituem mixins

Antes da Composition API, o Vue 2 usava mixins para reutilizar lógica entre componentes: um objeto com data, methods, computed que era "misturado" ao componente. O problema era que, ao usar dois ou mais mixins no mesmo componente, não dava para saber de onde vinha cada propriedade — se data.carregando veio do mixin A ou do mixin B era invisível no template, e colisões de nome se sobrescreviam silenciosamente.

Composables resolvem isso porque tudo é explícito: você importa a função, chama, e desestrutura exatamente o que quer usar, sob o nome que quiser:

JavaScript
const { eventosFiltrados: eventosDaHome } = useEventos()

Não há mágica de mesclagem por trás — é só uma função JavaScript comum retornando um objeto. Essa clareza de origem é a razão pela qual a comunidade Vue abandonou mixins como padrão recomendado.

4. Vue Router avançado

Rotas aninhadas (children)

Uma área administrativa tem uma URL-base (/admin) com sub-telas (/admin/eventos, /admin/eventos/novo). Em vez de repetir /admin em cada rota, use children:

JavaScript
// src/router/index.js (trecho)
{
  path: '/admin',
  component: () => import('../views/admin/AdminLayoutView.vue'),
  children: [
    { path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue') },
    { path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue') },
    { path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue') },
    { path: 'eventos/:id/editar', name: 'admin-evento-editar', component: () => import('../views/admin/AdminEventoFormView.vue') },
  ],
}

O componente pai da rota (AdminLayoutView.vue) precisa ter seu próprio <RouterView /> — é onde as rotas-filhas serão renderizadas:

Vue SFC
<!-- src/views/admin/AdminLayoutView.vue -->
<template>
  <v-container>
    <v-tabs>
      <v-tab :to="{ name: 'admin-home' }">Painel</v-tab>
      <v-tab :to="{ name: 'admin-eventos' }">Eventos</v-tab>
    </v-tabs>
    <RouterView />
  </v-container>
</template>

Esse aninhamento de RouterView dentro de RouterView é o mesmo padrão Composite que vimos na Aula 04 aplicado à navegação: cada nível de rota tem seu próprio "slot" de renderização.

meta em rotas

Cada rota pode carregar metadados arbitrários, usados por guards ou pela própria interface (ex.: título da página, exigência de autenticação):

JavaScript
{
  path: 'eventos/novo',
  name: 'admin-evento-novo',
  component: () => import('../views/admin/AdminEventoFormView.vue'),
  meta: { requerAutenticacao: true, titulo: 'Novo evento' },
}

Navigation guards

Guards são funções que rodam antes (ou depois) de uma navegação, podendo permitir, bloquear ou redirecionar.

beforeEach — guard global, roda em toda navegação:

JavaScript
// src/router/index.js (trecho, após criar o router)
router.beforeEach((to, from) => {
  document.title = to.meta.titulo
    ? `${to.meta.titulo} · UniEventos`
    : 'UniEventos'

  const autenticado = false // substituiremos por estado real com Pinia na Aula 06
  if (to.meta.requerAutenticacao && !autenticado) {
    return { name: 'home' } // redireciona
  }
  // retornar undefined/true permite a navegação
})

beforeEnter — guard por rota, só roda ao entrar naquela rota específica:

JavaScript
{
  path: 'eventos/:id/editar',
  name: 'admin-evento-editar',
  component: () => import('../views/admin/AdminEventoFormView.vue'),
  beforeEnter: (to) => {
    if (Number.isNaN(Number(to.params.id))) {
      return { name: 'nao-encontrado' }
    }
  },
}

onBeforeRouteLeave — guard dentro do componente, útil para confirmar saída de um formulário com alterações não salvas:

Vue SFC
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'

const formularioAlterado = ref(true)

onBeforeRouteLeave(() => {
  if (formularioAlterado.value) {
    const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
    if (!confirmar) return false
  }
})
</script>

Query strings sincronizadas com filtros

Uma prática comum e muito útil: refletir o estado dos filtros de busca na URL, para que o usuário possa compartilhar/recarregar a página sem perder o filtro aplicado.

Vue SFC
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const rota = useRoute()
const router = useRouter()

// inicializa o filtro a partir da query string, se existir
const categoriaFiltro = ref(rota.query.categoria ?? 'Todas')

// sempre que o filtro mudar, atualiza a URL (sem recarregar a página)
watch(categoriaFiltro, (novoValor) => {
  router.push({ query: { ...rota.query, categoria: novoValor } })
})
</script>

Com isso, /eventos?categoria=Minicurso carrega a tela já filtrada — útil para compartilhar um link de busca específica, e para o botão "voltar" do navegador restaurar o filtro anterior.

Scroll behavior

Por padrão, ao navegar entre rotas o Vue Router mantém a posição de rolagem atual. Para voltar ao topo em cada navegação (comportamento mais comum em SPAs de conteúdo):

JavaScript
// src/router/index.js (trecho, dentro de createRouter)
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition // navegação por botão voltar/avançar
    return { top: 0 }
  },
  routes: [ /* ... */ ],
})

Layouts diferentes por rota

Nem toda rota deve usar o mesmo App.vue. A área administrativa, por exemplo, pode ter um layout próprio (sem o app-bar público). Uma forma simples é usar rotas aninhadas com um componente de layout diferente para cada seção — exatamente a estrutura de AdminLayoutView.vue que criamos acima. Cada "família" de rotas aponta para seu próprio layout, e cada layout tem seu próprio <RouterView /> interno.

5. Vuetify aprofundado

v-form com validação por regras

Vue SFC
<script setup>
import { ref } from 'vue'

const formRef = ref(null)
const titulo = ref('')
const vagas = ref(null)

const regrasTitulo = [
  (v) => !!v || 'O título é obrigatório',
  (v) => (v && v.length >= 5) || 'O título precisa ter ao menos 5 caracteres',
]

const regrasVagas = [
  (v) => !!v || 'Informe o número de vagas',
  (v) => (v > 0) || 'O número de vagas deve ser positivo',
]

async function salvar() {
  const { valid } = await formRef.value.validate()
  if (!valid) return
  // ... enviar dados
}
</script>

<template>
  <v-form ref="formRef" @submit.prevent="salvar">
    <v-text-field v-model="titulo" label="Título do evento" :rules="regrasTitulo" />
    <v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" />
    <v-btn type="submit" color="primary">Salvar</v-btn>
  </v-form>
</template>

rules é um array de funções que recebem o valor atual do campo e retornam true (válido) ou uma string (mensagem de erro exibida abaixo do campo). Chamar formRef.value.validate() executa todas as regras de todos os campos do formulário de uma vez e retorna { valid, errors }.

⚠️ Atenção: no Vuetify 4, se você usa o slot com escopo do v-form (<v-form v-slot="{ isValid }">) para acessar o estado de validação diretamente no template, essas variáveis de slot não são mais refs — não use .value nelas dentro do template. Compare:

```vue Salvar

Salvar ```

Se você copiar um exemplo antigo com .value dentro do template do v-form, o botão nunca habilita — isValid deixou de ser um objeto ref e passou a ser o valor puro.

v-data-table

Vue SFC
<script setup>
import { ref } from 'vue'
import { eventos } from '../data/eventos'

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Data', key: 'dataHora' },
  { title: 'Vagas', key: 'vagas' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

const busca = ref('')
</script>

<template>
  <v-text-field v-model="busca" label="Buscar" prepend-inner-icon="mdi-magnify" class="mb-4" />

  <v-data-table
    :headers="cabecalhos"
    :items="eventos"
    :search="busca"
    items-per-page="5"
  >
    <template #item.acoes="{ item }">
      <v-btn icon="mdi-pencil" size="small" variant="text" />
      <v-btn icon="mdi-delete" size="small" variant="text" color="error" />
    </template>
  </v-data-table>
</template>

v-data-table já traz ordenação por coluna (clicando no cabeçalho), paginação e busca (via prop search, cruzada contra todos os campos dos itens) prontos, sem código adicional. O slot nomeado #item.acoes — repare no padrão item.<chave-da-coluna> — permite customizar completamente o conteúdo de uma coluna, exatamente com a técnica de slot com escopo que vimos na §2.

v-dialog de confirmação, v-tabs, v-menu, v-skeleton-loader, v-pagination

Vue SFC
<!-- diálogo de confirmação reutilizável -->
<v-dialog v-model="dialogoAberto" max-width="400" persistent>
  <v-card title="Confirmar exclusão" text="Esta ação não pode ser desfeita.">
    <v-card-actions>
      <v-spacer />
      <v-btn variant="text" @click="dialogoAberto = false">Cancelar</v-btn>
      <v-btn color="error" variant="flat" @click="confirmarExclusao">Excluir</v-btn>
    </v-card-actions>
  </v-card>
</v-dialog>
Vue SFC
<!-- abas -->
<v-tabs v-model="abaAtiva">
  <v-tab value="proximos">Próximos</v-tab>
  <v-tab value="encerrados">Encerrados</v-tab>
</v-tabs>
<v-window v-model="abaAtiva">
  <v-window-item value="proximos">...</v-window-item>
  <v-window-item value="encerrados">...</v-window-item>
</v-window>
Vue SFC
<!-- menu de contexto -->
<v-menu>
  <template #activator="{ props: propsAtivador }">
    <v-btn icon="mdi-dots-vertical" variant="text" v-bind="propsAtivador" />
  </template>
  <v-list>
    <v-list-item title="Editar" prepend-icon="mdi-pencil" />
    <v-list-item title="Excluir" prepend-icon="mdi-delete" />
  </v-list>
</v-menu>

O slot #activator do v-menu é outro exemplo de slot com escopo: ele entrega propsAtivador, um conjunto de listeners/atributos que você precisa espalhar (v-bind) no elemento que deve abrir o menu ao ser clicado.

Vue SFC
<!-- esqueleto de carregamento -->
<v-skeleton-loader v-if="carregando" type="card" />
<EventoCard v-else :evento="evento" />

v-skeleton-loader substitui o v-progress-circular genérico quando você quer que o "estado de carregando" já sugira a forma do conteúdo final (cards cinza pulsando no lugar dos cards reais) — uma técnica de percepção de performance bastante usada em produção.

Vue SFC
<!-- paginação manual (fora do v-data-table) -->
<v-pagination v-model="paginaAtual" :length="totalPaginas" />

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Composite e Template Method

Composite aparece de novo hoje, agora na composição de componentes de layout: CartaoBase não sabe o que vai dentro dele — apenas define a "moldura" (v-card com título, corpo e ações), e quem usa o componente decide o conteúdo via slots. Isso é o mesmo princípio da árvore de componentes da Aula 04, aplicado deliberadamente ao design de um componente reutilizável.

Template Method é um padrão comportamental em que uma classe (ou, aqui, um componente) define o esqueleto de um algoritmo ou de uma estrutura, deixando etapas específicas para serem preenchidas por quem o usa. Um slot com escopo — como o #item de EventoLista — é exatamente isso: o componente controla o "algoritmo" (iterar sobre a lista, aplicar filtro), mas delega ao chamador a etapa de "como desenhar cada item". A estrutura geral é fixa; o passo variável é injetado de fora.

💻 Mão na massa — refatorando o UniEventos em componentes

Passo 1 — criar EventoCard.vue

Vue SFC
<!-- src/components/EventoCard.vue -->
<script setup>
const props = defineProps({
  evento: { type: Object, required: true },
})

const emit = defineEmits({
  inscrever: (idEvento) => typeof idEvento === 'number',
})

function formatarData(dataIso) {
  return new Date(dataIso).toLocaleDateString('pt-BR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  })
}
</script>

<template>
  <v-card :to="{ name: 'evento-detalhe', params: { id: evento.id } }">
    <v-img :src="evento.imagemUrl" height="160" cover />
    <v-card-title>{{ evento.titulo }}</v-card-title>
    <v-card-subtitle>
      {{ formatarData(evento.dataHora) }} · {{ evento.local }}
    </v-card-subtitle>
    <v-card-actions>
      <v-chip color="secondary" size="small">{{ evento.categoria }}</v-chip>
      <v-spacer />
      <v-chip :color="evento.vagas > 0 ? 'success' : 'error'" size="small">
        {{ evento.vagas > 0 ? `${evento.vagas} vagas` : 'Esgotado' }}
      </v-chip>
    </v-card-actions>
  </v-card>
</template>

Passo 2 — criar EventoLista.vue

Vue SFC
<!-- src/components/EventoLista.vue -->
<script setup>
defineProps({
  eventos: { type: Array, required: true },
})
</script>

<template>
  <v-row>
    <v-col
      v-for="evento in eventos"
      :key="evento.id"
      cols="12"
      sm="6"
      md="4"
    >
      <EventoCard :evento="evento" />
    </v-col>
  </v-row>
</template>

Graças ao autoImport do Vuetify e ao registro automático de componentes de mesmo diretório do Vite/Vue no scaffold, EventoCard é usado aqui sem import explícito — mas em projetos com muitos componentes é comum importar explicitamente por clareza. Vamos manter import explícito nas próximas telas para deixar as dependências óbvias.

Vue SFC
<!-- src/components/EventoLista.vue — versão com import explícito -->
<script setup>
import EventoCard from './EventoCard.vue'

defineProps({
  eventos: { type: Array, required: true },
})
</script>

<template>
  <v-row>
    <v-col
      v-for="evento in eventos"
      :key="evento.id"
      cols="12"
      sm="6"
      md="4"
    >
      <EventoCard :evento="evento" />
    </v-col>
  </v-row>
</template>

Passo 3 — criar FiltroEventos.vue com v-model duplo

Vue SFC
<!-- src/components/FiltroEventos.vue -->
<script setup>
const busca = defineModel('busca', { type: String, default: '' })
const categoria = defineModel('categoria', { type: String, default: 'Todas' })

const categorias = ['Todas', 'Palestra', 'Minicurso', 'Workshop']
</script>

<template>
  <v-row class="mb-2">
    <v-col cols="12" md="6">
      <v-text-field
        v-model="busca"
        label="Buscar evento"
        prepend-inner-icon="mdi-magnify"
        variant="outlined"
        density="compact"
        clearable
      />
    </v-col>
    <v-col cols="12" md="6">
      <v-select
        v-model="categoria"
        :items="categorias"
        label="Categoria"
        variant="outlined"
        density="compact"
      />
    </v-col>
  </v-row>
</template>

defineModel('busca', ...) e defineModel('categoria', ...) são a forma de defineModel() para múltiplos v-models no mesmo componente — cada nome vira um par prop/evento independente:

Vue SFC
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />

Passo 4 — criar CabecalhoApp.vue e RodapeApp.vue

Vue SFC
<!-- src/components/CabecalhoApp.vue -->
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { RouterLink } from 'vue-router'

defineProps({
  totalEventos: { type: Number, default: 0 },
})

const emit = defineEmits(['abrir-menu'])

const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')

function alternarTema() {
  tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
</script>

<template>
  <v-app-bar color="primary">
    <v-app-bar-nav-icon @click="emit('abrir-menu')" />
    <v-app-bar-title>
      <RouterLink to="/" class="text-white text-decoration-none">UniEventos</RouterLink>
    </v-app-bar-title>
    <v-chip color="secondary" size="small" class="mr-2">{{ totalEventos }} eventos</v-chip>
    <v-spacer />
    <v-btn
      :icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
      variant="text"
      @click="alternarTema"
    />
  </v-app-bar>
</template>
Vue SFC
<!-- src/components/RodapeApp.vue -->
<script setup>
</script>

<template>
  <v-footer color="primary" class="d-flex justify-center pa-4">
    <span class="text-white">UNEMAT · FACET · FACET-SNP-310 · 2026.2</span>
  </v-footer>
</template>

Passo 5 — criar DialogoConfirmacao.vue

Vue SFC
<!-- src/components/DialogoConfirmacao.vue -->
<script setup>
const aberto = defineModel({ type: Boolean, default: false })

defineProps({
  titulo: { type: String, default: 'Confirmar ação' },
  mensagem: { type: String, default: 'Esta ação não pode ser desfeita.' },
})

const emit = defineEmits(['confirmar'])

function confirmar() {
  emit('confirmar')
  aberto.value = false
}
</script>

<template>
  <v-dialog v-model="aberto" max-width="400" persistent>
    <v-card :title="titulo" :text="mensagem">
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="aberto = false">Cancelar</v-btn>
        <v-btn color="error" variant="flat" @click="confirmar">Confirmar</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

Passo 6 — atualizar App.vue para usar CabecalhoApp e RodapeApp

Vue SFC
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
import { RouterView } from 'vue-router'
import CabecalhoApp from './components/CabecalhoApp.vue'
import RodapeApp from './components/RodapeApp.vue'
import { eventos } from './data/eventos'

const drawerAberto = ref(false)
const totalEventos = computed(() => eventos.length)

const linksMenu = [
  { titulo: 'Início', rota: 'home', icone: 'mdi-home' },
  { titulo: 'Sobre', rota: 'sobre', icone: 'mdi-information' },
  { titulo: 'Administração', rota: 'admin-home', icone: 'mdi-cog' },
]
</script>

<template>
  <v-app>
    <CabecalhoApp :total-eventos="totalEventos" @abrir-menu="drawerAberto = !drawerAberto" />

    <v-navigation-drawer v-model="drawerAberto" temporary>
      <v-list>
        <v-list-item
          v-for="link in linksMenu"
          :key="link.rota"
          :to="{ name: link.rota }"
          :title="link.titulo"
          :prepend-icon="link.icone"
        />
      </v-list>
    </v-navigation-drawer>

    <v-main>
      <RouterView />
    </v-main>

    <RodapeApp />
  </v-app>
</template>

Passo 7 — reescrever HomeView.vue usando os componentes e o composable

Vue SFC
<!-- src/views/HomeView.vue -->
<script setup>
import { useEventos } from '../composables/useEventos'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'

const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Eventos disponíveis</h1>

    <FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />

    <div v-if="carregando" class="d-flex justify-center pa-8">
      <v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
    </div>

    <v-alert
      v-else-if="eventosFiltrados.length === 0"
      type="info"
      variant="tonal"
      title="Nenhum evento encontrado"
    >
      Tente ajustar os filtros de categoria ou o termo de busca.
    </v-alert>

    <EventoLista v-else :eventos="eventosFiltrados" />
  </v-container>
</template>

Compare este arquivo com o HomeView.vue da Aula 04: a lógica de busca/filtro/carregamento saiu para o composable useEventos, o grid de cards virou EventoLista, e os campos de filtro viraram FiltroEventos. A view agora só orquestra — é um bom exemplo de componente "inteligente" fino, delegando apresentação aos filhos.

Passo 8 — criar rotas administrativas aninhadas

JavaScript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    return { top: 0 }
  },
  routes: [
    { path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
    { path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
    { path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue'), meta: { titulo: 'Sobre' } },
    {
      path: '/admin',
      component: () => import('../views/admin/AdminLayoutView.vue'),
      children: [
        { path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue'), meta: { titulo: 'Painel administrativo' } },
        { path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue'), meta: { titulo: 'Gerenciar eventos' } },
        { path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue'), meta: { titulo: 'Novo evento' } },
        {
          path: 'eventos/:id/editar',
          name: 'admin-evento-editar',
          component: () => import('../views/admin/AdminEventoFormView.vue'),
          meta: { titulo: 'Editar evento' },
          beforeEnter: (to) => {
            if (Number.isNaN(Number(to.params.id))) {
              return { name: 'nao-encontrado' }
            }
          },
        },
      ],
    },
    { path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
  ],
})

router.beforeEach((to) => {
  document.title = to.meta.titulo ? `${to.meta.titulo} · UniEventos` : 'UniEventos'
})

export default router

Passo 9 — criar AdminLayoutView.vue

Vue SFC
<!-- src/views/admin/AdminLayoutView.vue -->
<script setup>
import { ref } from 'vue'
import { RouterView } from 'vue-router'

const abaAtiva = ref('admin-eventos')
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Administração</h1>
    <v-tabs v-model="abaAtiva" class="mb-4">
      <v-tab value="admin-home" :to="{ name: 'admin-home' }">Painel</v-tab>
      <v-tab value="admin-eventos" :to="{ name: 'admin-eventos' }">Eventos</v-tab>
    </v-tabs>
    <RouterView />
  </v-container>
</template>

Passo 10 — criar AdminHomeView.vue e AdminEventosView.vue

Vue SFC
<!-- src/views/admin/AdminHomeView.vue -->
<script setup>
import { eventos } from '../../data/eventos'
</script>

<template>
  <v-row>
    <v-col cols="12" sm="4">
      <v-card class="pa-4 text-center">
        <div class="text-h3 text-primary">{{ eventos.length }}</div>
        <div>eventos cadastrados</div>
      </v-card>
    </v-col>
  </v-row>
</template>
Vue SFC
<!-- src/views/admin/AdminEventosView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { eventos } from '../../data/eventos'
import DialogoConfirmacao from '../../components/DialogoConfirmacao.vue'

const router = useRouter()
const dialogoAberto = ref(false)
const eventoParaExcluir = ref(null)

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Vagas', key: 'vagas' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

const busca = ref('')

function pedirExclusao(evento) {
  eventoParaExcluir.value = evento
  dialogoAberto.value = true
}

function confirmarExclusao() {
  const indice = eventos.findIndex((e) => e.id === eventoParaExcluir.value.id)
  if (indice !== -1) eventos.splice(indice, 1)
  eventoParaExcluir.value = null
}
</script>

<template>
  <div>
    <div class="d-flex justify-space-between align-center mb-4">
      <v-text-field
        v-model="busca"
        label="Buscar"
        prepend-inner-icon="mdi-magnify"
        density="compact"
        style="max-width: 300px"
      />
      <v-btn color="primary" prepend-icon="mdi-plus" :to="{ name: 'admin-evento-novo' }">
        Novo evento
      </v-btn>
    </div>

    <v-data-table :headers="cabecalhos" :items="eventos" :search="busca" items-per-page="5">
      <template #item.acoes="{ item }">
        <v-btn
          icon="mdi-pencil"
          size="small"
          variant="text"
          :to="{ name: 'admin-evento-editar', params: { id: item.id } }"
        />
        <v-btn
          icon="mdi-delete"
          size="small"
          variant="text"
          color="error"
          @click="pedirExclusao(item)"
        />
      </template>
    </v-data-table>

    <DialogoConfirmacao
      v-model="dialogoAberto"
      titulo="Excluir evento"
      :mensagem="`Excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
      @confirmar="confirmarExclusao"
    />
  </div>
</template>

Passo 11 — criar AdminEventoFormView.vue com validação

Vue SFC
<!-- src/views/admin/AdminEventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { eventos } from '../../data/eventos'

const rota = useRoute()
const router = useRouter()

const modoEdicao = computed(() => rota.name === 'admin-evento-editar')
const formRef = ref(null)
const formularioAlterado = ref(false)

const titulo = ref('')
const descricao = ref('')
const categoria = ref('palestra')
const local = ref('')
const vagas = ref(null)

const categorias = ['palestra', 'minicurso', 'workshop']

const regrasTitulo = [
  (v) => !!v || 'O título é obrigatório',
  (v) => (v && v.length >= 5) || 'Mínimo de 5 caracteres',
]
const regrasLocal = [(v) => !!v || 'O local é obrigatório']
const regrasVagas = [
  (v) => !!v || 'Informe o número de vagas',
  (v) => v > 0 || 'Deve ser maior que zero',
]

onMounted(() => {
  if (modoEdicao.value) {
    const evento = eventos.find((e) => e.id === Number(rota.params.id))
    if (evento) {
      titulo.value = evento.titulo
      descricao.value = evento.descricao
      categoria.value = evento.categoria
      local.value = evento.local
      vagas.value = evento.vagas
    }
  }
})

onBeforeRouteLeave(() => {
  if (formularioAlterado.value) {
    const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
    if (!confirmar) return false
  }
})

async function salvar() {
  const { valid } = await formRef.value.validate()
  if (!valid) return

  if (modoEdicao.value) {
    const evento = eventos.find((e) => e.id === Number(rota.params.id))
    Object.assign(evento, {
      titulo: titulo.value,
      descricao: descricao.value,
      categoria: categoria.value,
      local: local.value,
      vagas: vagas.value,
    })
  } else {
    const novoId = Math.max(...eventos.map((e) => e.id)) + 1
    eventos.push({
      id: novoId,
      titulo: titulo.value,
      descricao: descricao.value,
      categoria: categoria.value,
      local: local.value,
      vagas: vagas.value,
      dataHora: new Date().toISOString(),
      imagemUrl: `https://picsum.photos/seed/evento${novoId}/600/300`,
    })
  }

  formularioAlterado.value = false
  router.push({ name: 'admin-eventos' })
}
</script>

<template>
  <v-card class="pa-4">
    <v-card-title>{{ modoEdicao ? 'Editar evento' : 'Novo evento' }}</v-card-title>
    <v-card-text>
      <v-form ref="formRef" @submit.prevent="salvar" @update:model-value="formularioAlterado = true">
        <v-text-field v-model="titulo" label="Título" :rules="regrasTitulo" class="mb-2" />
        <v-textarea v-model="descricao" label="Descrição" rows="3" class="mb-2" />
        <v-select v-model="categoria" :items="categorias" label="Categoria" class="mb-2" />
        <v-text-field v-model="local" label="Local" :rules="regrasLocal" class="mb-2" />
        <v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" class="mb-4" />
        <v-btn type="submit" color="primary" variant="flat">Salvar</v-btn>
        <v-btn variant="text" class="ml-2" :to="{ name: 'admin-eventos' }">Cancelar</v-btn>
      </v-form>
    </v-card-text>
  </v-card>
</template>

🧪 Laboratório

1. CartaoBase com slots nomeados Crie o componente src/components/CartaoBase.vue com slots titulo, padrão e acoes (como na §2), e use-o para reescrever a tela SobreView.vue.

Dica

<template #titulo>, conteúdo solto (sem <template>) cai no slot padrão, <template #acoes>.

2. Composable useAlternanciaTema Extraia a lógica de alternarTema/ehEscuro do CabecalhoApp.vue para um composable src/composables/useAlternanciaTema.js, e use-o também em uma nova tela de configurações.

Dica

O composable recebe useTheme() internamente e retorna { ehEscuro, alternarTema }.

3. Guard de confirmação no formulário de novo evento No AdminEventoFormView.vue, o onBeforeRouteLeave já existe, mas formularioAlterado nunca vira true ao digitar em campos que não passam por @update:model-value do form (ex.: se o navegador não disparar esse evento para todo campo). Ajuste para marcar formularioAlterado.value = true de forma confiável usando watch sobre os campos do formulário.

Dica

watch([titulo, descricao, categoria, local, vagas], () => { formularioAlterado.value = true }).

4. Query string de paginação Adicione um v-pagination na AdminEventosView.vue (fora do v-data-table, como exercício) e sincronize a página atual com ?pagina=N na URL, seguindo o padrão da §4.

Dica

ref(Number(rota.query.pagina) || 1) mais um watch que chama router.push({ query: { ...rota.query, pagina } }).

5. v-menu de ações rápidas no EventoCard Adicione um v-menu com um botão de três pontinhos no EventoCard, com opções "Compartilhar" e "Favoritar", que emitem eventos compartilhar e favoritar para o componente pai.

Dica

Use o slot #activator="{ props }" do v-menu, como no exemplo da §5.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
v-model em componente customizado não atualiza nada Componente não implementa defineModel() nem o par modelValue/update:modelValue Adicione defineModel() (ou a forma clássica) no componente filho
Prop chega como undefined mesmo passando valor no pai Nome da prop em kebab-case no template não bate com o nome em camelCase no defineProps Vue converte automaticamente total-eventostotalEventos — confira grafia exata dos dois lados
formRef.value.validate() lança erro "Cannot read properties of null" O ref="formRef" não está associado a um <v-form> ainda montado (chamado antes do onMounted) Garanta que a chamada acontece após o componente montar, tipicamente dentro de um handler de clique
No v-slot="{ isValid }" do v-form, isValid.value é undefined Código copiado do Vuetify 3 — variáveis de slot não são mais refs no Vuetify 4 Use isValid diretamente, sem .value
RouterView da rota aninhada nunca renderiza Falta um <RouterView /> dentro do componente de layout (AdminLayoutView.vue) Toda rota com children precisa de um RouterView próprio no componente pai da rota
onBeforeRouteLeave não é chamado Guard declarado fora de um componente renderizado pela rota (ex.: em um componente filho que não é o componente-alvo da rota) onBeforeRouteLeave só funciona dentro do componente que a rota renderiza diretamente
Slot com escopo não recebe os dados esperados Faltou vincular os dados no <slot> do componente filho (:evento="evento") Toda variável que o slot precisa expor deve ser passada como atributo do <slot>

🏠 Atividade assíncrona (1 h)

No seu projeto autoral:

  1. Extraia pelo menos um componente reutilizável de apresentação (equivalente ao EventoCard), com defineProps tipado e ao menos um evento emitido.
  2. Crie um componente com slot nomeado (equivalente ao CartaoBase) e use-o em pelo menos duas telas diferentes.
  3. Extraia a lógica de carregamento/filtro de dados para um composable use*.
  4. Adicione uma área com rotas aninhadas (ex.: painel administrativo do seu domínio) com pelo menos duas rotas-filhas.
  5. Crie um formulário de cadastro/edição com v-form e rules para pelo menos dois campos.

Critério de pronto: o formulário não deixa salvar com campos inválidos; a navegação entre rotas aninhadas funciona sem recarregar a página; pelo menos um componente usa slot nomeado com sucesso. Suba o commit no repositório.

✅ Checkpoint do projeto autoral

  • [ ] Pelo menos 3 componentes de apresentação extraídos, com defineProps tipado.
  • [ ] Pelo menos um componente usando v-model customizado (defineModel() ou forma clássica).
  • [ ] Pelo menos um componente com slot nomeado ou com escopo.
  • [ ] Um composable use* extraindo lógica de dados/filtro.
  • [ ] Rotas aninhadas funcionando em pelo menos uma seção da aplicação.
  • [ ] Formulário com v-form e rules de validação.
  • [ ] v-data-table (ou lista equivalente) listando os dados do domínio com busca.

📚 Para aprofundar

Na Aula 06 o UniEventos passa a consumir dados de uma API de verdade com Axios, organizados em uma camada de serviços — e o estado de eventos e inscrições migra para Pinia, substituindo os refs locais que temos usado até aqui.

🎯 Objetivos de aprendizagem📋 Pré-requisitos desta aula🗺️ Roteiro1. Componentização a sérioGranularidade: quando quebrar em componenteComponente burro vs. componente inteligentedefineProps com contrato completodefineEmits e comunicação filho → paiv-model em componente customizadoprovide/inject para dados profundosAtributos fallthrough e inheritAttrs2. Slots: componentes de layout reutilizáveisSlot padrãoSlots nomeadosSlots com escopo (scoped slots)3. Composables: extraindo lógica reutilizávelPor que composables substituem mixins4. Vue Router avançadoRotas aninhadas (children)meta em rotasNavigation guardsQuery strings sincronizadas com filtrosScroll behaviorLayouts diferentes por rota5. Vuetify aprofundadov-form com validação por regrasv-data-tablev-dialog de confirmação, v-tabs, v-menu, v-skeleton-loader, v-pagination🧩 Padrão de projeto em uso🧩 Padrão de projeto em uso — Composite e Template Method💻 Mão na massa — refatorando o UniEventos em componentesPasso 1 — criar EventoCard.vuePasso 2 — criar EventoLista.vuePasso 3 — criar FiltroEventos.vue com v-model duploPasso 4 — criar CabecalhoApp.vue e RodapeApp.vuePasso 5 — criar DialogoConfirmacao.vuePasso 6 — atualizar App.vue para usar CabecalhoApp e RodapeAppPasso 7 — reescrever HomeView.vue usando os componentes e o composablePasso 8 — criar rotas administrativas aninhadasPasso 9 — criar AdminLayoutView.vuePasso 10 — criar AdminHomeView.vue e AdminEventosView.vuePasso 11 — criar AdminEventoFormView.vue com validação🧪 Laboratório🐛 Erros comuns e como resolver🏠 Atividade assíncrona (1 h)✅ Checkpoint do projeto autoral📚 Para aprofundar
Nível 3Unidade 2 · Vue.js avançado: Vuetify, Axios, Router e Pinia3 aulas de 50 min + 1 h EAD

Aula 06 — Axios e Pinia

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Explicar o protocolo HTTP na prática: métodos, os status codes que realmente importam no dia a dia e por que o CORS existe.
  • Configurar uma instância dedicada do Axios (axios.create) com interceptors de request e response, e tratar erros distinguindo error.response de error.request.
  • Cancelar requisições com AbortController e enviar arquivos com FormData.
  • Organizar chamadas HTTP em uma camada de serviços, mantendo os componentes livres de detalhes de rede.
  • Subir uma API falsa com json-server para desenvolvimento e testes.
  • Criar stores Pinia no estilo setup store, com storeToRefs, ações assíncronas, $reset, $patch, $subscribe e persistência em localStorage.
  • Conectar o UniEventos a uma API real via camada de serviços e stores, com feedback visual de carregamento, erro e sucesso.

📋 Pré-requisitos desta aula

  • [ ] UniEventos da Aula 05 com componentes extraídos, composable useEventos, rotas aninhadas e formulário validado funcionando.
  • [ ] Node.js 22.22.2 e npm 10.9.7 instalados (node -v, npm -v).
  • [ ] Terminal disponível para rodar dois processos simultâneos (API falsa + app Vue).

Na Aula 05 você quebrou o UniEventos em componentes e extraiu a lógica de dados para o composable useEventos. Esse composable ainda trabalha com um array estático importado de src/data/eventos.js. Hoje esse array vira uma API de verdade, e o estado que hoje vive em refs locais migra para stores Pinia — compartilhadas por toda a aplicação.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min HTTP na prática, CORS, Axios: instância dedicada, interceptors, tratamento de erro
2 50 min Camada de serviços, json-server, Pinia: setup store, storeToRefs, ações assíncronas, persistência
3 50 min Mão na massa: UniEventos consumindo API real via serviços + stores

1. HTTP na prática

Você já usou fetch na Aula 03. Antes de trocar por Axios, vale consolidar o que realmente importa saber sobre HTTP para trabalhar com APIs no dia a dia.

Métodos

Método Uso típico
GET ler um recurso (lista de eventos, um evento específico)
POST criar um recurso novo
PUT substituir um recurso inteiro
PATCH atualizar parcialmente um recurso
DELETE remover um recurso

Status codes que importam

Não é preciso decorar os ~60 códigos HTTP — só os que aparecem o tempo todo:

Código Significado Quando aparece
200 OK sucesso, resposta com corpo GET, PUT, PATCH bem-sucedidos
201 Created recurso criado POST bem-sucedido
204 No Content sucesso, sem corpo de resposta DELETE bem-sucedido
400 Bad Request requisição malformada corpo JSON inválido, campo faltando
401 Unauthorized não autenticado token ausente ou inválido
403 Forbidden autenticado, mas sem permissão usuário comum tentando ação de admin
404 Not Found recurso não existe ID inexistente na URL
409 Conflict conflito de estado tentar criar um recurso duplicado
422 Unprocessable Entity validação de negócio falhou e-mail em formato inválido, vagas negativas
500 Internal Server Error erro não tratado no servidor bug no back-end

📌 Na prova: a diferença entre 400 e 422 é sutil, mas cai em prova: 400 é sobre a forma da requisição (JSON quebrado, tipo errado); 422 é sobre o conteúdo semanticamente inválido de uma requisição bem formada (ex.: vagas: -5).

Headers e JSON

Toda requisição e resposta HTTP carrega headers — metadados como Content-Type: application/json (informa que o corpo é JSON) e Authorization: Bearer <token> (credencial de autenticação). O corpo em si, na grande maioria das APIs modernas, é um texto no formato JSON — o mesmo JSON.stringify/JSON.parse que você já usa em JavaScript puro.

CORS explicado de verdade

CORS (Cross-Origin Resource Sharing) é uma política de segurança do navegador, não do servidor. Quando sua aplicação Vue, servida em http://localhost:5173, faz uma requisição para uma API em http://localhost:3000, o navegador considera isso uma requisição cross-origin (origens diferentes: porta diferente já conta como origem diferente, mesmo com o mesmo domínio localhost).

Por padrão, o navegador bloqueia a leitura da resposta de uma requisição cross-origin, a menos que o servidor responda explicitamente autorizando aquela origem, através do header Access-Control-Allow-Origin. Isso existe para impedir que um site malicioso, rodando no seu navegador enquanto você está autenticado em outro site (ex.: seu banco), faça requisições silenciosas para esse outro site usando suas credenciais de sessão sem seu conhecimento.

Para requisições "simples" (GET/POST com Content-Type comum), o navegador já bloqueia a leitura da resposta se o header de autorização não vier certo. Para requisições consideradas "não simples" — como PUT, DELETE, ou POST com Content-Type: application/json combinado com headers customizados — o navegador primeiro envia uma requisição OPTIONS chamada preflight, perguntando ao servidor "você aceita esse tipo de requisição desta origem, com estes headers?". Só se o servidor responder afirmativamente ao preflight é que o navegador envia a requisição real.

⚠️ Atenção: CORS é responsabilidade do servidor resolver (autorizando origens), não do front-end. Se você está desenvolvendo e vê um erro de CORS no console, a correção não é "tentar outra sintaxe no Axios" — é configurar o servidor para responder com os headers corretos. Vamos configurar isso na prática quando construirmos a API Express, na Unidade 3 (Aula 07 em diante). Por hoje, o json-server que vamos usar já vem com CORS liberado por padrão.

2. Axios: por que uma biblioteca além do fetch

fetch é nativo do navegador e funciona bem para casos simples — foi o suficiente até a Aula 03. Mas em uma aplicação real, algumas limitações do fetch pesam:

Recurso fetch Axios
Corpo da resposta já convertido em JSON precisa de .json() manual response.data já vem pronto
Erros HTTP (4xx/5xx) não rejeitam a Promise automaticamente rejeitam a Promise automaticamente
Timeout de requisição precisa implementar manualmente com AbortController prop timeout pronta
Interceptors (request/response) não existe nativamente suportado nativamente
Instância com configuração padrão (baseURL, headers) precisa reimplementar um wrapper axios.create({...}) pronto
Cancelamento AbortController AbortController (compatível)

O ponto mais importante da tabela é o segundo: com fetch, uma resposta 404 ou 500 não faz a Promise falhar — você precisa checar response.ok manualmente. Isso é uma fonte comum de bugs silenciosos. Com Axios, qualquer status fora da faixa 2xx já cai automaticamente no catch.

Instalação

Terminal
npm install axios

Versão usada nesta disciplina: axios 1.19.0.

Instância dedicada

⚠️ Atenção: nunca use o axios importado diretamente (import axios from 'axios') espalhado pelos componentes. Sempre crie uma instância dedicada, configurada uma única vez, e reutilize-a em toda a aplicação.

JavaScript
// src/services/http.js
import axios from 'axios'

const http = axios.create({
  baseURL: 'http://localhost:3000',
  timeout: 8000,
  headers: {
    'Content-Type': 'application/json',
  },
})

export default http

Isso centraliza baseURL (endereço da API), timeout (tempo máximo de espera antes de desistir da requisição) e headers padrão em um único lugar — trocar de ambiente (desenvolvimento → produção) vira uma alteração em um arquivo só.

Interceptor de request — injetar token

Um interceptor é uma função que roda automaticamente antes de cada requisição sair (interceptor de request) ou antes de cada resposta chegar ao código que a chamou (interceptor de response):

JavaScript
// src/services/http.js (trecho — adicionar após criar a instância)
http.interceptors.request.use((config) => {
  const token = localStorage.getItem('uniEventosToken')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

Com isso, nenhum componente ou serviço precisa se lembrar de anexar o token manualmente — toda requisição feita através de http já sai com o header Authorization quando há um token salvo. Vamos usar esse mecanismo de verdade na Unidade 3, quando implementarmos login com Firebase.

Interceptor de response — tratar 401 e normalizar erros

JavaScript
// src/services/http.js (trecho — adicionar após o interceptor de request)
http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('uniEventosToken')
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

Esse interceptor de response roda para toda resposta com erro, em qualquer lugar da aplicação: se o servidor responder 401 (token expirado ou inválido), o interceptor limpa o token salvo e redireciona para o login — sem que cada chamada de API precise repetir essa lógica.

Tratamento de erro: error.response vs. error.request

Ao capturar um erro do Axios, existem três cenários possíveis, e cada um exige um tratamento diferente:

JavaScript
try {
  const resposta = await http.get('/eventos')
  console.log(resposta.data)
} catch (erro) {
  if (erro.response) {
    // o servidor respondeu, mas com status de erro (4xx, 5xx)
    console.error('Erro do servidor:', erro.response.status, erro.response.data)
  } else if (erro.request) {
    // a requisição foi enviada, mas nenhuma resposta chegou
    // (servidor fora do ar, sem rede, CORS bloqueando)
    console.error('Sem resposta do servidor:', erro.request)
  } else {
    // erro ao montar a própria requisição (configuração inválida, etc.)
    console.error('Erro ao configurar a requisição:', erro.message)
  }
}

Essa distinção importa na prática: um erro.response com 404 deve mostrar "evento não encontrado"; um erro.request (sem resposta nenhuma) deve mostrar "não foi possível conectar ao servidor — verifique sua internet".

Cancelamento com AbortController

Em telas com busca "ao digitar" (busca incremental), cada tecla pode disparar uma nova requisição antes da anterior terminar — sem cancelamento, respostas antigas podem chegar depois das novas e sobrescrever dados mais recentes na tela.

JavaScript
let controlador = null

async function buscar(termo) {
  if (controlador) controlador.abort() // cancela a busca anterior, se existir
  controlador = new AbortController()

  try {
    const resposta = await http.get('/eventos', {
      params: { titulo_like: termo },
      signal: controlador.signal,
    })
    return resposta.data
  } catch (erro) {
    if (axios.isCancel(erro) || erro.code === 'ERR_CANCELED') {
      return [] // busca cancelada, não é um erro de verdade
    }
    throw erro
  }
}

Upload com FormData

Quando o UniEventos precisar permitir upload de uma imagem de evento (em vez de só uma URL), o corpo da requisição deixa de ser JSON e passa a ser multipart/form-data, construído com FormData:

JavaScript
async function enviarImagem(arquivo) {
  const dados = new FormData()
  dados.append('imagem', arquivo)

  const resposta = await http.post('/upload', dados, {
    headers: { 'Content-Type': 'multipart/form-data' },
  })
  return resposta.data.url
}

FormData é uma API nativa do navegador (não específica do Axios) para montar corpos de requisição no formato usado tradicionalmente por formulários HTML com arquivos.

3. Camada de serviços

Um erro comum é chamar http.get(...) diretamente dentro de um componente .vue. Isso mistura duas responsabilidades que deveriam ser independentes: como a tela se comporta e como os dados são buscados. Se a API mudar (endpoint renomeado, formato de resposta diferente), você teria que caçar cada componente que faz chamadas HTTP.

A solução é uma camada de serviços: um módulo por recurso, que expõe funções com nomes de negócio (listar, criar, remover) e esconde os detalhes de URL, método HTTP e formato de payload.

JavaScript
// src/services/eventosService.js
import http from './http'

export default {
  async listar(filtros = {}) {
    const resposta = await http.get('/eventos', { params: filtros })
    return resposta.data
  },

  async buscarPorId(id) {
    const resposta = await http.get(`/eventos/${id}`)
    return resposta.data
  },

  async criar(evento) {
    const resposta = await http.post('/eventos', evento)
    return resposta.data
  },

  async atualizar(id, evento) {
    const resposta = await http.put(`/eventos/${id}`, evento)
    return resposta.data
  },

  async remover(id) {
    await http.delete(`/eventos/${id}`)
  },
}

Um componente (ou, como veremos, uma store) usa isso assim:

JavaScript
import eventosService from '../services/eventosService'

const eventos = await eventosService.listar({ categoria: 'palestra' })

Por que os componentes não devem chamar Axios diretamente:

  1. Testabilidade — testar um serviço isolado (mockando http) é muito mais simples do que testar um componente inteiro só para validar uma chamada de API.
  2. Reuso — a mesma função eventosService.listar() é chamada pela HomeView, pela busca administrativa e por um composable, sem repetir a URL em três lugares.
  3. Um ponto único de mudança — se o endpoint /eventos virar /api/v1/eventos, você edita um arquivo, não uma dúzia de componentes.
  4. Separação de camadas — é o mesmo princípio de "não misturar HTML com lógica de banco de dados" que você vai aplicar no back-end, na Unidade 3.

4. API falsa para a aula: json-server

Antes de existir um back-end real (isso vem na Unidade 3, com Express), usamos o json-server: uma ferramenta que transforma um arquivo JSON em uma API REST completa, com poucos minutos de configuração.

Criando o db.json do UniEventos

JSON
// db.json
{
  "eventos": [
    { "id": 1, "titulo": "Semana Acadêmica de Computação", "descricao": "Palestras e minicursos sobre tendências em tecnologia.", "categoria": "palestra", "dataHora": "2026-09-29T19:00:00", "local": "Auditório Central", "vagas": 40, "imagemUrl": "https://picsum.photos/seed/evento1/600/300" },
    { "id": 2, "titulo": "Minicurso de Vue.js Avançado", "descricao": "Componentização, roteamento e gerenciamento de estado.", "categoria": "minicurso", "dataHora": "2026-09-15T18:30:00", "local": "Laboratório 3", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento2/600/300" },
    { "id": 3, "titulo": "Workshop de Prototipação em Figma", "descricao": "Fundamentos de design de interfaces para desenvolvedores.", "categoria": "workshop", "dataHora": "2026-09-20T14:00:00", "local": "Sala 12", "vagas": 30, "imagemUrl": "https://picsum.photos/seed/evento3/600/300" },
    { "id": 4, "titulo": "Palestra: Carreira em Dados", "descricao": "Trilhas profissionais em ciência e engenharia de dados.", "categoria": "palestra", "dataHora": "2026-10-02T19:30:00", "local": "Auditório Central", "vagas": 50, "imagemUrl": "https://picsum.photos/seed/evento4/600/300" },
    { "id": 5, "titulo": "Minicurso de Banco de Dados NoSQL", "descricao": "Modelagem de dados com MongoDB na prática.", "categoria": "minicurso", "dataHora": "2026-09-22T18:30:00", "local": "Laboratório 2", "vagas": 20, "imagemUrl": "https://picsum.photos/seed/evento5/600/300" },
    { "id": 6, "titulo": "Workshop de Testes Automatizados", "descricao": "Testes unitários e de integração em aplicações web.", "categoria": "workshop", "dataHora": "2026-10-05T14:00:00", "local": "Sala 12", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento6/600/300" },
    { "id": 7, "titulo": "Palestra: Ética em Inteligência Artificial", "descricao": "Discussão sobre vieses e responsabilidade em sistemas de IA.", "categoria": "palestra", "dataHora": "2026-10-10T19:00:00", "local": "Auditório Central", "vagas": 60, "imagemUrl": "https://picsum.photos/seed/evento7/600/300" },
    { "id": 8, "titulo": "Minicurso de Node.js e Express", "descricao": "Construindo APIs REST do zero.", "categoria": "minicurso", "dataHora": "2026-09-25T18:30:00", "local": "Laboratório 1", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento8/600/300" }
  ],
  "inscricoes": []
}

Rodando o servidor

Terminal
npx json-server --watch db.json --port 3000

Isso sobe uma API completa em http://localhost:3000, com:

  • GET /eventos — lista todos os eventos.
  • GET /eventos/3 — retorna o evento com id: 3, ou 404 se não existir.
  • GET /eventos?categoria=palestra — filtro por campo exato.
  • GET /eventos?titulo_like=vue — busca parcial, sem diferenciar maiúsculas/minúsculas.
  • POST /eventos — cria um evento novo (retorna 201).
  • PUT /eventos/3 — substitui o evento 3 inteiro.
  • PATCH /eventos/3 — atualiza campos específicos do evento 3.
  • DELETE /eventos/3 — remove o evento 3 (retorna 200 com corpo vazio no json-server).

O --watch faz o json-server recarregar automaticamente sempre que db.json é editado manualmente — útil para resetar o estado de teste durante a aula.

💡 Dica: rode o json-server e o npm run dev do Vite em dois terminais separados. Nenhum dos dois substitui o outro — um serve a API, o outro serve a aplicação Vue.

🔎 Por baixo do capô: o json-server não é o que você vai construir de verdade. Ele existe para permitir treinar consumo de API antes de saber construir uma. Na Unidade 3 (Aulas 07–08), você vai construir a API real do UniEventos com Express, replicando esses mesmos endpoints — e aí vai entender por dentro o que o json-server faz por baixo dos panos.

5. Pinia: estado compartilhado de verdade

O problema do prop drilling e do estado espalhado

Na Aula 05, você usou provide/inject para dados amplamente compartilhados, e o composable useEventos para lógica reutilizável. Mas o composable tem uma limitação: cada componente que o chama recebe seu próprio estado isolado. Se a HomeView e o CabecalhoApp chamarem useEventos() separadamente, cada um tem sua própria cópia da lista de eventos — atualizar uma não atualiza a outra.

Para estado que precisa ser verdadeiramente compartilhado — a lista de eventos carregada uma vez e usada em várias telas, as inscrições do usuário, o carrinho de um e-commerce —, a resposta é uma store: um único objeto reativo, acessível de qualquer componente, sem precisar passar por props em cada nível da árvore.

Pinia: createPinia

O Pinia já vem instalado e registrado se você criou o projeto com a flag --pinia (como recomenda a §4 da especificação):

JavaScript
// src/main.js (trecho, já presente no scaffold)
import { createPinia } from 'pinia'
// ...
app.use(createPinia())

defineStore: dois estilos

Pinia suporta dois estilos de declaração de store. Esta disciplina usa o setup store — mas você precisa reconhecer os dois, porque o estilo options ainda aparece bastante em projetos e tutoriais existentes.

Options store (parecido com a Options API do Vue 2):

JavaScript
// exemplo — NÃO é o estilo usado nesta disciplina, mas você deve reconhecê-lo
import { defineStore } from 'pinia'

export const useContadorStore = defineStore('contador', {
  state: () => ({ valor: 0 }),
  getters: {
    dobro: (state) => state.valor * 2,
  },
  actions: {
    incrementar() {
      this.valor++
    },
  },
})

Setup store (usa a Composition API — ref, computed, funções comuns):

JavaScript
// src/stores/contadorStore.js — estilo usado nesta disciplina
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useContadorStore = defineStore('contador', () => {
  const valor = ref(0)
  const dobro = computed(() => valor.value * 2)

  function incrementar() {
    valor.value++
  }

  return { valor, dobro, incrementar }
})

No setup store: ref vira state, computed vira getter, função comum vira action — e você retorna explicitamente tudo que deve ficar público. É o mesmo modelo mental que você já usa em <script setup> e em composables, o que reduz a curva de aprendizado: uma store é, na prática, um composable que vive fora de qualquer componente e é compartilhado por todos eles.

📌 Na prova: se te perguntarem a diferença entre uma store setup e um composable comum, a resposta central é: uma store é um singleton (uma instância única compartilhada por toda a aplicação, gerenciada pelo Pinia); um composable comum cria estado novo a cada chamada. Veja o box de padrões de projeto logo abaixo.

storeToRefs — por que desestruturar direto quebra a reatividade

JavaScript
import { useEventosStore } from '../stores/eventosStore'

const store = useEventosStore()

// ERRADO — quebra a reatividade
const { eventos, carregando } = store

Desestruturar propriedades reativas diretamente de store quebra a reatividade: eventos e carregando viram cópias estáticas do valor no momento da desestruturação, desconectadas da store. Se a store atualizar depois, essas variáveis locais não acompanham.

JavaScript
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'

const store = useEventosStore()
const { eventos, carregando } = storeToRefs(store) // CORRETO — mantém reatividade

// ações continuam sendo chamadas direto da store, sem storeToRefs
store.carregarEventos()

storeToRefs converte cada propriedade de state/getter em um ref reativo de verdade, ligado à store original. Ações (funções) não precisam desse tratamento — elas não são reativas, só são chamadas — então continuam sendo acessadas direto de store.nomeDaAcao().

🔎 Por baixo do capô: isso acontece pela mesma razão pela qual desestruturar um reactive() comum quebra a reatividade (você viu isso na Aula 03, ao estudar reactive vs. ref): a store internamente é um objeto reactive, e desestruturar um reactive extrai o valor primitivo naquele instante, perdendo o Proxy que rastreia mudanças. storeToRefs contorna isso criando um ref para cada propriedade, que continua "ligado" ao Proxy original.

Store completa: eventosStore.js

JavaScript
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import eventosService from '../services/eventosService'

export const useEventosStore = defineStore('eventos', () => {
  const eventos = ref([])
  const carregando = ref(false)
  const erro = ref(null)
  const categoriaFiltro = ref('Todas')
  const busca = ref('')

  const eventosFiltrados = computed(() => {
    return eventos.value.filter((evento) => {
      const bateCategoria =
        categoriaFiltro.value === 'Todas' ||
        evento.categoria === categoriaFiltro.value.toLowerCase()
      const bateBusca = evento.titulo
        .toLowerCase()
        .includes(busca.value.toLowerCase())
      return bateCategoria && bateBusca
    })
  })

  async function carregarEventos() {
    carregando.value = true
    erro.value = null
    try {
      eventos.value = await eventosService.listar()
    } catch (e) {
      erro.value = 'Não foi possível carregar os eventos. Tente novamente.'
    } finally {
      carregando.value = false
    }
  }

  async function removerEvento(id) {
    await eventosService.remover(id)
    eventos.value = eventos.value.filter((e) => e.id !== id)
  }

  async function salvarEvento(dadosEvento) {
    if (dadosEvento.id) {
      const atualizado = await eventosService.atualizar(dadosEvento.id, dadosEvento)
      const indice = eventos.value.findIndex((e) => e.id === dadosEvento.id)
      if (indice !== -1) eventos.value[indice] = atualizado
    } else {
      const criado = await eventosService.criar(dadosEvento)
      eventos.value.push(criado)
    }
  }

  function $reset() {
    eventos.value = []
    carregando.value = false
    erro.value = null
    categoriaFiltro.value = 'Todas'
    busca.value = ''
  }

  return {
    eventos,
    carregando,
    erro,
    categoriaFiltro,
    busca,
    eventosFiltrados,
    carregarEventos,
    removerEvento,
    salvarEvento,
    $reset,
  }
})

Ações assíncronas

Repare que carregarEventos, removerEvento e salvarEvento são funções async comuns — Pinia não exige nenhuma sintaxe especial para ações assíncronas. O padrão carregando/erro como state da própria store (em vez de refs locais em cada componente) é o que permite que qualquer tela mostre o estado de carregamento correto, sem duplicar essa lógica.

$reset, $patch, $subscribe

JavaScript
const store = useEventosStore()

// $reset — no setup store, você define sua própria função $reset (como acima),
// pois o Pinia só gera $reset automaticamente para options stores
store.$reset()

// $patch — atualiza várias propriedades de uma vez, útil para mudanças em lote
store.$patch({ categoriaFiltro: 'Palestra', busca: '' })

// $patch também aceita uma função, útil quando a mudança depende do estado atual
store.$patch((state) => {
  state.eventos.push({ id: 99, titulo: 'Evento de teste' })
})

// $subscribe — reage a qualquer mudança de state da store (ótimo para persistência/log)
store.$subscribe((mutation, state) => {
  console.log('Store eventos mudou:', mutation.type, state)
})

⚠️ Atenção: em uma setup store, $reset() não é gerado automaticamente pelo Pinia (isso só acontece no estilo options store) — por isso a store acima define sua própria função $reset manualmente e a expõe no return. É um detalhe pequeno, mas comum de esquecer.

Composição de stores

Uma store pode usar outra dentro de si, exatamente como um composable usa outro:

JavaScript
// src/stores/inscricoesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useEventosStore } from './eventosStore'

export const useInscricoesStore = defineStore('inscricoes', () => {
  const eventosStore = useEventosStore()

  const idsInscritos = ref(
    JSON.parse(localStorage.getItem('uniEventosInscricoes') || '[]')
  )

  const eventosInscritos = computed(() =>
    eventosStore.eventos.filter((evento) => idsInscritos.value.includes(evento.id))
  )

  function inscrever(idEvento) {
    if (!idsInscritos.value.includes(idEvento)) {
      idsInscritos.value.push(idEvento)
      persistir()
    }
  }

  function cancelarInscricao(idEvento) {
    idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
    persistir()
  }

  function estaInscrito(idEvento) {
    return idsInscritos.value.includes(idEvento)
  }

  function persistir() {
    localStorage.setItem('uniEventosInscricoes', JSON.stringify(idsInscritos.value))
  }

  return {
    idsInscritos,
    eventosInscritos,
    inscrever,
    cancelarInscricao,
    estaInscrito,
  }
})

useInscricoesStore depende de useEventosStore para calcular eventosInscritos — uma composição direta, sem nenhuma cerimônia especial: dentro do setup store, você simplesmente chama useEventosStore() como chamaria em qualquer componente.

Persistência em localStorage

A inscricoesStore acima já persiste manualmente, chamando persistir() a cada mudança. Uma alternativa mais genérica é usar $subscribe para persistir qualquer mudança de state automaticamente, sem espalhar chamadas de localStorage.setItem pelas ações:

JavaScript
// src/stores/inscricoesStore.js (trecho — alternativa com $subscribe)
export const useInscricoesStore = defineStore('inscricoes', () => {
  const idsInscritos = ref(
    JSON.parse(localStorage.getItem('uniEventosInscricoes') || '[]')
  )

  function inscrever(idEvento) {
    if (!idsInscritos.value.includes(idEvento)) {
      idsInscritos.value.push(idEvento)
    }
  }

  function cancelarInscricao(idEvento) {
    idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
  }

  return { idsInscritos, inscrever, cancelarInscricao }
})
JavaScript
// src/main.js (trecho — assinatura global, fora da store)
import { useInscricoesStore } from './stores/inscricoesStore'

const inscricoesStore = useInscricoesStore()
inscricoesStore.$subscribe((mutation, state) => {
  localStorage.setItem('uniEventosInscricoes', JSON.stringify(state.idsInscritos))
})

Ambas as abordagens são válidas; a primeira (persistir dentro da própria ação) é mais explícita e fácil de acompanhar em uma disciplina introdutória — é a que vamos usar no Mão na massa.

Vue DevTools inspecionando a store

Instale a extensão Vue DevTools no navegador (ou use vite-plugin-vue-devtools, incluído por padrão em muitos scaffolds do create-vue). Na aba Pinia, você vê, em tempo real: todas as stores ativas, o state atual de cada uma, e um histórico de mutações — útil para depurar por que eventosFiltrados não está retornando o que você espera, sem precisar espalhar console.log pelo código.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Singleton e Decorator

Singleton (criacional): uma store Pinia é, por construção, uma instância única compartilhada. Não importa quantas vezes useEventosStore() seja chamado, em quantos componentes diferentes — todos recebem a mesma instância de store, gerenciada internamente pelo Pinia (identificada pelo primeiro argumento de defineStore, 'eventos'). Isso é exatamente o padrão Singleton: garantir que existe no máximo uma instância de um objeto, e fornecer um ponto de acesso global a ela. É a diferença estrutural entre uma store e um composable comum — o composable cria estado novo a cada chamada; a store sempre devolve a mesma instância.

Decorator (estrutural): os interceptors do Axios são um exemplo direto de Decorator. Cada interceptor "envolve" a requisição (ou resposta) original, adicionando comportamento sem alterar o código que originou a chamada — o interceptor de request adiciona o header Authorization; o interceptor de response adiciona tratamento de 401. O componente que chama http.get('/eventos') não sabe (nem precisa saber) que essas camadas extras existem — elas são "decoradas" por fora, de forma transparente.

💻 Mão na massa — UniEventos consumindo API real

Passo 1 — instalar Axios e criar db.json

Terminal
npm install axios

Crie db.json na raiz do projeto (conteúdo completo na §4 acima), e rode em um terminal separado:

Terminal
npx json-server --watch db.json --port 3000

Deixe esse terminal aberto durante toda a aula — é a "API" que o front vai consumir.

Passo 2 — criar a instância HTTP

JavaScript
// src/services/http.js
import axios from 'axios'

const http = axios.create({
  baseURL: 'http://localhost:3000',
  timeout: 8000,
  headers: {
    'Content-Type': 'application/json',
  },
})

http.interceptors.request.use((config) => {
  const token = localStorage.getItem('uniEventosToken')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

http.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('uniEventosToken')
    }
    return Promise.reject(error)
  }
)

export default http

Passo 3 — criar eventosService.js

JavaScript
// src/services/eventosService.js
import http from './http'

export default {
  async listar(filtros = {}) {
    const resposta = await http.get('/eventos', { params: filtros })
    return resposta.data
  },

  async buscarPorId(id) {
    const resposta = await http.get(`/eventos/${id}`)
    return resposta.data
  },

  async criar(evento) {
    const resposta = await http.post('/eventos', evento)
    return resposta.data
  },

  async atualizar(id, evento) {
    const resposta = await http.put(`/eventos/${id}`, evento)
    return resposta.data
  },

  async remover(id) {
    await http.delete(`/eventos/${id}`)
  },
}

Passo 4 — criar eventosStore.js

JavaScript
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import eventosService from '../services/eventosService'

export const useEventosStore = defineStore('eventos', () => {
  const eventos = ref([])
  const carregando = ref(false)
  const erro = ref(null)
  const categoriaFiltro = ref('Todas')
  const busca = ref('')

  const eventosFiltrados = computed(() => {
    return eventos.value.filter((evento) => {
      const bateCategoria =
        categoriaFiltro.value === 'Todas' ||
        evento.categoria === categoriaFiltro.value.toLowerCase()
      const bateBusca = evento.titulo
        .toLowerCase()
        .includes(busca.value.toLowerCase())
      return bateCategoria && bateBusca
    })
  })

  async function carregarEventos() {
    carregando.value = true
    erro.value = null
    try {
      eventos.value = await eventosService.listar()
    } catch (e) {
      erro.value = 'Não foi possível carregar os eventos. Verifique se o json-server está rodando.'
    } finally {
      carregando.value = false
    }
  }

  async function removerEvento(id) {
    await eventosService.remover(id)
    eventos.value = eventos.value.filter((e) => e.id !== id)
  }

  async function salvarEvento(dadosEvento) {
    if (dadosEvento.id) {
      const atualizado = await eventosService.atualizar(dadosEvento.id, dadosEvento)
      const indice = eventos.value.findIndex((e) => e.id === dadosEvento.id)
      if (indice !== -1) eventos.value[indice] = atualizado
    } else {
      const criado = await eventosService.criar(dadosEvento)
      eventos.value.push(criado)
    }
  }

  function $reset() {
    eventos.value = []
    carregando.value = false
    erro.value = null
    categoriaFiltro.value = 'Todas'
    busca.value = ''
  }

  return {
    eventos,
    carregando,
    erro,
    categoriaFiltro,
    busca,
    eventosFiltrados,
    carregarEventos,
    removerEvento,
    salvarEvento,
    $reset,
  }
})

Passo 5 — criar inscricoesStore.js

JavaScript
// src/stores/inscricoesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useEventosStore } from './eventosStore'

const CHAVE_LOCALSTORAGE = 'uniEventosInscricoes'

export const useInscricoesStore = defineStore('inscricoes', () => {
  const eventosStore = useEventosStore()

  const idsInscritos = ref(
    JSON.parse(localStorage.getItem(CHAVE_LOCALSTORAGE) || '[]')
  )

  const eventosInscritos = computed(() =>
    eventosStore.eventos.filter((evento) => idsInscritos.value.includes(evento.id))
  )

  function persistir() {
    localStorage.setItem(CHAVE_LOCALSTORAGE, JSON.stringify(idsInscritos.value))
  }

  function inscrever(idEvento) {
    if (!idsInscritos.value.includes(idEvento)) {
      idsInscritos.value.push(idEvento)
      persistir()
    }
  }

  function cancelarInscricao(idEvento) {
    idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
    persistir()
  }

  function estaInscrito(idEvento) {
    return idsInscritos.value.includes(idEvento)
  }

  return {
    idsInscritos,
    eventosInscritos,
    inscrever,
    cancelarInscricao,
    estaInscrito,
  }
})

Passo 6 — atualizar HomeView.vue para usar a store

Vue SFC
<!-- src/views/HomeView.vue -->
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'

const store = useEventosStore()
const { carregando, erro, categoriaFiltro, busca, eventosFiltrados } = storeToRefs(store)

onMounted(() => {
  store.carregarEventos()
})
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Eventos disponíveis</h1>

    <FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />

    <div v-if="carregando" class="d-flex justify-center pa-8">
      <v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
    </div>

    <v-alert v-else-if="erro" type="error" variant="tonal" title="Erro ao carregar eventos">
      {{ erro }}
    </v-alert>

    <v-alert
      v-else-if="eventosFiltrados.length === 0"
      type="info"
      variant="tonal"
      title="Nenhum evento encontrado"
    >
      Tente ajustar os filtros de categoria ou o termo de busca.
    </v-alert>

    <EventoLista v-else :eventos="eventosFiltrados" />
  </v-container>
</template>

Note os três estados de tela que você já pratica desde a Aula 03 (carregando / erro / vazio), agora alimentados pela store em vez de lógica local — e uma quarta condição implícita (dados carregados com sucesso), coberta pelo v-else final.

Passo 7 — feedback com v-snackbar na inscrição

Vue SFC
<!-- src/views/EventoDetalheView.vue -->
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
import { useInscricoesStore } from '../stores/inscricoesStore'

const rota = useRoute()
const router = useRouter()

const eventosStore = useEventosStore()
const { eventos } = storeToRefs(eventosStore)

const inscricoesStore = useInscricoesStore()

const snackbarAberto = ref(false)
const snackbarMensagem = ref('')
const snackbarCor = ref('success')

onMounted(() => {
  if (eventos.value.length === 0) {
    eventosStore.carregarEventos()
  }
})

const evento = computed(() =>
  eventos.value.find((e) => e.id === Number(rota.params.id))
)

const jaInscrito = computed(() =>
  evento.value ? inscricoesStore.estaInscrito(evento.value.id) : false
)

function formatarDataHora(dataIso) {
  return new Date(dataIso).toLocaleString('pt-BR', {
    dateStyle: 'long',
    timeStyle: 'short',
  })
}

function inscrever() {
  inscricoesStore.inscrever(evento.value.id)
  snackbarMensagem.value = 'Inscrição realizada com sucesso!'
  snackbarCor.value = 'success'
  snackbarAberto.value = true
}

function cancelarInscricao() {
  inscricoesStore.cancelarInscricao(evento.value.id)
  snackbarMensagem.value = 'Inscrição cancelada.'
  snackbarCor.value = 'warning'
  snackbarAberto.value = true
}

function voltar() {
  router.push({ name: 'home' })
}
</script>

<template>
  <v-container>
    <v-btn variant="text" prepend-icon="mdi-arrow-left" class="mb-4" @click="voltar">
      Voltar para eventos
    </v-btn>

    <v-alert v-if="!evento" type="error" variant="tonal" title="Evento não encontrado">
      Não existe evento com este identificador. Confira o link acessado.
    </v-alert>

    <v-card v-else>
      <v-img :src="evento.imagemUrl" height="280" cover />
      <v-card-title class="text-h5">{{ evento.titulo }}</v-card-title>
      <v-card-subtitle>
        <v-icon icon="mdi-calendar" size="small" class="mr-1" />
        {{ formatarDataHora(evento.dataHora) }}
      </v-card-subtitle>
      <v-card-text>
        <p class="mb-4">{{ evento.descricao }}</p>
        <div class="d-flex align-center mb-2">
          <v-icon icon="mdi-map-marker" class="mr-2" />
          <span>{{ evento.local }}</span>
        </div>
        <div class="d-flex align-center">
          <v-icon icon="mdi-account-group" class="mr-2" />
          <span>{{ evento.vagas }} vagas disponíveis</span>
        </div>
      </v-card-text>
      <v-card-actions>
        <v-chip color="secondary">{{ evento.categoria }}</v-chip>
        <v-spacer />
        <v-btn v-if="!jaInscrito" color="primary" variant="flat" @click="inscrever">
          Inscrever-se
        </v-btn>
        <v-btn v-else color="error" variant="outlined" @click="cancelarInscricao">
          Cancelar inscrição
        </v-btn>
      </v-card-actions>
    </v-card>

    <v-snackbar v-model="snackbarAberto" :color="snackbarCor" timeout="3000">
      {{ snackbarMensagem }}
    </v-snackbar>
  </v-container>
</template>

Passo 8 — atualizar AdminEventosView.vue e o formulário para usar a store

Vue SFC
<!-- src/views/admin/AdminEventosView.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../../stores/eventosStore'
import DialogoConfirmacao from '../../components/DialogoConfirmacao.vue'

const store = useEventosStore()
const { eventos, carregando, erro } = storeToRefs(store)

const dialogoAberto = ref(false)
const eventoParaExcluir = ref(null)
const busca = ref('')
const snackbarAberto = ref(false)

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Vagas', key: 'vagas' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

onMounted(() => {
  if (eventos.value.length === 0) store.carregarEventos()
})

function pedirExclusao(evento) {
  eventoParaExcluir.value = evento
  dialogoAberto.value = true
}

async function confirmarExclusao() {
  await store.removerEvento(eventoParaExcluir.value.id)
  eventoParaExcluir.value = null
  snackbarAberto.value = true
}
</script>

<template>
  <div>
    <div class="d-flex justify-space-between align-center mb-4">
      <v-text-field
        v-model="busca"
        label="Buscar"
        prepend-inner-icon="mdi-magnify"
        density="compact"
        style="max-width: 300px"
      />
      <v-btn color="primary" prepend-icon="mdi-plus" :to="{ name: 'admin-evento-novo' }">
        Novo evento
      </v-btn>
    </div>

    <v-alert v-if="erro" type="error" variant="tonal" class="mb-4">{{ erro }}</v-alert>

    <v-data-table
      :headers="cabecalhos"
      :items="eventos"
      :search="busca"
      :loading="carregando"
      items-per-page="5"
    >
      <template #item.acoes="{ item }">
        <v-btn
          icon="mdi-pencil"
          size="small"
          variant="text"
          :to="{ name: 'admin-evento-editar', params: { id: item.id } }"
        />
        <v-btn
          icon="mdi-delete"
          size="small"
          variant="text"
          color="error"
          @click="pedirExclusao(item)"
        />
      </template>
    </v-data-table>

    <DialogoConfirmacao
      v-model="dialogoAberto"
      titulo="Excluir evento"
      :mensagem="`Excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
      @confirmar="confirmarExclusao"
    />

    <v-snackbar v-model="snackbarAberto" color="success" timeout="3000">
      Evento excluído com sucesso.
    </v-snackbar>
  </div>
</template>
Vue SFC
<!-- src/views/admin/AdminEventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../../stores/eventosStore'

const rota = useRoute()
const router = useRouter()
const store = useEventosStore()
const { eventos } = storeToRefs(store)

const modoEdicao = computed(() => rota.name === 'admin-evento-editar')
const formRef = ref(null)
const formularioAlterado = ref(false)
const salvando = ref(false)
const erroSalvar = ref(null)

const titulo = ref('')
const descricao = ref('')
const categoria = ref('palestra')
const local = ref('')
const vagas = ref(null)

const categorias = ['palestra', 'minicurso', 'workshop']

const regrasTitulo = [
  (v) => !!v || 'O título é obrigatório',
  (v) => (v && v.length >= 5) || 'Mínimo de 5 caracteres',
]
const regrasLocal = [(v) => !!v || 'O local é obrigatório']
const regrasVagas = [
  (v) => !!v || 'Informe o número de vagas',
  (v) => v > 0 || 'Deve ser maior que zero',
]

onMounted(() => {
  if (modoEdicao.value) {
    const evento = eventos.value.find((e) => e.id === Number(rota.params.id))
    if (evento) {
      titulo.value = evento.titulo
      descricao.value = evento.descricao
      categoria.value = evento.categoria
      local.value = evento.local
      vagas.value = evento.vagas
    }
  }
})

onBeforeRouteLeave(() => {
  if (formularioAlterado.value) {
    const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
    if (!confirmar) return false
  }
})

async function salvar() {
  const { valid } = await formRef.value.validate()
  if (!valid) return

  salvando.value = true
  erroSalvar.value = null
  try {
    await store.salvarEvento({
      id: modoEdicao.value ? Number(rota.params.id) : undefined,
      titulo: titulo.value,
      descricao: descricao.value,
      categoria: categoria.value,
      local: local.value,
      vagas: vagas.value,
      dataHora: new Date().toISOString(),
      imagemUrl: `https://picsum.photos/seed/evento${Date.now()}/600/300`,
    })
    formularioAlterado.value = false
    router.push({ name: 'admin-eventos' })
  } catch (e) {
    erroSalvar.value = 'Não foi possível salvar o evento. Tente novamente.'
  } finally {
    salvando.value = false
  }
}
</script>

<template>
  <v-card class="pa-4">
    <v-card-title>{{ modoEdicao ? 'Editar evento' : 'Novo evento' }}</v-card-title>
    <v-card-text>
      <v-alert v-if="erroSalvar" type="error" variant="tonal" class="mb-4">{{ erroSalvar }}</v-alert>
      <v-form ref="formRef" @submit.prevent="salvar" @update:model-value="formularioAlterado = true">
        <v-text-field v-model="titulo" label="Título" :rules="regrasTitulo" class="mb-2" />
        <v-textarea v-model="descricao" label="Descrição" rows="3" class="mb-2" />
        <v-select v-model="categoria" :items="categorias" label="Categoria" class="mb-2" />
        <v-text-field v-model="local" label="Local" :rules="regrasLocal" class="mb-2" />
        <v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" class="mb-4" />
        <v-btn type="submit" color="primary" variant="flat" :loading="salvando">Salvar</v-btn>
        <v-btn variant="text" class="ml-2" :to="{ name: 'admin-eventos' }">Cancelar</v-btn>
      </v-form>
    </v-card-text>
  </v-card>
</template>

Passo 9 — testar de ponta a ponta

Com o json-server rodando em um terminal e npm run dev em outro: a home carrega eventos da API (confira na aba Rede do navegador que a requisição GET http://localhost:3000/eventos acontece); inscrever-se em um evento persiste em localStorage (recarregue a página — a inscrição continua marcada); editar um evento na área administrativa reflete na home; derrubar o json-server (Ctrl+C) e recarregar a home deve mostrar o alerta de erro, não uma tela quebrada.

🧪 Laboratório

1. Cancelamento de requisição na busca Aplique a técnica de AbortController da §2 no eventosService.listar, cancelando a busca anterior sempre que o usuário digitar um novo termo antes da resposta anterior chegar.

Dica

Guarde a instância de AbortController em uma variável de módulo dentro do próprio serviço, como no exemplo da §2.

2. Getter totalPorCategoria na store Adicione um computed em eventosStore.js chamado totalPorCategoria, que retorna um objeto { palestra: n, minicurso: n, workshop: n } contando eventos de cada categoria. Exiba isso em três v-chip no AdminHomeView.vue.

Dica

eventos.value.reduce((acc, e) => { acc[e.categoria] = (acc[e.categoria] || 0) + 1; return acc }, {}).

3. $subscribe para log de auditoria No main.js, use eventosStore.$subscribe para imprimir no console, a cada mudança, quantos eventos existem na store — útil para depurar sincronizações inesperadas.

Dica

store.$subscribe((mutation, state) => console.log('eventos:', state.eventos.length)), chamado após app.mount('#app').

4. Tratamento de erro de rede real Derrube o json-server propositalmente e force um erro.request (não erro.response). Ajuste eventosStore.carregarEventos para mostrar uma mensagem diferente quando o erro for de conexão (sem resposta) versus quando for um erro HTTP com resposta.

Dica

Dentro do catch, verifique if (e.response) { ... } else if (e.request) { ... }, como na §2.

5. Persistência de tema com Pinia Crie src/stores/preferenciasStore.js com uma setup store que guarda o tema atual ('light'/'dark'), persiste em localStorage e é usada pelo CabecalhoApp.vue no lugar da lógica local de useTheme() isolada.

Dica

A store guarda o nome do tema em um ref; um watch sobre esse ref chama tema.global.name.value = novoValor e localStorage.setItem.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
Erro de CORS no console, requisição bloqueada Servidor não autoriza a origem do front-end Confirme que o json-server está rodando (ele libera CORS por padrão); em uma API própria, configure o header Access-Control-Allow-Origin
eventos.value nunca atualiza na tela, mesmo a store mudando Desestruturação direta da store (const { eventos } = store) em vez de storeToRefs Use storeToRefs(store) para state/getters; ações continuam vindo direto de store.acao()
store.$reset is not a function Setup store não define $reset manualmente — Pinia só gera isso automaticamente em options stores Declare uma função $reset na própria store e inclua-a no return
Cannot read properties of undefined (reading 'status') no catch de um erro Axios Tentando ler erro.response.status quando o erro é erro.request (sem resposta) Sempre cheque erro.response antes de acessar erro.response.status
json-server responde, mas com 404 em toda requisição Nome da propriedade no db.json não bate com a rota chamada (ex.: db.json tem "evento" no singular, mas o serviço chama /eventos) O nome da chave raiz do db.json define o endpoint — confira a grafia exata
Inscrição some ao recarregar a página localStorage.setItem não está sendo chamado após a mutação, ou a chave usada na leitura é diferente da usada na escrita Confirme que persistir() roda em toda ação que muda idsInscritos, e que a chave é idêntica nos dois lugares
Interceptor de request não injeta o token Token não existe ainda em localStorage (usuário nunca logou) ou a chave usada é diferente da chave de login Confirme a chave (uniEventosToken) e teste manualmente com localStorage.setItem('uniEventosToken', 'teste')

🏠 Atividade assíncrona (1 h)

No seu projeto autoral:

  1. Crie um db.json com os mesmos dados do seu domínio (mínimo 8 registros) e suba com json-server.
  2. Crie src/services/http.js com instância dedicada, interceptor de request e de response.
  3. Crie um serviço (*Service.js) com pelo menos listar, buscarPorId, criar, remover.
  4. Crie uma store Pinia (setup store) para o recurso principal do seu domínio, com carregando, erro e ao menos uma ação assíncrona.
  5. Conecte pelo menos uma tela à store usando storeToRefs, com feedback de v-snackbar em pelo menos uma ação (criar, excluir ou favoritar).

Critério de pronto: a tela principal carrega dados reais do json-server (não mais do array estático); desligar o json-server e recarregar mostra uma mensagem de erro, não uma tela em branco ou quebrada. Suba o commit.

✅ Checkpoint do projeto autoral

  • [ ] db.json criado com pelo menos 8 registros do domínio autoral.
  • [ ] src/services/http.js com instância Axios dedicada e ao menos um interceptor.
  • [ ] Camada de serviço (*Service.js) usada por pelo menos uma store — nenhum componente chama Axios direto.
  • [ ] Pelo menos uma setup store Pinia com state, getter e ação assíncrona.
  • [ ] storeToRefs usado corretamente em pelo menos uma tela.
  • [ ] Estado de carregando/erro refletido visualmente na interface (skeleton ou spinner + alert).
  • [ ] Alguma forma de persistência em localStorage (favoritos, inscrições, tema — a seu critério).

📚 Para aprofundar

Isso encerra a Unidade 2. A Avaliação 2 vence em 07/10/2026, com as instruções completas de entrega na Aula 08 — mas o escopo, resumido em 5 linhas: seu projeto autoral deve consumir dados de uma API (própria ou json-server) através de uma camada de serviços com Axios; ter estado gerenciado por pelo menos uma store Pinia com carregando/erro; refletir esses estados visualmente na interface; persistir algum dado em localStorage; e manter tudo isso rodando em cima da estrutura de rotas e componentes que você já construiu nas Aulas 04 e 05. Comece a organizar seu db.json e sua camada de serviços desde já — não deixe para a última semana.

🎯 Objetivos de aprendizagem📋 Pré-requisitos desta aula🗺️ Roteiro1. HTTP na práticaMétodosStatus codes que importamHeaders e JSONCORS explicado de verdade2. Axios: por que uma biblioteca além do fetchInstalaçãoInstância dedicadaInterceptor de request — injetar tokenInterceptor de response — tratar 401 e normalizar errosTratamento de erro: error.response vs. error.requestCancelamento com AbortControllerUpload com FormData3. Camada de serviços4. API falsa para a aula: json-serverCriando o db.json do UniEventosRodando o servidor5. Pinia: estado compartilhado de verdadeO problema do prop drilling e do estado espalhadoPinia: createPiniadefineStore: dois estilosstoreToRefs — por que desestruturar direto quebra a reatividadeStore completa: eventosStore.jsAções assíncronas$reset, $patch, $subscribeComposição de storesPersistência em localStorageVue DevTools inspecionando a store🧩 Padrão de projeto em uso🧩 Padrão de projeto em uso — Singleton e Decorator💻 Mão na massa — UniEventos consumindo API realPasso 1 — instalar Axios e criar db.jsonPasso 2 — criar a instância HTTPPasso 3 — criar eventosService.jsPasso 4 — criar eventosStore.jsPasso 5 — criar inscricoesStore.jsPasso 6 — atualizar HomeView.vue para usar a storePasso 7 — feedback com v-snackbar na inscriçãoPasso 8 — atualizar AdminEventosView.vue e o formulário para usar a storePasso 9 — testar de ponta a ponta🧪 Laboratório🐛 Erros comuns e como resolver🏠 Atividade assíncrona (1 h)✅ Checkpoint do projeto autoral📚 Para aprofundar
Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 07 — Introdução ao Firebase, Node.js e Express

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 06 você conectou o UniEventos a uma API falsa com json-server, encapsulou as chamadas numa instância dedicada do Axios e organizou o estado global com Pinia. O front-end ficou pronto para conversar com um back-end de verdade. A partir de hoje ele existe — e você é quem vai escrevê-lo.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • explicar por que uma aplicação séria não pode viver só no navegador, e o que precisa acontecer do lado do servidor;
  • descrever o que é o Node.js, como funciona seu event loop e por que ele é adequado para servidores web;
  • diferenciar módulos CommonJS de ES Modules e configurar um projeto Node com "type": "module";
  • criar um projeto no Firebase, obter as credenciais do app web e ler/escrever documentos no Firestore com o SDK modular;
  • explicar o que é um BaaS (Backend as a Service) e quando ele resolve — e quando não resolve — o problema de back-end;
  • subir um servidor Express 5 mínimo, com CORS e rotas JSON, evitando as armadilhas de sintaxe do Express 4;
  • apontar o front-end UniEventos para uma API Express real, no lugar do json-server.

📋 Pré-requisitos desta aula

  • [ ] Front-end unieventos-web da Aula 06 rodando localmente com npm run dev, consumindo json-server via instância Axios dedicada.
  • [ ] Store Pinia de eventos funcionando (estado, carregando, erro).
  • [ ] Node.js 22.x instalado (node -v). Se você tem outra versão, use nvm install 22 && nvm use 22.
  • [ ] Conta Google para criar o projeto no console do Firebase.
  • [ ] VS Code com a extensão REST Client ou Thunder Client instalada (vamos usar hoje).
  • [ ] Terminal com curl disponível (já vem no Linux/macOS; no Windows use o curl do PowerShell ou WSL).
  • [ ] Editor com abas suficientes para acompanhar dois projetos abertos ao mesmo tempo (unieventos-web e, a partir de hoje, unieventos-api).
  • [ ] Duas janelas de terminal livres — uma para cada projeto rodando simultaneamente.

⚠️ Atenção Muito tutorial de Express na internet — inclusive vídeos recentes — ainda usa a sintaxe do Express 4. A partir de hoje você trabalha com o Express 5.2.1, que já é o padrão de instalação (npm install express traz a v5). Este material tem uma seção inteira (§5) só sobre isso. Leia com atenção antes de copiar código de fora.

Retomando rapidamente onde a Aula 06 parou: você tem hoje um front-end Vue com Vuetify, Router e Pinia, consumindo dados de um json-server através de uma instância Axios dedicada, com interceptors e uma camada src/services/. Essa arquitetura de consumo não muda — o que muda, a partir de agora, é o que está do outro lado da rede.

Duas frentes novas se abrem hoje, e vamos alternar entre elas: primeiro o Node.js e o Firebase (uma introdução rápida a um back-end pronto), depois o Express (o back-end que você mesmo escreve, e que vai crescer pelo resto do semestre).

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Por que back-end existe; arquitetura cliente-servidor; Node.js e seu modelo de execução
2 50 min BaaS vs API própria; criação do projeto Firebase; primeira leitura/escrita no Firestore
3 50 min Express 5 na prática: servidor mínimo, rotas, CORS, armadilhas de sintaxe; conectar o front real

1. Por que o front sozinho não basta

Até a Aula 06, o UniEventos rodava inteiro no navegador de quem acessa. O json-server simulava uma API, mas ele não impõe nenhuma regra: qualquer pessoa com o DevTools aberto pode alterar o corpo de uma requisição e gravar o que quiser. Isso é aceitável para prototipar, mas não para um sistema real. Três problemas aparecem assim que você tenta ir além do protótipo.

Segredos não podem ficar no navegador. Toda variável, toda constante, todo arquivo .js que você entrega ao navegador é público — qualquer pessoa pode abrir o DevTools, ver o código-fonte baixado e ler o que está ali. Uma chave de API paga, uma credencial de banco de dados ou uma regra de negócio sigilosa não podem estar no front-end. Elas precisam morar em um ambiente que o usuário não acessa diretamente: o servidor.

Regras de negócio precisam de um lugar confiável para rodar. Pense no UniEventos: um evento tem um número de vagas. Se a lógica "não deixar inscrever além do limite de vagas" estiver só no front-end (por exemplo, desabilitando um botão quando vagas === 0), basta alguém chamar a API diretamente — pelo curl, pelo Postman, por um script — ignorando a interface, para furar a regra. A regra de negócio real precisa ser verificada no servidor, porque é o único lugar que o usuário não controla.

Integridade e autorização dependem de um árbitro imparcial. "Este usuário pode editar este evento?" "Este evento realmente existe e tem vaga disponível neste exato momento?" Essas perguntas não podem ser respondidas com confiança por código que roda na máquina do próprio usuário — ele poderia simplesmente alterar a resposta. É preciso um terceiro, fora do alcance do cliente, que centralize a decisão. Esse terceiro é o back-end.

Um jeito direto de sentir isso na prática: abra o DevTools do navegador (F12) numa aplicação Vue rodando com npm run dev, vá na aba Sources e procure pelos arquivos .js da sua própria aplicação. Estão todos ali, legíveis, com nomes de variáveis e comentários incluídos (a não ser que você tenha ativado minificação/ofuscação — que dificulta a leitura, mas não impede). Qualquer verificação de senha, qualquer "if usuário é admin" escrito só em JavaScript de front-end, está exposto a quem quiser ler.

🔎 Por baixo do capô "Confiável" aqui não é sobre honestidade — é sobre controle de execução. O servidor é confiável não porque é "mais correto", mas porque só você (o dono da infraestrutura) pode alterar o código que roda nele. O código do navegador, qualquer usuário pode alterar antes de ele rodar (interceptando a requisição, editando o JS carregado, etc.).

Arquitetura cliente-servidor: o que trafega, onde cada coisa roda

O modelo cliente-servidor divide responsabilidades em duas metades que se comunicam por rede:

  • Cliente — o front-end UniEventos rodando no navegador do usuário. Responsável por interface, navegação (Vue Router), estado local de tela (Pinia) e por pedir dados e ações ao servidor via HTTP (Axios).
  • Servidor — um processo rodando em uma máquina que você controla (seu notebook agora, um provedor de nuvem depois). Responsável por validar entradas, aplicar regras de negócio, autenticar e autorizar, e ler/gravar dados persistentes.

O que trafega entre os dois é HTTP: requisições com método, URL, cabeçalhos e corpo (geralmente JSON), e respostas com status code, cabeçalhos e corpo. Você já usa isso desde a Aula 06 com o Axios — a diferença é que, a partir de hoje, do outro lado da requisição não tem mais o json-server genérico, tem um programa que você escreve, controla e pode fazer aplicar qualquer regra que quiser.

Texto
┌─────────────────────┐        HTTP (JSON)        ┌──────────────────────┐
│  unieventos-web      │  ────────────────────▶    │  unieventos-api       │
│  (Vue + Vuetify)      │                            │  (Node + Express)     │
│  roda no navegador    │  ◀────────────────────    │  roda no servidor     │
│  do usuário            │                            │  que você controla    │
└─────────────────────┘                             └──────────────────────┘
                                                                │
                                                                ▼
                                                      banco de dados / Firestore

📌 Na prova Se a pergunta for "por que não validar tudo no front-end?", a resposta certa cita que o código do cliente é executado em uma máquina que o usuário controla, portanto não é confiável para decisões de segurança ou integridade — só o servidor pode ser esse árbitro.

Relembrando HTTP, porque hoje você escreve os dois lados

Desde a Aula 06 você usa o Axios para fazer requisições. Hoje você passa a escrever o código que recebe essas requisições, então vale relembrar o vocabulário do protocolo — ele é o mesmo dos dois lados.

Toda requisição HTTP tem um método, que expressa a intenção da ação:

Método Intenção
GET ler um recurso, sem alterar nada
POST criar um recurso novo
PUT/PATCH atualizar um recurso existente (inteiro ou parcial)
DELETE remover um recurso

E toda resposta HTTP tem um status code, um número de três dígitos que resume o resultado sem precisar ler o corpo:

Faixa Significado
2xx sucesso (200 OK, 201 Created, 204 No Content)
4xx erro do cliente — pedido malformado, recurso inexistente (400, 404, 422)
5xx erro do servidor — algo quebrou ao processar (500)

Você já viu o Axios lançar exceção quando o status vem 4xx ou 5xx (Aula 06, nos interceptors). Hoje o ponto de vista muda: você é quem decide qual status devolver em cada rota. Vamos aprofundar status codes por operação na Aula 08 — por ora, guarde que res.status(código) é como o Express define esse número antes do corpo da resposta.

Por trás do que o Axios monta e o Express interpreta, uma requisição HTTP crua se parece com isto (é texto puro, trafegando pela rede):

HTTP
GET /api/eventos/1 HTTP/1.1
Host: localhost:3000
Accept: application/json

E a resposta, também texto puro, com cabeçalhos seguidos de uma linha em branco e depois o corpo:

HTTP
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 132

{"id":1,"titulo":"Semana Acadêmica de Computação","categoria":"palestra","vagas":80}

O Axios, no front, monta essa requisição a partir do que você escreve em api.get(...). O Express, no servidor, faz o caminho inverso: recebe esse texto cru, faz o parse do método, da URL, dos cabeçalhos e do corpo, e entrega tudo isso organizado em req.method, req.path, req.headers e req.body para o seu código usar. É exatamente esse trabalho de parsing que express.json() completa para o corpo, quando o Content-Type é application/json.

2. Node.js: o runtime por trás do servidor

JavaScript nasceu para rodar dentro de navegadores. O Node.js é um runtime — um ambiente de execução — que tira o motor V8 (o mesmo que roda JS no Chrome) de dentro do navegador e o coloca para rodar direto no sistema operacional, com acesso a coisas que o navegador não dá: sistema de arquivos, rede em nível baixo, processos. É isso que permite escrever um servidor HTTP em JavaScript.

Event loop e I/O não bloqueante, sem mistério

A pergunta que costuma travar quem vem de outras linguagens: como um programa de uma única thread atende centenas de requisições ao mesmo tempo sem travar?

A resposta é que o Node.js é bom em uma coisa específica: esperar. A maior parte do trabalho de um servidor web não é "calcular muito" — é "esperar coisas lentas": esperar o banco de dados responder, esperar um arquivo ser lido do disco, esperar outra API responder. Essas operações são de I/O (entrada/saída) e, no Node, elas não bloqueiam a thread principal.

Pense assim: quando seu código pede "leia este arquivo" ou "consulte este banco de dados", o Node não fica parado esperando. Ele delega essa espera para o sistema operacional (ou para uma thread interna de apoio) e imediatamente volta a executar a próxima linha de código disponível — atendendo outra requisição, por exemplo. Quando a operação de I/O termina, o resultado entra em uma fila. O event loop é o mecanismo que fica continuamente perguntando "tem algo pronto na fila para eu processar agora?" e, quando tem, executa o callback (ou resolve a Promise, ou retoma o await) correspondente.

Texto
 chegou requisição A (buscar eventos no Firestore)
     │
     ▼
 Node dispara a consulta e NÃO espera parado
     │
     ▼
 thread livre → atende requisição B (buscar 1 evento por id)
     │
     ▼
 Node dispara a consulta B e NÃO espera parado
     │
     ▼
 resultado de A fica pronto → event loop retoma o código de A
     │
     ▼
 resultado de B fica pronto → event loop retoma o código de B

Isso é diferente de um modelo bloqueante, onde a thread ficaria parada, sem fazer nada, do início ao fim de cada consulta — atendendo uma requisição de cada vez, em fila, mesmo que 99% do tempo seja espera. Um servidor Node consegue lidar com milhares de conexões simultâneas com uma única thread principal porque quase todo esse tempo é espera de I/O, não cálculo.

⚠️ Atenção Isso não significa que o Node é mágico para tudo. Se seu código fizer um cálculo pesado e síncrono (por exemplo, um laço for gigantesco processando dados em memória), ele bloqueia a thread principal e trava todas as requisições até terminar. O modelo não bloqueante vale para I/O — rede, disco, banco de dados —, não para processamento pesado de CPU. Para isso existem worker threads, fora do escopo desta disciplina.

Na prática, isso aparece no seu código como async/await e Promise, que você já usa desde a Aula 01. await pool.query(...) (aula 09) ou await getDocs(...) (Firestore, ainda hoje) são exatamente isso: "dispare esta operação de I/O e me devolva o controle quando o resultado chegar, sem travar o resto do programa".

🔎 Por baixo do capô Internamente, o Node usa uma biblioteca em C chamada libuv para implementar o event loop e delegar operações de I/O ao sistema operacional (ou a uma pequena pool de threads auxiliares, para coisas como leitura de arquivo que o SO não oferece de forma assíncrona nativa). Sua thread JavaScript principal continua única — é a libuv que faz o trabalho de bastidores para nunca bloqueá-la. Você não precisa mexer nisso diretamente; só precisa saber que existe, para entender por que async/await "simplesmente funciona" sem travar o servidor.

💡 Dica Uma analogia útil: pense num garçom (a thread do Node) atendendo várias mesas (requisições) num restaurante. Um garçom bloqueante ficaria parado do lado de uma mesa esperando a cozinha terminar um prato antes de atender a próxima mesa. Um garçom não bloqueante anota o pedido, leva para a cozinha, e imediatamente vai atender a próxima mesa — voltando a cada mesa só quando o prato dela está pronto. É o mesmo garçom (uma thread), mas ele nunca fica parado esperando.

Módulos: CommonJS vs ES Modules

Node.js existe desde 2009, muito antes de o JavaScript ter um sistema de módulos padronizado na própria linguagem. Por isso o Node criou o seu: CommonJS, baseado em require() e module.exports.

JavaScript
// estilo CommonJS (antigo, ainda muito comum em tutoriais e pacotes legados)
const express = require('express')
module.exports = { minhaFuncao }

Anos depois, o JavaScript ganhou um sistema de módulos oficial da linguagem: ES Modules (ESM), baseado em import/export — o mesmo que você já usa em todo componente Vue desde a Aula 01.

JavaScript
// estilo ES Modules (o que esta disciplina usa no back-end)
import express from 'express'
export function minhaFuncao() { /* ... */ }

Nesta disciplina, o back-end usa ESM. É consistente com o que você já escreve no front-end, é o padrão recomendado para projetos novos e evita misturar dois estilos de import/require no mesmo projeto. Para o Node tratar seus arquivos .js como ESM (e não CommonJS, que é o padrão histórico), é preciso declarar isso no package.json:

JSON
{
  "name": "unieventos-api",
  "type": "module"
}

Com "type": "module" presente, todo arquivo .js do projeto passa a ser interpretado como ESM. require deixa de funcionar; use sempre import.

⚠️ Atenção Se você copiar um trecho de tutorial que usa require('express') e colar em um projeto com "type": "module" no package.json, o Node lança ReferenceError: require is not defined in ES module scope. A correção é reescrever para import express from 'express'. Isso vai acontecer — memorize a mensagem de erro.

Preparando o ambiente: npm init, scripts, node --watch

Todo projeto Node começa com um package.json, que descreve o projeto, suas dependências e seus scripts.

Terminal
mkdir unieventos-api && cd unieventos-api
npm init -y

O npm init -y gera um package.json com valores padrão. Ajuste-o para o que a disciplina usa:

JSON
{
  "name": "unieventos-api",
  "version": "1.0.0",
  "description": "API do projeto UniEventos — FACET-SNP-310",
  "type": "module",
  "main": "src/servidor.js",
  "scripts": {
    "dev": "node --watch --env-file=.env src/servidor.js",
    "start": "node --env-file=.env src/servidor.js"
  }
}

Dois pontos merecem atenção:

node --watch no lugar do nodemon. Historicamente, quem desenvolvia com Node instalava o pacote nodemon para reiniciar o servidor automaticamente a cada alteração de arquivo. Desde a versão 18.11, o próprio Node tem essa funcionalidade embutida: a flag --watch. Não é preciso instalar mais nada.

Terminal
node --watch src/servidor.js

Variáveis de ambiente com process.env e --env-file. Toda configuração que muda entre ambientes (porta do servidor, credenciais de banco, chaves de API) deve vir de variáveis de ambiente, nunca de valores fixos no código. O Node expõe essas variáveis no objeto global process.env. Desde a versão 20.6 (estável desde a 22), o Node lê arquivos .env nativamente, sem precisar do pacote dotenv:

Terminal
node --env-file=.env src/servidor.js
Terminal
# .env (nunca commitar este arquivo)
PORTA=3000
JavaScript
// uso de process.env em qualquer arquivo do projeto
const porta = process.env.PORTA || 3000

💡 Dica Crie sempre um .env.example versionado, com as chaves (sem os valores sigilosos), para quem clonar o repositório saber o que configurar. E adicione .env ao .gitignore imediatamente — antes do primeiro commit, não depois.

dependencies vs devDependencies, e o que é o package-lock.json

Quando você roda npm install express cors, dois efeitos acontecem: os pacotes são baixados para node_modules/, e o package.json ganha uma entrada em "dependencies". Pacotes que só existem para ajudar durante o desenvolvimento — nunca rodam em produção — vão em "devDependencies", instalados com a flag -D:

Terminal
npm install express cors          # vai para "dependencies" — necessário em produção
npm install -D algum-pacote-de-teste   # vai para "devDependencies" — só em desenvolvimento

O package-lock.json, gerado automaticamente, trava a versão exata (inclusive das dependências transitivas — as dependências das suas dependências) que foi instalada. Ele deve ser commitado: garante que qualquer pessoa que clone o repositório e rode npm install receba exatamente as mesmas versões que você testou, evitando o clássico "na minha máquina funciona".

⚠️ Atenção node_modules/ nunca é commitado — é sempre reconstruído com npm install a partir do package.json e do package-lock.json. Ele já está no .gitignore do projeto.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Chain of Responsibility

Um servidor Express processa toda requisição através de uma sequência de funções chamadas middlewares: express.json(), cors(), sua rota, e (aula 08) validadores e tratadores de erro. Cada função na cadeia decide se trata a requisição, a repassa adiante com next(), ou interrompe o fluxo respondendo diretamente. Isso é o padrão comportamental Chain of Responsibility: uma corrente de handlers, cada um com a chance de agir e passar adiante. Você já viu a ideia em ação nos interceptors do Axios (Aula 06) — lá era uma cadeia de duas etapas (requisição/resposta); aqui é uma cadeia configurável de N etapas. Vamos aprofundar isso na Aula 08, quando você escrever seus próprios middlewares.

3. BaaS vs API própria: o que é o Firebase

Nem todo back-end precisa ser escrito do zero. Um BaaS (Backend as a Service) é um serviço de terceiros que já entrega pedaços prontos de back-end — banco de dados, autenticação, upload de arquivos, hospedagem — através de um SDK que você chama direto do seu front-end, sem escrever seu próprio servidor para essas partes.

O Firebase, do Google, é o BaaS mais usado no mercado. Os serviços relevantes para esta disciplina:

Serviço Para que serve
Authentication login/cadastro (e-mail+senha, Google, etc.) — usado na Aula 10
Firestore banco de dados NoSQL orientado a documentos, em tempo real
Storage upload e hospedagem de arquivos (ex.: imagem do evento)
Hosting hospedagem estática do front-end compilado
Cloud Functions código de back-end sob demanda, sem gerenciar servidor

Quando um BaaS resolve: protótipos, MVPs, projetos pequenos ou médios onde autenticação e um banco de dados de propósito geral já cobrem a necessidade. Você escreve praticamente zero código de servidor — o SDK do Firebase fala direto com a nuvem do Google a partir do seu Vue.

Quando um BaaS não resolve: quando a regra de negócio é complexa demais para expressar só em regras de segurança do Firestore; quando você precisa de consultas relacionais complexas (JOINs, agregações — ponto forte de um SGBD relacional, Aula 09); quando você precisa de controle total sobre a lógica do servidor; ou, como nesta disciplina, quando o objetivo é justamente aprender a construir um back-end. Por isso o UniEventos vai usar o Firestore hoje para uma leitura/escrita simples, mas a partir da Aula 08 a lógica de negócio migra para uma API Express própria, e na Aula 09 os dados migram para MySQL.

⚠️ Atenção Nunca use a API antiga do Firebase, com namespace (firebase.initializeApp(...), firebase.firestore()). Ela ainda aparece em vídeos e artigos antigos. Esta disciplina usa exclusivamente a API modular, versão 12, com import nomeado de funções: import { initializeApp } from 'firebase/app'.

Criando o projeto no console do Firebase

  1. Acesse console.firebase.google.com com sua conta Google.
  2. Clique em Adicionar projeto, dê o nome unieventos (ou unieventos-seu-nome, já que o nome do projeto precisa ser único globalmente) e siga o assistente (pode desativar o Google Analytics, não é necessário para a disciplina).
  3. Dentro do projeto, clique no ícone **`** (Web) para registrar um app web. Dê o apelidounieventos-web`.
  4. O console mostra um objeto firebaseConfigcopie-o, ele contém as chaves de configuração do seu projeto (não são segredos no sentido de senha, mas identificam seu projeto):
JavaScript
// exemplo de firebaseConfig — o seu terá valores diferentes
const firebaseConfig = {
  apiKey: 'AIzaSyExemploDeChaveNaoUseEsta',
  authDomain: 'unieventos-xxxxx.firebaseapp.com',
  projectId: 'unieventos-xxxxx',
  storageBucket: 'unieventos-xxxxx.firebasestorage.app',
  messagingSenderId: '123456789012',
  appId: '1:123456789012:web:abcdef1234567890',
}
  1. No menu lateral, vá em Build → Firestore Database e clique em Criar banco de dados. Escolha a localização (qualquer região das Américas serve) e, quando perguntado sobre regras de segurança, escolha modo de teste.

⚠️ Atenção O modo de teste libera leitura e escrita para qualquer um por 30 dias, sem autenticação nenhuma. Isso é intencional para você aprender sem se preocupar com regras agora — mas nunca vá para produção assim. Na Aula 10, quando integrarmos o Firebase Auth, vamos escrever regras de segurança de verdade, amarradas ao usuário autenticado.

Instalando o SDK e inicializando o app

Isso aqui roda no front-end (unieventos-web), não na API que vamos criar depois — o SDK do Firebase fala direto com a nuvem do Google a partir do navegador.

Terminal
npm install firebase@12
JavaScript
// src/firebase.js — em unieventos-web
import { initializeApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
}

// inicializa a conexão com o projeto Firebase
export const appFirebase = initializeApp(firebaseConfig)

// instância do Firestore usada em todo o projeto
export const db = getFirestore(appFirebase)
Terminal
# .env (em unieventos-web, prefixo VITE_ obrigatório para o Vite expor a variável ao front)
VITE_FIREBASE_API_KEY=AIzaSyExemploDeChaveNaoUseEsta
VITE_FIREBASE_AUTH_DOMAIN=unieventos-xxxxx.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos-xxxxx
VITE_FIREBASE_STORAGE_BUCKET=unieventos-xxxxx.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012
VITE_FIREBASE_APP_ID=1:123456789012:web:abcdef1234567890

💡 Dica No Vite, variáveis de ambiente expostas ao código do navegador precisam começar com VITE_. É uma proteção: assim você não expõe acidentalmente uma variável sensível do servidor de build para o navegador.

Primeira leitura e escrita no Firestore

O Firestore organiza dados em coleções (como uma tabela, mas sem schema fixo) de documentos (como uma linha, mas um objeto JSON aninhável). Vamos escrever e ler eventos de teste.

JavaScript
// src/testeFirestore.js — script de exploração, não faz parte da app final
import {
  collection,
  addDoc,
  getDocs,
  doc,
  updateDoc,
  deleteDoc,
  query,
  where,
  orderBy,
} from 'firebase/firestore'
import { db } from './firebase.js'

// referência para a coleção "eventos"
const colecaoEventos = collection(db, 'eventos')

async function criarEventoDeTeste() {
  // addDoc gera um id automático e grava o documento
  const referencia = await addDoc(colecaoEventos, {
    titulo: 'Semana Acadêmica de Computação',
    categoria: 'palestra',
    vagas: 80,
    dataHora: '2026-10-15T19:00:00',
  })
  console.log('documento criado com id:', referencia.id)
}

async function listarTodosOsEventos() {
  const snapshot = await getDocs(colecaoEventos)
  const eventos = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
  console.log('eventos encontrados:', eventos)
}

async function listarPalestrasOrdenadasPorData() {
  // query + where + orderBy formam uma consulta filtrada e ordenada
  const consulta = query(
    colecaoEventos,
    where('categoria', '==', 'palestra'),
    orderBy('dataHora', 'asc'),
  )
  const snapshot = await getDocs(consulta)
  return snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
}

async function atualizarVagas(idDoEvento, novasVagas) {
  // doc() aponta para um documento específico dentro da coleção
  const referenciaDoDocumento = doc(db, 'eventos', idDoEvento)
  await updateDoc(referenciaDoDocumento, { vagas: novasVagas })
}

async function removerEvento(idDoEvento) {
  const referenciaDoDocumento = doc(db, 'eventos', idDoEvento)
  await deleteDoc(referenciaDoDocumento)
}

await criarEventoDeTeste()
await listarTodosOsEventos()

🔎 Por baixo do capô getDocs devolve um QuerySnapshot, não um array direto. Cada item é um QueryDocumentSnapshot, com .id (o id do documento) separado de .data() (o conteúdo). Por isso o padrão { id: d.id, ...d.data() } aparece toda vez que você lê uma coleção — é assim que você recompõe um objeto "normal" com id incluso.

As regras de segurança por trás do modo de teste

Quando você escolheu "modo de teste" ao criar o banco, o Firebase gravou uma regra de segurança liberando tudo por 30 dias. Vale abrir Firestore Database → Regras no console e olhar o que foi gerado:

Texto
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2026, 10, 12);
    }
  }
}

Note a condição: allow read, write só vale até uma data. Depois disso, toda leitura e escrita passa a ser negada por padrão — é assim que o Firestore evita bancos de teste esquecidos abertos para o mundo. Não precisa mexer nessas regras hoje; na Aula 10, quando o Firebase Authentication entrar, vamos trocar essa condição por algo como allow read: if true; allow write: if request.auth != null; — leitura pública, escrita só para quem está autenticado.

⚠️ Atenção Um erro comum é confundir "regra de segurança do Firestore" com "regra de negócio da aplicação". A regra de segurança só decide quem pode ler/escrever; ela não valida, por exemplo, se o número de vagas de um evento é positivo. Esse tipo de validação continua sendo responsabilidade do código — do front-end como primeira camada de UX, e do back-end como camada de verdade (Aula 08).

Esta exploração no Firestore serve para você conhecer o SDK modular — ele volta com força na Aula 10, quando o Firebase Authentication entrar em cena. A partir de agora, porém, o motor de dados principal do UniEventos passa a ser a API Express que vamos construir, primeiro em memória (hoje e na Aula 08) e depois em MySQL (Aula 09).

4. Express 5.2.1: o servidor mínimo

Express é um framework web minimalista para Node.js: ele não decide como você organiza pastas nem qual banco usar, só oferece o essencial para receber requisições HTTP, rotear por método e caminho, e responder. É a ferramenta padrão de mercado para APIs Node.

Terminal
mkdir -p src
npm install express cors
JavaScript
// src/servidor.js
import express from 'express'
import cors from 'cors'

const app = express()

// middlewares de aplicação: rodam em toda requisição, nesta ordem
app.use(cors())           // libera requisições de outras origens (o front em outra porta)
app.use(express.json())   // faz o parse do corpo JSON e popula req.body

// primeira rota: responde a GET /
app.get('/', (req, res) => {
  res.json({ mensagem: 'API do UniEventos no ar' })
})

const porta = process.env.PORTA || 3000

app.listen(porta, () => {
  console.log(`unieventos-api rodando em http://localhost:${porta}`)
})

Suba o servidor:

Terminal
node --watch src/servidor.js

Teste no navegador acessando http://localhost:3000/, ou no terminal:

Terminal
curl http://localhost:3000/
# {"mensagem":"API do UniEventos no ar"}

express.json() e cors() são exemplos de middlewares de aplicação: funções que rodam para toda requisição, registradas com app.use(), antes de qualquer rota. express.json() lê o corpo da requisição, faz o parse como JSON, e disponibiliza o resultado em req.body. cors() adiciona os cabeçalhos que autorizam o navegador a aceitar a resposta quando a requisição vem de uma origem diferente (por exemplo, o front-end rodando em localhost:5173 chamando a API em localhost:3000) — sem isso, o navegador bloqueia a resposta por política de mesma origem.

Estrutura inicial de pastas

Texto
unieventos-api/
├─ src/
│  └─ servidor.js
├─ .env
├─ .env.example
├─ .gitignore
├─ package.json
└─ requests.http

Vamos crescer essa estrutura nas próximas duas aulas (routes/, middlewares/, repositories/, services/, controllers/). Por enquanto, um único arquivo é suficiente.

⚠️ Express 5 não é Express 4

Quando você instala Express hoje (npm install express), recebe a versão 5.2.1 — mas a maioria dos tutoriais, cursos gravados e respostas de fórum na internet ainda ensina Express 4, que tem sintaxe diferente em pontos que quebram silenciosamente ou lançam erro. A tabela a seguir foi testada no ambiente real desta disciplina — não é teoria, é o que de fato acontece rodando o código.

Express 4 (não use) Express 5.2.1 (use)
erro em handler async precisa de .catch(next) manual erro em handler async é capturado automaticamente pelo Express
req.query podia ser reatribuído req.query é somente leitura
app.del('/rota', ...) app.delete('/rota', ...)app.del foi removido
res.redirect('/rota', 302) res.redirect(302, '/rota') — ordem invertida
res.json(objeto, 201) res.status(201).json(objeto) — a assinatura de dois argumentos não existe mais
req.param('id') req.params.idreq.param() foi removido
res.sendfile() res.sendFile()
app.get('/arquivos/*') app.get('/arquivos/*splat') — curinga nomeado, req.params.splat vira array
app.get('/relatorio/:ano?') app.get('/relatorio{/:ano}') — segmento opcional é chave, não ?
exigia body-parser instalado à parte express.json()/express.urlencoded() já são nativos
req.body virava {} sem parser req.body é undefined se nada foi parseado

Os pontos que mais pegam quem está aprendendo:

Erros assíncronos, agora automáticos. No Express 4, se um handler async lançasse uma exceção, ela não era capturada pelo tratador de erros — a requisição ficava pendurada ou o processo caía, a menos que você embrulhasse manualmente com .catch(next) ou usasse o pacote express-async-handler. É por isso que tanto código por aí ainda importa esse pacote. No Express 5, isso já funciona sem nada extra:

JavaScript
// Express 5: pode dar throw dentro de um handler async — cai direto no error handler
app.get('/api/eventos/:id', async (req, res) => {
  const evento = await buscarEventoPorId(req.params.id)
  if (!evento) {
    throw new Error('Evento não encontrado')  // capturado automaticamente
  }
  res.json(evento)
})

Vamos explorar isso a fundo na Aula 08, com uma classe de erro própria (ErroHttp) e um tratador central.

req.query é somente leitura. No Express 4 era comum, embora não recomendado, fazer req.query.pagina = Number(req.query.pagina) para normalizar um valor. No Express 5, isso lança erro em runtime — req.query não pode ser reatribuído. Se precisar de um valor tratado, crie uma variável nova:

JavaScript
// ERRADO no Express 5: lança TypeError
// req.query.pagina = Number(req.query.pagina)

// CORRETO: crie uma variável nova a partir do valor lido
const pagina = Number(req.query.pagina) || 1

Curingas e segmentos opcionais mudaram de sintaxe. O Express 5 trocou o motor de rotas para path-to-regexp v8, que não aceita mais * solto nem ? para tornar um segmento opcional. É preciso nomear o curinga e envolver o opcional em chaves:

JavaScript
// Express 4 (não use): curinga solto
// app.get('/arquivos/*', (req, res) => { ... })

// Express 5: curinga nomeado — req.params.splat vem como ARRAY de segmentos
app.get('/arquivos/*splat', (req, res) => {
  console.log(req.params.splat) // ex.: ['pdf', 'edital-2026.pdf']
})

// Express 4 (não use): segmento opcional com "?"
// app.get('/relatorio/:ano?', (req, res) => { ... })

// Express 5: segmento opcional entre chaves
app.get('/relatorio{/:ano}', (req, res) => {
  // GET /relatorio      -> req.params.ano é undefined
  // GET /relatorio/2026 -> req.params.ano é '2026'
  const ano = req.params.ano || 'atual'
  res.json({ relatorioDoAno: ano })
})

Não vamos precisar de curingas nem de segmentos opcionais na API do UniEventos por enquanto, mas é comum encontrar essa sintaxe em documentação de upload de arquivos ou rotas de relatório — reconhecer a diferença evita copiar sintaxe do Express 4 sem perceber.

app.del e res.sendfile, os "quase iguais" que quebram silenciosamente. Esses dois têm um detalhe traiçoeiro: o Express 5 não lança erro amigável — app.del simplesmente não existe mais como método (TypeError: app.del is not a function), e res.sendfile (tudo minúsculo) também não existe (res.sendfile is not a function). A diferença de capitalização em sendFile é sutil o bastante para passar despercebida numa leitura rápida.

JavaScript
// ERRADO — Express 4
// app.del('/api/eventos/:id', removerEvento)
// res.sendfile(caminhoDoArquivo)

// CORRETO — Express 5
app.delete('/api/eventos/:id', removerEvento)
res.sendFile(caminhoDoArquivo)

📌 Na prova Se aparecer um trecho de código com app.del(...), res.json(obj, 201) ou req.param('id'), é Express 4 — identifique a sintaxe errada e corrija para a equivalente do Express 5.

💻 Mão na massa — criando a unieventos-api e conectando o front

Passo 1 — criar o repositório e o projeto Node

Terminal
mkdir unieventos-api && cd unieventos-api
git init
npm init -y
npm install express cors

Edite o package.json gerado:

JSON
{
  "name": "unieventos-api",
  "version": "1.0.0",
  "description": "API do projeto UniEventos — FACET-SNP-310",
  "type": "module",
  "main": "src/servidor.js",
  "scripts": {
    "dev": "node --watch --env-file=.env src/servidor.js",
    "start": "node --env-file=.env src/servidor.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^5.2.1"
  }
}
Texto
# .gitignore
node_modules/
.env
Terminal
# .env.example
PORTA=3000
Terminal
# .env
PORTA=3000

Passo 2 — dados de eventos em memória

JavaScript
// src/dados/eventos.js
// dados em memória — nesta aula ainda não temos banco de dados (chega na Aula 09)
export const eventos = [
  {
    id: 1,
    titulo: 'Semana Acadêmica de Computação',
    descricao: 'Palestras e minicursos sobre o mercado de tecnologia.',
    categoria: 'palestra',
    dataHora: '2026-10-15T19:00:00',
    local: 'Auditório FACET',
    vagas: 80,
    imagemUrl: 'https://picsum.photos/seed/semana-computacao/400/240',
  },
  {
    id: 2,
    titulo: 'Minicurso de Vue 3',
    descricao: 'Introdução prática ao framework Vue com Composition API.',
    categoria: 'minicurso',
    dataHora: '2026-10-20T14:00:00',
    local: 'Laboratório 3',
    vagas: 30,
    imagemUrl: 'https://picsum.photos/seed/minicurso-vue/400/240',
  },
  {
    id: 3,
    titulo: 'Workshop de Firebase e Express',
    descricao: 'Construindo uma API real do zero.',
    categoria: 'workshop',
    dataHora: '2026-10-28T19:30:00',
    local: 'Laboratório 1',
    vagas: 25,
    imagemUrl: 'https://picsum.photos/seed/workshop-firebase/400/240',
  },
]

Passo 3 — servidor com as duas primeiras rotas

JavaScript
// src/servidor.js
import express from 'express'
import cors from 'cors'
import { eventos } from './dados/eventos.js'

const app = express()

app.use(cors())
app.use(express.json())

// GET /api/eventos — lista todos os eventos
app.get('/api/eventos', (req, res) => {
  res.json(eventos)
})

// GET /api/eventos/:id — busca um evento específico
app.get('/api/eventos/:id', (req, res) => {
  // req.params.id sempre chega como string — convertemos para comparar com o id numérico
  const id = Number(req.params.id)
  const evento = eventos.find((e) => e.id === id)

  if (!evento) {
    return res.status(404).json({ erro: 'Evento não encontrado' })
  }

  res.json(evento)
})

const porta = process.env.PORTA || 3000

app.listen(porta, () => {
  console.log(`unieventos-api rodando em http://localhost:${porta}`)
})

Suba com npm run dev e teste:

Terminal
curl http://localhost:3000/api/eventos
curl http://localhost:3000/api/eventos/1
curl http://localhost:3000/api/eventos/999
# {"erro":"Evento não encontrado"}

Três formas de testar, e quando usar cada uma

Você vai testar a mesma API de três jeitos diferentes ao longo do curso. Cada um serve para um momento:

Navegador. Rápido para conferir uma rota GET simples visualmente — cole a URL na barra de endereços. Limitação: o navegador só faz GET ao digitar uma URL; não dá para testar POST, PUT, DELETE nem enviar cabeçalhos customizados dessa forma.

curl. Funciona para qualquer método, direto do terminal, sem depender do VS Code estar aberto. Ótimo para scripts, para depuração rápida e para copiar/colar em relatos de bug. A sintaxe fica mais verbosa conforme a requisição cresce:

Terminal
curl -X POST http://localhost:3000/api/eventos \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Palestra de teste","categoria":"palestra","vagas":10}'

REST Client / Thunder Client (VS Code). O melhor equilíbrio para desenvolvimento do dia a dia: a requisição fica escrita em um arquivo versionado (requests.http), legível, reaproveitável pelo time todo, e você clica para executar sem digitar nada no terminal. É o que vamos usar como principal ferramenta de teste manual a partir de agora — inclusive na Aula 08, onde o arquivo requests.http cresce para cobrir todo o CRUD.

💡 Dica Nenhuma das três ferramentas substitui testes automatizados (fora do escopo desta disciplina). Elas servem para verificação manual durante o desenvolvimento — e o arquivo requests.http tem a vantagem extra de documentar a API para quem vai usá-la depois, inclusive você mesmo daqui a um mês.

Passo 4 — testando com REST Client (VS Code)

Crie um arquivo de requisições versionado, que serve de documentação viva da API:

HTTP
### requests.http — abra este arquivo no VS Code com a extensão REST Client instalada
### clique em "Send Request" acima de cada bloco para testar

### listar todos os eventos
GET http://localhost:3000/api/eventos

### buscar um evento específico
GET http://localhost:3000/api/eventos/1

### buscar um evento que não existe (deve responder 404)
GET http://localhost:3000/api/eventos/999

💡 Dica Se você usa a extensão Thunder Client em vez de REST Client, a ideia é a mesma, mas a interface é uma aba própria no VS Code, com histórico de requisições e coleções salvas. Escolha a que preferir — o importante é testar toda rota antes de conectar o front.

Passo 5 — apontar o front-end da Aula 06 para a API real

No unieventos-web, você já tem uma instância dedicada do Axios (Aula 06). Troque só a baseURL:

JavaScript
// src/services/api.js — em unieventos-web
import axios from 'axios'

const api = axios.create({
  // antes: baseURL: 'http://localhost:3001' (json-server)
  baseURL: 'http://localhost:3000/api',
})

export default api

Nada mais no front-end precisa mudar — nem a store Pinia, nem os componentes. O service continua chamando api.get('/eventos') e api.get(/eventos/${id}); quem muda é só o destino das requisições. Esse desacoplamento é exatamente o motivo pelo qual a Aula 06 insistiu em centralizar o baseURL numa instância única, em vez de espalhar URLs pelo código.

Terminal
# em dois terminais separados:

# terminal 1 — dentro de unieventos-api
npm run dev

# terminal 2 — dentro de unieventos-web
npm run dev

Abra o front no navegador. A lista de eventos deve carregar exatamente como antes — só que agora vem de um servidor Express que você escreveu, não de um json-server.

🧪 Laboratório

1. Rota de saudação personalizada. Crie GET /api/saudacao/:nome que responde { "mensagem": "Olá, <nome>!" }, capitalizando a primeira letra do nome recebido.

Dica

Use req.params.nome e uma função para capitalizar: nome.charAt(0).toUpperCase() + nome.slice(1).

2. Filtro por categoria via query string. Modifique GET /api/eventos para aceitar ?categoria=palestra e retornar só os eventos daquela categoria. Sem o parâmetro, retorna todos.

Dica

Leia req.query.categoria (lembre-se: é somente leitura, não reatribua). Se estiver presente, filtre o array com .filter() antes de responder.

3. Contagem total no cabeçalho. Adicione um cabeçalho de resposta X-Total-Count com a quantidade de eventos retornados em GET /api/eventos, usando res.set('X-Total-Count', String(eventos.length)).

Dica

res.set(nome, valor) precisa vir antes de res.json(...), porque depois que o corpo é enviado os cabeçalhos não podem mais ser alterados.

4. Teste de erro proposital. Escreva uma rota GET /api/quebra que dá throw new Error('falha proposital') dentro de um handler async. Suba o servidor, acesse a rota e observe no terminal o que acontece — sem nenhum tratamento de erro escrito por você ainda.

Dica

No Express 5 isso não derruba o servidor: a resposta padrão é um HTML de erro 500. Guarde essa observação — na Aula 08 você substitui isso por um tratador de erros customizado.

5. Front consumindo a nova rota de filtro. No unieventos-web, adicione um <v-select> de categoria na Home e faça a requisição incluir params: { categoria } quando um filtro estiver selecionado (lembre do terceiro parâmetro do axios.get, visto na Aula 06).

Dica

api.get('/eventos', { params: { categoria: valorSelecionado } }) — se valorSelecionado for undefined ou string vazia, o Axios omite o parâmetro da URL automaticamente.

6. Explorando o Firestore com uma segunda coleção. Crie, pelo console do Firebase ou por script, uma coleção organizadores com pelo menos 2 documentos (nome, email). Escreva uma função listarOrganizadores() que usa getDocs para trazer todos e imprime no console. Depois, escreva buscarOrganizadorPorEmail(email) usando query + where('email', '==', email).

Dica

where sempre entra como argumento de query(colecao, where(...), ...) — não é um método encadeado como em outras bibliotecas. Lembre de importar where de 'firebase/firestore'.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
ReferenceError: require is not defined in ES module scope copiou código CommonJS num projeto com "type": "module" troque require/module.exports por import/export
EADDRINUSE: address already in use :::3000 outro processo já ocupa a porta 3000 finalize o processo anterior ou mude PORTA no .env
Front recebe erro de CORS no console do navegador esqueceu app.use(cors()) ou ele vem depois das rotas registre app.use(cors()) antes de declarar as rotas
req.body chega undefined express.json() não foi registrado, ou o Content-Type da requisição não é application/json adicione app.use(express.json()) antes das rotas; confira o cabeçalho enviado
TypeError: Cannot set property query of #<IncomingMessage> tentou reatribuir req.query diretamente (hábito do Express 4) crie uma variável nova a partir do valor lido, não reatribua req.query
Página em branco ao testar rota no navegador, sem erro no terminal esqueceu do return antes de um segundo res.status().json() na mesma função sempre return res... quando houver mais de uma resposta possível no handler
Firestore: getDocs retorna vazio mesmo com dados no console está lendo de uma coleção com nome diferente do usado ao gravar (ex.: Eventos vs eventos) nomes de coleção são case-sensitive; confira a grafia exata
FirebaseError: Missing or insufficient permissions as regras de segurança de teste do Firestore expiraram (30 dias) reabra o modo de teste no console ou escreva regras explícitas (Aula 10)
Cannot find module 'firebase/app' instalou uma versão muito antiga do pacote, ou digitou firebase-admin por engano no front confirme npm install firebase@12 no unieventos-web; firebase-admin é só para back-end (Aula 10)
Rota GET /api/eventos/:id sempre cai no "não encontrado" comparou req.params.id (string) com id (number) sem converter use Number(req.params.id) antes de comparar com ===
npm run dev não reinicia ao salvar o arquivo versão do Node anterior à 18.11, sem suporte a --watch rode node -v e atualize para a 22.x indicada na disciplina

🏠 Atividade assíncrona (1 h)

No seu projeto autoral, replique o que foi feito hoje:

  1. Crie o repositório <seu-projeto>-api, com package.json configurado em ESM, scripts dev/start, .env/.env.example/.gitignore.
  2. Monte um arquivo src/dados/<entidade-principal>.js com pelo menos 4 itens de exemplo do domínio do seu projeto autoral (ex.: se seu tema é "cardápio de restaurante", 4 pratos).
  3. Escreva src/servidor.js com Express 5, cors(), express.json(), e as duas rotas equivalentes: listar tudo e buscar por id.
  4. Teste as duas rotas com curl e com REST Client/Thunder Client — cole as evidências (prints ou saída do terminal) num arquivo EVIDENCIAS.md no repositório.
  5. Aponte o front-end do seu projeto autoral (já existente desde a Aula 06) para esta nova API, trocando só o baseURL.

Critério de pronto: os dois repositórios (-web e -api) rodando simultaneamente em portas diferentes, com a listagem do seu projeto autoral carregando dados vindos da sua própria API Express — nada de json-server a partir de agora.

✅ Checkpoint do projeto autoral

  • [ ] Repositório <seu-projeto>-api criado, com "type": "module" no package.json.
  • [ ] Servidor Express 5 rodando com npm run dev (usando node --watch).
  • [ ] .env e .env.example configurados; .env no .gitignore (nunca commitado).
  • [ ] cors() e express.json() registrados antes das rotas.
  • [ ] GET /api/<entidade> e GET /api/<entidade>/:id respondendo corretamente, inclusive o caso de id inexistente (404).
  • [ ] Front-end do projeto autoral consumindo essa API real via a instância Axios dedicada.
  • [ ] Nenhuma sintaxe de Express 4 (app.del, res.json(obj, 201), req.param()) presente no código.

📚 Para aprofundar

  • Documentação oficial do Node.js — nodejs.org/docs (seções Modules: ECMAScript modules e Command-line API para --watch e --env-file).
  • Documentação oficial do Express — expressjs.com e o guia de migração Express 5 changesexpressjs.com/en/guide/migrating-5.html.
  • Documentação oficial do Firebase — firebase.google.com/docs/web/setup e firebase.google.com/docs/firestore (API modular).
  • Referência de path-to-regexp v8, usado internamente pelo Express 5 para casar rotas — útil para entender a sintaxe de curingas e segmentos opcionais em profundidade.
  • Plano de curso FACET-SNP-310 — bibliografia básica, capítulos sobre arquitetura cliente-servidor e Node.js.

Na Aula 08 você transforma o servidor de hoje num CRUD completo, modulariza rotas com express.Router(), escreve middlewares próprios e recebe as instruções da Avaliação 2. Deixe a unieventos-api rodando — ela cresce a partir daqui, aula após aula, até virar a API do seu projeto autoral final.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EADFecha a unidade · Avaliação 2

Aula 08 — Definindo endpoints e middlewares

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 07 você criou a unieventos-api com Express 5, duas rotas GET em memória, CORS habilitado, e conectou o front-end real a ela. Hoje essa API vira um CRUD completo, ganha middlewares próprios e validação de entrada — e você recebe as instruções da Avaliação 2, com entrega até hoje às 23h59.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • projetar endpoints REST coerentes: recursos no plural, verbos HTTP corretos, status codes apropriados por operação;
  • implementar um CRUD completo (GET, POST, PUT, PATCH, DELETE) com Express 5, modularizado em express.Router();
  • escrever middlewares próprios — logger, medidor de tempo, validador, tratador de 404 e tratador de erros centralizado — entendendo a ordem de execução da cadeia;
  • explicar a diferença entre middleware de aplicação, de rota e de erro, e por que este último precisa vir por último;
  • validar corpos de requisição com zod, devolvendo 422 com mensagens em português;
  • entender por que throw dentro de um handler async do Express 5 cai automaticamente no tratador de erros;
  • organizar testes manuais num arquivo requests.http cobrindo todos os endpoints.

📋 Pré-requisitos desta aula

  • [ ] unieventos-api da Aula 07 rodando, com GET /api/eventos e GET /api/eventos/:id funcionando em memória.
  • [ ] Front-end unieventos-web apontando para essa API via baseURL do Axios.
  • [ ] Entendimento de async/await e por que erros em handlers async do Express 5 são capturados automaticamente (Aula 07).
  • [ ] Projeto autoral com API própria (<seu-projeto>-api) criada na atividade assíncrona da Aula 07.

⚠️ Atenção Esta é a aula da Avaliação 2. Leia a seção "📝 Avaliação 2 — instruções de entrega" logo no início do período de aula, para planejar seu tempo — o prazo de entrega é hoje, 07/10/2026, às 23h59.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min REST na prática: recursos, verbos, status codes, formato de resposta, paginação e filtros
2 50 min CRUD completo com express.Router(); middlewares próprios e de terceiros; tratador de erros central
3 50 min Validação com zod; requests.http completo; instruções da Avaliação 2

1. REST na prática

Você já usa APIs "estilo REST" desde a Aula 06, mas hoje é você quem projeta os endpoints. REST não é um protocolo com regras fixadas em pedra — é um conjunto de convenções que, seguidas com consistência, tornam uma API previsível para quem consome.

O nome vem de Representational State Transfer — a ideia central é que cada recurso do seu domínio (um evento, um usuário, uma inscrição) tem uma representação (o JSON que a API devolve) e um endereço próprio (a URL). O cliente manipula o estado do sistema transferindo representações desse recurso para lá e para cá, usando os verbos HTTP para expressar a intenção. Você não precisa decorar a definição formal — o que importa na prática são as convenções que seguem daqui.

Por que seguir convenção importa: quando toda API do mercado usa GET para ler e POST para criar, qualquer desenvolvedor que chega no seu projeto já sabe, sem ler documentação nenhuma, que POST /api/v1/eventos cria um evento. Quebrar essa expectativa (por exemplo, usando GET /api/deletarEvento?id=3 para apagar algo) obriga quem consome sua API a ler cada linha de código para entender o que uma rota faz — e, pior, faz com que caches e proxies HTTP, que assumem que GET nunca tem efeito colateral, possam repetir a chamada e apagar coisas sem querer.

Recursos: substantivos no plural, sempre

Um endpoint representa um recurso — uma entidade do seu domínio — nunca uma ação. O verbo da ação já está no método HTTP, não precisa (e não deve) repetir no caminho.

Texto
✅ GET    /api/eventos            (correto: recurso no plural, sem verbo)
❌ GET    /api/buscarEventos       (errado: verbo no caminho)
❌ GET    /api/evento               (errado: singular)

✅ POST   /api/eventos            (criar um evento)
❌ POST   /api/criarEvento          (errado: verbo redundante)

✅ DELETE /api/eventos/3          (remover o evento de id 3)
❌ GET    /api/deletarEvento?id=3  (errado: usa GET para uma ação destrutiva)

Sub-recursos seguem o mesmo padrão, aninhando o caminho:

Texto
GET /api/eventos/3/inscricoes     (inscrições do evento 3)
POST /api/eventos/3/inscricoes    (inscrever alguém no evento 3)

Verbos HTTP e o que cada um significa neste domínio

Verbo Uso no UniEventos Idempotente?
GET ler evento(s), sem efeito colateral sim
POST criar um evento novo não
PUT substituir um evento inteiro sim
PATCH atualizar campos específicos de um evento não, em geral
DELETE remover um evento sim

Idempotência significa: repetir a mesma requisição várias vezes produz o mesmo resultado final que executá-la uma vez. GET /api/eventos/3 sempre devolve o mesmo evento (até que ele mude por outro motivo) — chamar dez vezes não altera nada. DELETE /api/eventos/3 é idempotente porque, depois da primeira chamada, o evento já não existe; chamar de novo continua resultando em "evento 3 não existe" (ainda que a segunda chamada responda 404 em vez de 204 — o estado final do sistema é o mesmo). Já POST /api/eventos não é idempotente: cada chamada cria um evento novo, mesmo enviando o corpo idêntico.

📌 Na prova Se a pergunta pedir para classificar um verbo HTTP como idempotente ou não, lembre: GET, PUT, DELETE são idempotentes; POST não é. PATCH depende de como é implementado, mas normalmente também não é.

Status codes por operação

Operação Status de sucesso Quando falha
GET (lista ou item existente) 200 OK 404 se o item não existe
POST (criação) 201 Created + cabeçalho Location 422 se a validação falha
PUT/PATCH (atualização) 200 OK com o recurso atualizado 404 se não existe, 422 se inválido
DELETE (remoção) 204 No Content, sem corpo 404 se já não existe

O cabeçalho Location em uma criação bem-sucedida informa ao cliente onde o novo recurso pode ser lido depois — é uma convenção REST, não uma obrigação técnica, mas boas APIs seguem.

JavaScript
// dentro do handler de criação, depois de gerar o novo evento com id 7
res.status(201).location(`/api/eventos/${novoEvento.id}`).json(novoEvento)

⚠️ Atenção Lembre da armadilha do Express 5 vista na Aula 07: res.json(objeto, 201) não existe. A sintaxe correta é sempre res.status(201).json(objeto), com o status vindo antes, encadeado.

Versionamento

Prefixar rotas com /api/v1 sinaliza desde o início que a API pode evoluir sem quebrar clientes existentes — quando uma mudança incompatível for necessária, ela nasce em /api/v2, e /v1 continua funcionando para quem ainda depende dele.

JavaScript
app.use('/api/v1/eventos', eventosRoutes)

Esta disciplina usa /api sem versão explícita nos exemplos anteriores para simplificar, mas a partir de hoje adotamos /api/v1 na unieventos-api — é o padrão que se espera num projeto profissional, e é exigido na Avaliação 2.

Formato de resposta consistente

Uma API previsível responde sempre no mesmo formato — envelope de sucesso e envelope de erro —, para que o front-end trate qualquer resposta da mesma forma, sem checar caso a caso.

JSON
{
  "dados": { "id": 1, "titulo": "Semana Acadêmica de Computação" },
  "meta": { "pagina": 1, "limite": 10, "total": 42 }
}
JSON
{
  "erro": {
    "mensagem": "Evento não encontrado",
    "codigo": "EVENTO_NAO_ENCONTRADO"
  }
}

dados carrega o conteúdo (objeto único ou array); meta carrega metadados de paginação quando aplicável; erro só aparece em respostas de falha, nunca junto com dados. Vamos implementar exatamente esse envelope no CRUD desta aula.

O ganho prático aparece no front-end: um interceptor de resposta do Axios (Aula 06) pode, por exemplo, sempre extrair response.data.dados automaticamente, ou sempre reconhecer response.data.erro para disparar uma notificação padronizada — porque a forma nunca muda, só o conteúdo. Sem esse envelope, cada endpoint devolveria uma "forma" diferente (às vezes um array solto, às vezes um objeto solto, às vezes um objeto com results), obrigando o front a tratar cada chamada como um caso especial.

Paginação, filtros e ordenação por query string

Texto
GET /api/v1/eventos?pagina=2&limite=10
GET /api/v1/eventos?categoria=palestra
GET /api/v1/eventos?ordenarPor=dataHora&direcao=asc

Paginação evita devolver milhares de registros de uma vez — o cliente pede uma "página" por vez. Filtros restringem o conjunto por algum critério. Ordenação decide a sequência dos resultados. As três são independentes e combináveis na mesma URL. Vamos implementar isso no CRUD abaixo.

2. CRUD completo em memória

Vamos reescrever a unieventos-api da Aula 07, agora com o CRUD inteiro e o formato de resposta padronizado.

Dados em memória, com função de próximo id

JavaScript
// src/dados/eventos.js
export const eventos = [
  {
    id: 1,
    titulo: 'Semana Acadêmica de Computação',
    descricao: 'Palestras e minicursos sobre o mercado de tecnologia.',
    categoria: 'palestra',
    dataHora: '2026-10-15T19:00:00',
    local: 'Auditório FACET',
    vagas: 80,
    imagemUrl: 'https://picsum.photos/seed/semana-computacao/400/240',
  },
  {
    id: 2,
    titulo: 'Minicurso de Vue 3',
    descricao: 'Introdução prática ao framework Vue com Composition API.',
    categoria: 'minicurso',
    dataHora: '2026-10-20T14:00:00',
    local: 'Laboratório 3',
    vagas: 30,
    imagemUrl: 'https://picsum.photos/seed/minicurso-vue/400/240',
  },
  {
    id: 3,
    titulo: 'Workshop de Firebase e Express',
    descricao: 'Construindo uma API real do zero.',
    categoria: 'workshop',
    dataHora: '2026-10-28T19:30:00',
    local: 'Laboratório 1',
    vagas: 25,
    imagemUrl: 'https://picsum.photos/seed/workshop-firebase/400/240',
  },
]

// gera o próximo id disponível — em memória; na Aula 09 o próprio banco faz isso
export function proximoId() {
  const maiorId = eventos.reduce((max, e) => Math.max(max, e.id), 0)
  return maiorId + 1
}

Classe de erro HTTP customizada

Antes de escrever as rotas, criamos uma classe de erro que carrega o status HTTP junto da mensagem — assim qualquer parte do código pode throw um erro que já sabe se traduzir em resposta.

JavaScript
// src/erros/ErroHttp.js
export class ErroHttp extends Error {
  constructor(status, mensagem, codigo = 'ERRO') {
    super(mensagem)
    this.name = 'ErroHttp'
    this.status = status
    this.codigo = codigo
  }
}

// atalhos comuns, para não repetir "new ErroHttp(404, ...)" em todo lugar
export function erroNaoEncontrado(mensagem = 'Recurso não encontrado') {
  return new ErroHttp(404, mensagem, 'NAO_ENCONTRADO')
}

export function erroValidacao(mensagem = 'Dados inválidos') {
  return new ErroHttp(422, mensagem, 'VALIDACAO')
}

Rotas modularizadas com express.Router()

Até a Aula 07, as rotas viviam direto em src/servidor.js, registradas com app.get(...). Isso funciona para duas rotas; não escala para uma API com vários recursos, cada um com seu CRUD completo. express.Router() cria um "mini aplicativo Express" — um objeto que aceita .get(), .post(), .put(), .patch(), .delete() exatamente como app, mas que fica isolado num arquivo próprio, sem saber em qual prefixo vai ser montado.

Repare que dentro do arquivo de rotas os caminhos são relativos: router.get('/') e router.get('/:id'), sem repetir /api/v1/eventos. É só na hora de montar, em servidor.js, que o prefixo é definido:

JavaScript
app.use('/api/v1/eventos', eventosRoutes)

Isso significa que, se amanhã você decidir que a API deve responder em /api/v2/eventos também, basta montar o mesmo eventosRoutes num segundo prefixo — nenhuma rota interna precisa mudar.

JavaScript
// src/routes/eventos.routes.js
import { Router } from 'express'
import { eventos, proximoId } from '../dados/eventos.js'
import { erroNaoEncontrado, erroValidacao } from '../erros/ErroHttp.js'

const router = Router()

// função auxiliar: encontra o índice do evento pelo id, ou -1
function indiceDoEvento(id) {
  return eventos.findIndex((e) => e.id === id)
}

// GET /api/v1/eventos — lista com filtro, ordenação e paginação
router.get('/', (req, res) => {
  let resultado = [...eventos]

  // filtro por categoria
  if (req.query.categoria) {
    resultado = resultado.filter((e) => e.categoria === req.query.categoria)
  }

  // ordenação
  const ordenarPor = req.query.ordenarPor || 'id'
  const direcao = req.query.direcao === 'desc' ? -1 : 1
  resultado.sort((a, b) => {
    if (a[ordenarPor] < b[ordenarPor]) return -1 * direcao
    if (a[ordenarPor] > b[ordenarPor]) return 1 * direcao
    return 0
  })

  // paginação
  const pagina = Number(req.query.pagina) || 1
  const limite = Number(req.query.limite) || 10
  const inicio = (pagina - 1) * limite
  const pagina_de_resultados = resultado.slice(inicio, inicio + limite)

  res.json({
    dados: pagina_de_resultados,
    meta: { pagina, limite, total: resultado.length },
  })
})

// GET /api/v1/eventos/:id — busca um evento específico
router.get('/:id', (req, res) => {
  const id = Number(req.params.id)
  const evento = eventos.find((e) => e.id === id)

  if (!evento) {
    throw erroNaoEncontrado('Evento não encontrado')
  }

  res.json({ dados: evento })
})

// POST /api/v1/eventos — cria um evento novo
router.post('/', (req, res) => {
  const corpo = req.body

  if (!corpo || !corpo.titulo || !corpo.categoria) {
    throw erroValidacao('Campos "titulo" e "categoria" são obrigatórios')
  }

  const novoEvento = {
    id: proximoId(),
    titulo: corpo.titulo,
    descricao: corpo.descricao || '',
    categoria: corpo.categoria,
    dataHora: corpo.dataHora || null,
    local: corpo.local || '',
    vagas: Number(corpo.vagas) || 0,
    imagemUrl: corpo.imagemUrl || '',
  }

  eventos.push(novoEvento)

  res
    .status(201)
    .location(`/api/v1/eventos/${novoEvento.id}`)
    .json({ dados: novoEvento })
})

// PUT /api/v1/eventos/:id — substitui o evento inteiro
router.put('/:id', (req, res) => {
  const id = Number(req.params.id)
  const indice = indiceDoEvento(id)

  if (indice === -1) {
    throw erroNaoEncontrado('Evento não encontrado')
  }

  const corpo = req.body
  if (!corpo || !corpo.titulo || !corpo.categoria) {
    throw erroValidacao('Campos "titulo" e "categoria" são obrigatórios')
  }

  eventos[indice] = {
    id,
    titulo: corpo.titulo,
    descricao: corpo.descricao || '',
    categoria: corpo.categoria,
    dataHora: corpo.dataHora || null,
    local: corpo.local || '',
    vagas: Number(corpo.vagas) || 0,
    imagemUrl: corpo.imagemUrl || '',
  }

  res.json({ dados: eventos[indice] })
})

// PATCH /api/v1/eventos/:id — atualiza campos específicos
router.patch('/:id', (req, res) => {
  const id = Number(req.params.id)
  const indice = indiceDoEvento(id)

  if (indice === -1) {
    throw erroNaoEncontrado('Evento não encontrado')
  }

  // mescla só os campos enviados — PATCH é parcial, diferente de PUT
  eventos[indice] = { ...eventos[indice], ...req.body }

  res.json({ dados: eventos[indice] })
})

// DELETE /api/v1/eventos/:id — remove o evento
router.delete('/:id', (req, res) => {
  const id = Number(req.params.id)
  const indice = indiceDoEvento(id)

  if (indice === -1) {
    throw erroNaoEncontrado('Evento não encontrado')
  }

  eventos.splice(indice, 1)

  res.status(204).send()
})

export default router

⚠️ Atenção Note router.delete(...), não router.del(...). app.del/router.del foram removidos no Express 5 (Aula 07, §5).

Montando o Router no servidor

JavaScript
// src/servidor.js
import express from 'express'
import cors from 'cors'
import eventosRoutes from './routes/eventos.routes.js'
import { middlewareNaoEncontrado, tratadorDeErros } from './middlewares/erros.js'
import { logger } from './middlewares/logger.js'
import { medidorDeTempo } from './middlewares/medidorDeTempo.js'

const app = express()

app.use(cors())
app.use(express.json())
app.use(logger)
app.use(medidorDeTempo)

// monta o router em /api/v1/eventos — dentro do router, as rotas usam caminhos relativos
app.use('/api/v1/eventos', eventosRoutes)

// a partir daqui, nenhuma rota casou: 404
app.use(middlewareNaoEncontrado)

// tratador de erros SEMPRE por último
app.use(tratadorDeErros)

const porta = process.env.PORTA || 3000

app.listen(porta, () => {
  console.log(`unieventos-api rodando em http://localhost:${porta}`)
})

Modularizar com express.Router() separa a definição das rotas de eventos do arquivo principal do servidor. Isso escala: cada recurso (eventos, e futuramente inscricoes, usuarios) ganha seu próprio arquivo de rotas, e servidor.js só monta cada um em seu prefixo.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Chain of Responsibility e Strategy

A cadeia cors → express.json → logger → medidorDeTempo → eventosRoutes → middlewareNaoEncontrado → tratadorDeErros é o Chain of Responsibility completo: cada middleware decide se processa a requisição e a passa adiante com next(), ou se responde e encerra a cadeia ali. A ordem importa — é o próprio desenho do padrão: cada elo só recebe a requisição se o anterior decidiu repassá-la.

Já os validadores de corpo que vamos construir com zod ilustram o Strategy (comportamental): a função validar(schema) é genérica — ela não sabe nada sobre "evento" —, e recebe de fora, como parâmetro, a estratégia de validação específica (o schema Zod do evento, do usuário, do que for). Trocar a validação de uma rota é só trocar o schema passado, sem tocar no middleware validar. Isso é Strategy: o algoritmo (validação) é injetado, intercambiável, sem alterar quem o usa.

3. Middlewares a fundo

Um middleware é uma função com a assinatura (req, res, next) — ou (err, req, res, next) no caso especial de tratador de erros, com quatro argumentos. Ele roda entre a chegada da requisição e a resposta final, podendo:

  • ler ou modificar req/res;
  • encerrar o ciclo respondendo diretamente (res.send(), res.json(), etc.);
  • passar a bola adiante chamando next();
  • passar um erro adiante chamando next(erro) (embora no Express 5, como vimos, um throw dentro de um handler async já faz isso sozinho).

Pense em cada middleware como uma estação de inspeção numa linha de produção. A requisição entra por um lado, passa estação por estação, e cada uma pode carimbá-la (adicionar algo a req), rejeitá-la ali mesmo (responder e nunca chamar next()) ou deixá-la seguir para a próxima estação. Uma rota (router.get, router.post, etc.) é só a última estação da linha — a que finalmente produz uma resposta para o cliente, na maioria das requisições.

Ordem de execução

Middlewares rodam na ordem em que são registrados com app.use() ou dentro de uma rota. Se um middleware não chamar next() nem responder, a requisição fica pendurada para sempre — esse é o erro mais comum ao escrever middleware pela primeira vez.

JavaScript
app.use(cors())           // 1º: libera CORS
app.use(express.json())   // 2º: faz o parse do corpo
app.use(logger)           // 3º: registra a requisição no console
app.use(medidorDeTempo)   // 4º: começa a medir o tempo de resposta
app.use('/api/v1/eventos', eventosRoutes)  // 5º: tenta casar com alguma rota de evento
app.use(middlewareNaoEncontrado)  // 6º: só roda se nada casou acima
app.use(tratadorDeErros)          // 7º: só roda se algo lançou erro em qualquer ponto anterior

Middleware de aplicação × de rota × de erro

Middleware de aplicação roda para toda requisição, registrado direto em app.use(fn), sem caminho — é o caso de cors(), express.json(), logger.

Middleware de rota roda só para requisições que casam com um caminho e método específicos, registrado como argumento extra antes do handler final:

JavaScript
// middleware de rota: só roda para POST /api/v1/eventos
router.post('/', validar(schemaEvento), (req, res) => {
  // aqui req.body já passou pela validação
})

Middleware de erro tem quatro parâmetros — (err, req, res, next) — e o Express o reconhece pela aridade da função (contagem de parâmetros), não por onde está registrado. Ele só é chamado quando algum middleware ou handler anterior invoca next(erro) ou lança uma exceção (capturada automaticamente em handlers async, como vimos).

Por que o tratador de erros vem por último

O Express testa os middlewares registrados na ordem em que aparecem. Um middleware de erro só é alcançado quando a cadeia "pula" para ele — o que acontece quando algo dá errado em qualquer ponto anterior. Se você registrar o tratador de erros antes de uma rota, ele nunca vai capturar os erros dela, porque a execução normal (sem erro) nem chega a considerá-lo — e mesmo em caso de erro, o Express busca o próximo middleware de erro à frente na cadeia, nunca voltando para trás. Por isso a regra é fixa: middlewares normais primeiro, depois o 404 (que captura tudo que não casou com nenhuma rota), depois o tratador de erros por último de todos.

Texto
requisição
    │
    ▼
  cors() ──────────────► ok, next()
    │
    ▼
  express.json() ──────► ok, next()
    │
    ▼
  eventosRoutes ───────► lançou erro (throw)
    │                         │
    │            Express pula direto para o
    │            próximo middleware DE ERRO
    │                         │
    ▼                         ▼
  middlewareNaoEncontrado   tratadorDeErros
  (não roda: já tinha        (roda: recebe o erro,
   uma rota que casou)        responde ao cliente)

Se middlewareNaoEncontrado estivesse depois de tratadorDeErros, ele nunca seria alcançado no caminho de erro — e se estivesse antes das rotas, capturaria toda requisição como "não encontrada", mesmo as que tinham rota válida. A ordem — rotas, depois 404, depois tratador de erros — não é estilo, é a única ordem que faz os três cumprirem seu papel corretamente.

Escrevendo os middlewares do zero

JavaScript
// src/middlewares/logger.js
// registra método, caminho e horário de cada requisição recebida
export function logger(req, res, next) {
  const agora = new Date().toISOString()
  console.log(`[${agora}] ${req.method} ${req.originalUrl}`)
  next()
}
JavaScript
// src/middlewares/medidorDeTempo.js
// mede quanto tempo o servidor levou para responder, em milissegundos
export function medidorDeTempo(req, res, next) {
  const inicio = process.hrtime.bigint()

  // 'finish' dispara quando a resposta terminou de ser enviada
  res.on('finish', () => {
    const fim = process.hrtime.bigint()
    const duracaoMs = Number(fim - inicio) / 1_000_000
    console.log(`  ↳ ${res.statusCode} em ${duracaoMs.toFixed(1)}ms`)
  })

  next()
}
JavaScript
// src/middlewares/erros.js
import { ErroHttp } from '../erros/ErroHttp.js'

// roda quando nenhuma rota casou com a requisição — precisa vir depois de todas as rotas
export function middlewareNaoEncontrado(req, res, next) {
  next(new ErroHttp(404, `Rota ${req.method} ${req.originalUrl} não existe`, 'ROTA_NAO_ENCONTRADA'))
}

// tratador de erros central — repare nos QUATRO parâmetros, é assim que o Express o reconhece
export function tratadorDeErros(err, req, res, next) {
  // erros conhecidos (ErroHttp) já sabem seu status; erros inesperados viram 500
  const status = err instanceof ErroHttp ? err.status : 500
  const codigo = err instanceof ErroHttp ? err.codigo : 'ERRO_INTERNO'
  const mensagem = err instanceof ErroHttp ? err.message : 'Erro interno do servidor'

  if (status === 500) {
    // erro inesperado: registre o stack completo no servidor, mas não exponha ao cliente
    console.error(err)
  }

  res.status(status).json({ erro: { mensagem, codigo } })
}
JavaScript
// src/middlewares/validador.js
export function validar(schema) {
  // retorna um middleware de rota configurado para o schema recebido — isto é Strategy
  return (req, res, next) => {
    const resultado = schema.safeParse(req.body)

    if (!resultado.success) {
      const mensagens = resultado.error.issues.map((problema) => problema.message)
      return res.status(422).json({
        erro: { mensagem: 'Dados inválidos', codigo: 'VALIDACAO', detalhes: mensagens },
      })
    }

    // substitui req.body pelos dados já validados e tipados pelo Zod
    req.body = resultado.data
    next()
  }
}

Middlewares de terceiros

Terminal
npm install morgan helmet express-rate-limit compression
JavaScript
// src/servidor.js (trecho adicional)
import morgan from 'morgan'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import compression from 'compression'

app.use(helmet())            // cabeçalhos de segurança padrão (evita alguns ataques comuns)
app.use(compression())       // comprime respostas grandes (gzip) — mais rápido para o cliente
app.use(morgan('dev'))       // log de requisições formatado — mais completo que nosso logger

const limitador = rateLimit({
  windowMs: 15 * 60 * 1000,  // janela de 15 minutos
  max: 100,                   // no máximo 100 requisições por IP nessa janela
  message: { erro: { mensagem: 'Muitas requisições, tente novamente mais tarde', codigo: 'RATE_LIMIT' } },
})
app.use('/api/', limitador)  // aplica o limite só nas rotas de API
Pacote Para que serve
cors libera requisições de outras origens (front em outra porta/domínio)
morgan log de requisições HTTP formatado (substitui nosso logger em produção)
helmet adiciona cabeçalhos HTTP de segurança (proteção básica contra alguns ataques)
express-rate-limit limita quantas requisições um IP pode fazer numa janela de tempo
compression comprime o corpo das respostas (gzip), reduzindo tráfego

💡 Dica morgan('dev') e nosso logger/medidorDeTempo fazem trabalho parecido. Escrever o seu próprio primeiro é pedagógico — mostra o que acontece por baixo —, mas em projetos reais é comum usar só morgan, já testado e configurável.

4. Async no Express 5, revisitado

Na Aula 07 você viu que throw dentro de um handler async cai automaticamente no tratador de erros — e testou isso no laboratório com a rota /api/quebra. Agora, com um tratador de erros de verdade escrito, o comportamento fica completo:

JavaScript
// Express 5: qualquer throw, síncrono ou dentro de um await, é capturado
router.get('/:id', async (req, res) => {
  const evento = await buscarEventoPorIdNoBanco(req.params.id)  // função hipotética assíncrona
  if (!evento) {
    throw erroNaoEncontrado('Evento não encontrado')
  }
  res.json({ dados: evento })
})

Se buscarEventoPorIdNoBanco rejeitasse a Promise (por exemplo, uma falha de conexão), o Express 5 também capturaria automaticamente e encaminharia para tratadorDeErros. Nenhum try/catch manual é necessário para isso — o framework embrulha cada handler async internamente.

Por que tanto código por aí usa express-async-handler então? Porque esse pacote foi criado para o Express 4, que não tinha essa captura automática — era preciso embrulhar manualmente cada handler assíncrono:

JavaScript
// Express 4 (não use): precisava embrulhar manualmente
// const asyncHandler = require('express-async-handler')
// router.get('/:id', asyncHandler(async (req, res) => { ... }))

No Express 5, esse pacote é desnecessário. Se você encontrar em um projeto ou tutorial, é sinal de código escrito para Express 4 (ou copiado de um).

📌 Na prova Se perguntarem por que express-async-handler não é mais necessário no Express 5, a resposta é: o próprio framework agora captura automaticamente qualquer exceção lançada (ou Promise rejeitada) dentro de um handler async, encaminhando para o middleware de erro — antes isso exigia embrulhar manualmente.

💻 Mão na massa — validação com Zod e testes organizados

Passo 1 — instalar e escrever o schema

Terminal
npm install zod
JavaScript
// src/schemas/evento.schema.js
import { z } from 'zod'

export const schemaEvento = z.object({
  titulo: z.string().min(3, 'O título precisa ter ao menos 3 caracteres'),
  descricao: z.string().optional(),
  categoria: z.enum(['palestra', 'minicurso', 'workshop'], {
    message: 'Categoria deve ser palestra, minicurso ou workshop',
  }),
  dataHora: z.string().min(1, 'Informe a data e hora do evento'),
  local: z.string().min(1, 'Informe o local do evento'),
  vagas: z.number({ message: 'Vagas deve ser um número' }).int().positive('Vagas deve ser maior que zero'),
  imagemUrl: z.string().url('URL de imagem inválida').optional().or(z.literal('')),
})

// schema para PATCH: os mesmos campos, mas todos opcionais
export const schemaEventoParcial = schemaEvento.partial()

Passo 2 — aplicar o middleware validar nas rotas

JavaScript
// src/routes/eventos.routes.js (trechos alterados)
import { Router } from 'express'
import { eventos, proximoId } from '../dados/eventos.js'
import { erroNaoEncontrado } from '../erros/ErroHttp.js'
import { validar } from '../middlewares/validador.js'
import { schemaEvento, schemaEventoParcial } from '../schemas/evento.schema.js'

const router = Router()

// ...rotas GET permanecem como antes...

router.post('/', validar(schemaEvento), (req, res) => {
  // req.body já chega validado e com os tipos corretos (vagas já é number, por exemplo)
  const novoEvento = { id: proximoId(), ...req.body }
  eventos.push(novoEvento)
  res.status(201).location(`/api/v1/eventos/${novoEvento.id}`).json({ dados: novoEvento })
})

router.put('/:id', validar(schemaEvento), (req, res) => {
  const id = Number(req.params.id)
  const indice = eventos.findIndex((e) => e.id === id)
  if (indice === -1) throw erroNaoEncontrado('Evento não encontrado')

  eventos[indice] = { id, ...req.body }
  res.json({ dados: eventos[indice] })
})

router.patch('/:id', validar(schemaEventoParcial), (req, res) => {
  const id = Number(req.params.id)
  const indice = eventos.findIndex((e) => e.id === id)
  if (indice === -1) throw erroNaoEncontrado('Evento não encontrado')

  eventos[indice] = { ...eventos[indice], ...req.body }
  res.json({ dados: eventos[indice] })
})

export default router

Com validar(schemaEvento) na frente do handler, o corpo malformado nunca chega a ser processado pela lógica de negócio — a validação já respondeu 422 e encerrou a cadeia antes disso.

Passo 3 — requests.http completo

HTTP
### requests.http — todos os endpoints da unieventos-api

@baseUrl = http://localhost:3000/api/v1

### listar eventos (com paginação, filtro e ordenação)
GET {{baseUrl}}/eventos?pagina=1&limite=10&categoria=palestra&ordenarPor=dataHora&direcao=asc

### buscar evento por id
GET {{baseUrl}}/eventos/1

### buscar evento inexistente (espera 404)
GET {{baseUrl}}/eventos/999

### criar evento válido (espera 201 + Location)
POST {{baseUrl}}/eventos
Content-Type: application/json

{
  "titulo": "Palestra de Segurança da Informação",
  "categoria": "palestra",
  "dataHora": "2026-11-05T19:00:00",
  "local": "Auditório FACET",
  "vagas": 60
}

### criar evento inválido (espera 422)
POST {{baseUrl}}/eventos
Content-Type: application/json

{
  "titulo": "AB",
  "categoria": "show"
}

### substituir evento inteiro (espera 200)
PUT {{baseUrl}}/eventos/1
Content-Type: application/json

{
  "titulo": "Semana Acadêmica de Computação — atualizada",
  "categoria": "palestra",
  "dataHora": "2026-10-16T19:00:00",
  "local": "Auditório FACET",
  "vagas": 100
}

### atualizar parcialmente (espera 200)
PATCH {{baseUrl}}/eventos/2
Content-Type: application/json

{
  "vagas": 25
}

### remover evento (espera 204)
DELETE {{baseUrl}}/eventos/3

### remover evento já removido (espera 404)
DELETE {{baseUrl}}/eventos/3

### rota inexistente (espera 404 do middlewareNaoEncontrado)
GET {{baseUrl}}/qualquer-coisa

💡 Dica A variável @baseUrl no topo do arquivo evita repetir http://localhost:3000/api/v1 em toda linha — troque só ali quando mudar de ambiente (local, homologação, produção).

🧪 Laboratório

1. Endpoint de contagem por categoria. Crie GET /api/v1/eventos/estatisticas/por-categoria que devolve { "dados": { "palestra": 2, "minicurso": 1, "workshop": 1 } }, contando quantos eventos existem em cada categoria.

Dica

Cuidado com a ordem: registre essa rota antes de router.get('/:id', ...), senão o Express interpreta estatisticas como um valor de :id.

2. Middleware de log condicional. Modifique o logger para só imprimir requisições cujo método seja POST, PUT, PATCH ou DELETE (as que alteram dados) — omita GET.

Dica

Um if (req.method !== 'GET') { ... } dentro do middleware, antes de chamar next().

3. Erro de validação com múltiplos campos. Envie, pelo requests.http, um POST /api/v1/eventos com titulo vazio e categoria inválida ao mesmo tempo. Confirme que a resposta 422 lista as duas mensagens de erro no array detalhes.

Dica

O Zod, por padrão, coleta todos os problemas antes de falhar — não para no primeiro. resultado.error.issues é um array com um item por campo problemático.

4. Rate limit em ação. Reduza temporariamente o max do express-rate-limit para 5 e a windowMs para 60000 (1 minuto). Dispare mais de 5 requisições seguidas com curl num loop e observe a resposta 429 Too Many Requests. Depois volte os valores originais.

Dica
Terminal
for i in 1 2 3 4 5 6 7; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/eventos; done

5. PATCH que tenta mudar o id. Envie PATCH /api/v1/eventos/1 com corpo { "id": 999 }. Verifique o que acontece com o registro em memória. Corrija o handler para ignorar qualquer id enviado no corpo (o id da URL é sempre a fonte da verdade).

Dica

Depois do merge ({ ...eventos[indice], ...req.body }), force eventos[indice].id = id (o id da URL, já convertido para número) por cima, sobrescrevendo qualquer valor vindo do corpo.

6. Middleware de erro específico para JSON malformado. Envie, via curl, um POST /api/v1/eventos com corpo JSON propositalmente quebrado (ex.: {"titulo": "teste",} com vírgula sobrando). Observe qual status volta. express.json() lança um erro de parsing antes mesmo de sua rota rodar — confirme que esse erro também é capturado pelo seu tratadorDeErros, e ajuste a mensagem para ficar amigável ("corpo da requisição não é um JSON válido") quando o erro vier do parser.

Dica

O erro lançado pelo express.json() tem err.type === 'entity.parse.failed'. No tratadorDeErros, adicione uma verificação extra antes da checagem de ErroHttp: if (err.type === 'entity.parse.failed') { return res.status(400).json({ erro: { mensagem: 'JSON inválido no corpo da requisição', codigo: 'JSON_INVALIDO' } }) }.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
GET /eventos/estatisticas cai no handler de /:id rota com parâmetro dinâmico registrada antes da rota fixa registre rotas fixas (/estatisticas/...) antes de rotas com :id
Middleware "trava" a requisição, sem resposta nem erro esqueceu de chamar next() dentro do middleware toda função de middleware precisa terminar chamando next() ou respondendo diretamente
Tratador de erros nunca é chamado ele foi registrado antes das rotas, ou tem menos de 4 parâmetros mova app.use(tratadorDeErros) para o fim; confira a assinatura (err, req, res, next)
422 não aparece, servidor retorna 500 schema Zod não corresponde ao formato de req.body, gerando erro diferente do esperado confira o schema campo a campo; teste isoladamente com schema.safeParse(objetoDeTeste)
POST retorna 201 mas sem Location no cabeçalho esqueceu .location(...) antes de .json(...) encadeie res.status(201).location(url).json(dados)
express-rate-limit bloqueia até requisições legítimas em sala max configurado baixo demais para uma turma toda testando do mesmo IP/rede aumente max durante a aula, ou aplique o limitador só em rotas de escrita
zod: erro Cannot read properties of undefined (reading 'issues') schema.safeParse não foi usado (usou schema.parse, que lança exceção em vez de devolver objeto) use sempre safeParse no middleware validar, para tratar o erro manualmente
DELETE retorna 204 mas o corpo aparece vazio "errado" no REST Client comportamento esperado — 204 No Content nunca deve ter corpo confirme com res.status(204).send() sem argumento; não chame .json() depois de 204
Duas rotas parecem casar com a mesma URL, só a primeira responde ordem de registro determina qual middleware/rota atende primeiro reordene: rotas mais específicas antes das mais genéricas
Front-end para de funcionar depois de adicionar helmet() helmet por padrão bloqueia carregamento de alguns recursos cross-origin ajuste as políticas de helmet conforme a necessidade, ou mantenha o padrão em desenvolvimento e ajuste caso a caso

📝 Avaliação 2 — instruções de entrega

Escopo. Uma aplicação Vue 3 completa, consumindo uma API (a sua, em memória ou já com Firestore — MySQL só é exigido a partir da Aula 09), sobre o projeto autoral de cada estudante (não o UniEventos, que é o exemplo do professor).

Requisitos obrigatórios:

  • Vuetify para toda a interface (nenhum CSS puro estrutural fora do Vuetify, exceto ajustes pontuais).
  • Vue Router, com no mínimo 4 rotas (ex.: Home, Detalhe, Formulário de criação/edição, uma quarta rota própria do domínio — listagem filtrada, painel, etc.).
  • No mínimo 6 componentes próprios (.vue autorais, além dos componentes do Vuetify) — componentes de card, formulário, lista, filtro, layout, etc.
  • Uma store Pinia com estado assíncrono: ações que chamam a API, estados de carregando e erro, getters quando fizer sentido.
  • Axios com instância dedicada (axios.create) e ao menos um interceptor.
  • Consumo de API com tratamento visível de carregando / erro / vazio (três estados, não só o caminho feliz) em pelo menos uma tela de listagem.
  • Formulário com validação (Vuetify rules ou biblioteca de validação) para criar ou editar um registro do domínio.
  • Layout responsivo — funcional em tela de celular e de desktop, usando o sistema de grid do Vuetify.

Rubrica:

Critério Peso
Vue Router — 4+ rotas, navegação coerente, guards se aplicável 1,5
Componentização — 6+ componentes próprios, props/emits corretos 2,0
Pinia — store com estado assíncrono, carregando/erro tratados 2,0
Axios — instância dedicada, interceptor, integração com a store 1,5
Vuetify — uso consistente, responsividade 1,5
Formulário com validação funcionando 1,0
Organização do código e commits (histórico git coerente) 0,5

Total: 10,0 pontos.

Formato de entrega. Link do repositório Git (GitHub, GitLab ou similar), público ou com acesso liberado para o professor, enviado via SIGAA, no campo de entrega da Avaliação 2. O README.md do repositório deve conter: nome do projeto autoral, instruções de instalação (npm install, npm run dev) e uma breve descrição do domínio escolhido.

Prazo. Até 07/10/2026, 23h59, horário de Brasília. O SIGAA registra o horário da submissão — entregas após o prazo entram na política de atraso abaixo.

Política de atraso. Cada 24h de atraso desconta 1,0 ponto da nota final da avaliação, até o limite de 5 dias corridos; após esse prazo, a atividade recebe nota zero, salvo justificativa formal (atestado médico ou similar) protocolada junto à coordenação.

Política de plágio e uso de IA. É permitido usar ferramentas de IA como apoio (explicar um erro, sugerir uma correção pontual, revisar um trecho) — é o mesmo tipo de apoio que se espera de qualquer ferramenta de desenvolvimento moderna. Não é permitido entregar um projeto majoritariamente gerado por IA sem compreensão do próprio código: na correção, o professor pode fazer perguntas orais sobre qualquer trecho entregue, e a incapacidade de explicar decisões básicas do próprio código (por que essa rota, por que essa store, o que faz esse computed) resulta em revisão da nota. Cópia entre colegas — código idêntico ou com alterações cosméticas — resulta em nota zero para todos os envolvidos, sem exceção.

🏠 Atividade assíncrona (1 h)

Além de finalizar e entregar a Avaliação 2, use esta hora para:

  1. Adicionar ao seu requests.http autoral os casos de erro esperados (404, 422) — não só o caminho feliz.
  2. Rodar o laboratório de rate limit (exercício 4) no seu próprio projeto, confirmando que o 429 aparece.
  3. Revisar seu tratador de erros: force um erro inesperado (ex.: acesse uma propriedade de undefined de propósito dentro de uma rota) e confirme que a resposta chega como 500 com o envelope { "erro": { ... } }, sem vazar o stack trace para o cliente.

Critério de pronto: sua API autoral tem CRUD completo, middlewares próprios funcionando na ordem correta, validação com Zod retornando 422 com mensagens claras, e a Avaliação 2 já submetida no SIGAA.

✅ Checkpoint do projeto autoral

  • [ ] CRUD completo (GET lista, GET por id, POST, PUT, PATCH, DELETE) funcionando na sua API autoral.
  • [ ] Rotas modularizadas com express.Router(), montadas com prefixo /api/v1/<recurso>.
  • [ ] Middlewares próprios (logger, medidorDeTempo, middlewareNaoEncontrado, tratadorDeErros) escritos e na ordem correta.
  • [ ] Validação de entrada com zod, retornando 422 com mensagens em português.
  • [ ] requests.http cobrindo todos os endpoints, inclusive casos de erro.
  • [ ] Front-end autoral consumindo o CRUD completo, com tratamento de carregando/erro/vazio.
  • [ ] Avaliação 2 entregue via SIGAA.

📚 Para aprofundar

Na Aula 09 os dados em memória desta API saem de cena: você migra tudo para MySQL, com pool de conexões, consultas parametrizadas e camada de repositório — mantendo os mesmos contratos de endpoint, para o front-end não perceber a diferença.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 09 — Integrando com SGBD MySQL

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 08 você entregou a Avaliação 2 com um CRUD completo, middlewares próprios e validação com Zod — tudo isso guardando dados num array em memória. Hoje esse array desaparece. Toda a unieventos-api passa a persistir em um banco de dados relacional de verdade: MySQL.

Vale reforçar o que muda e o que não muda hoje. O que muda: de onde os dados vêm e para onde vão — de um array na RAM para tabelas em disco, com todas as garantias que isso traz. O que não muda: o formato de cada requisição, o formato de cada resposta, os status codes, as rotas, os middlewares de validação e de erro. Esse é o teste que valida se você fez a migração corretamente — se o requests.http da Aula 08 continuar passando sem editar uma linha sequer, a API está correta.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • explicar por que dados em memória não servem para uma aplicação real, e o que persistência significa;
  • modelar as tabelas do UniEventos em MySQL, com chaves primárias, estrangeiras, tipos, restrições e índices;
  • instalar e configurar um servidor MySQL (nativo ou via Docker) e conectar-se a ele com uma ferramenta gráfica;
  • criar um pool de conexões com mysql2/promise, entendendo por que pool e não conexão única;
  • escrever consultas parametrizadas, evitando SQL injection por concatenação de string;
  • ler resultados de INSERT, UPDATE, DELETE e SELECT corretamente com a API baseada em Promise;
  • usar transações para operações que precisam ser atômicas (inscrever em evento e decrementar vagas);
  • organizar o back-end em camadas — repositório, serviço, controlador — migrando o CRUD da Aula 08 sem quebrar contrato nenhum com o front-end.

📋 Pré-requisitos desta aula

  • [ ] unieventos-api da Aula 08, com CRUD completo em memória, middlewares e validação Zod funcionando.
  • [ ] requests.http cobrindo todos os endpoints (Aula 08).
  • [ ] Avaliação 2 entregue.
  • [ ] Modelagem relacional revisada: entidade, atributo, chave primária e estrangeira (conteúdo de disciplinas anteriores de banco de dados — hoje é aplicação, não introdução).
  • [ ] Máquina com privilégios de administrador para instalar o MySQL (ou Docker instalado, como alternativa).
  • [ ] Ao menos uma ferramenta gráfica de banco escolhida (MySQL Workbench, DBeaver ou a extensão do VS Code) para inspecionar tabelas visualmente durante a aula.

⚠️ Atenção Nunca commite senha de banco de dados no repositório. Toda credencial desta aula vive em .env, fora do controle de versão. Se você acidentalmente commitar uma senha, troque-a imediatamente — trocar a senha é mais rápido e mais seguro do que tentar "remover" o commit do histórico.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Modelagem relacional do UniEventos; instalação do MySQL; schema.sql completo
2 50 min mysql2/promise: pool, consultas parametrizadas, SQL injection, transações
3 50 min Camadas repositório/serviço/controlador; migração dos endpoints da Aula 08 para MySQL

1. Por que dados em memória não servem

O array eventos das Aulas 07 e 08 vive na memória RAM do processo Node. Isso tem três problemas fatais para uso real:

Não sobrevive a um reinício. Toda vez que você reinicia o servidor (ou ele cai por qualquer motivo), o array volta ao estado inicial do código-fonte — qualquer evento criado, editado ou removido durante a execução se perde.

Não escala para múltiplas instâncias. Se você um dia rodar duas cópias da API (para atender mais tráfego), cada cópia tem seu próprio array, na sua própria memória — uma não sabe o que a outra gravou. Os dados ficam inconsistentes entre instâncias.

Não sobrevive a um deploy. Publicar uma nova versão do código normalmente significa derrubar o processo antigo e subir um novo — e o processo novo começa com o array do código, não com o estado anterior.

Persistência é a propriedade de dados sobreviverem além do tempo de vida do processo que os manipula. Um banco de dados é software especializado exatamente nisso: gravar em disco (ou em memória de forma replicada e durável) de um jeito que sobrevive a reinícios, crashes e múltiplas instâncias acessando ao mesmo tempo — com garantias de consistência que um array simples não oferece.

Por que relacional, e por que MySQL

O UniEventos tem entidades com relações claras entre si: um evento tem várias inscrições; uma inscrição pertence a um evento e a um usuário. Esse tipo de relação — um-para-muitos, muitos-para-muitos — é exatamente o que um banco de dados relacional (SGBD — Sistema Gerenciador de Banco de Dados) modela bem, com chaves estrangeiras garantindo a integridade dessas relações no próprio banco, não só no código da aplicação.

MySQL é um dos SGBDs relacionais mais usados no mercado, de código aberto, com décadas de maturidade. A disciplina usa a versão 8, com o driver mysql2 (Node) na versão 3.23, sempre pelo submódulo mysql2/promise — a variante que devolve Promises em vez de exigir callbacks, compatível com async/await, no mesmo estilo que você já usa desde a Aula 01.

🔎 Por baixo do capô Você já viu o Firestore (Aula 07) como alternativa de persistência. A diferença central: o Firestore é um banco NoSQL orientado a documentos — cada documento é um JSON flexível, sem schema fixo entre documentos da mesma coleção, e relações entre coleções são geridas manualmente pela aplicação. Um SGBD relacional como o MySQL exige schema definido antes de inserir dados (as tabelas do script abaixo), mas em troca oferece integridade referencial garantida pelo próprio banco (FOREIGN KEY), consultas relacionais poderosas (JOIN) e transações ACID robustas. Nenhum dos dois é "melhor" em absoluto — a escolha depende do formato dos dados e das garantias que a aplicação precisa. O UniEventos usa MySQL a partir de hoje porque suas entidades são fortemente relacionadas (evento ↔ inscrição ↔ usuário), o caso de uso clássico para modelagem relacional.

2. Modelagem relacional aplicada ao UniEventos

Relembrando o modelo de dados do projeto (Aula 07, §3), as três entidades centrais do UniEventos:

  • eventos — os eventos acadêmicos: palestras, minicursos, workshops.
  • usuarios — quem se cadastra e se inscreve (autenticados via Firebase Auth a partir da Aula 10; aqui já preparamos a tabela).
  • inscricoes — a relação muitos-para-muitos entre usuarios e eventos: um usuário pode se inscrever em vários eventos, um evento tem vários inscritos.
Texto
┌───────────────┐          ┌──────────────────┐          ┌───────────────┐
│   eventos      │          │   inscricoes       │          │   usuarios     │
├───────────────┤          ├──────────────────┤          ├───────────────┤
│ id (PK)        │◄─────────│ evento_id (FK)     │          │ id (PK)        │
│ titulo         │  1:N     │ usuario_id (FK)    │─────────►│ firebase_uid   │
│ descricao      │          │ id (PK)             │   N:1    │ nome           │
│ categoria      │          │ criado_em           │          │ email          │
│ data_hora      │          └──────────────────┘          │ criado_em      │
│ local          │                                          └───────────────┘
│ vagas          │
│ imagem_url     │
│ criado_em      │
└───────────────┘

Tipos de dados, restrições e índices

Coluna Tipo Por quê
id INT AUTO_INCREMENT número inteiro que o próprio banco incrementa a cada inserção
titulo, nome, email VARCHAR(n) texto de tamanho limitado e conhecido
descricao TEXT texto longo, sem limite prático relevante
data_hora, criado_em DATETIME data e hora, sem fuso embutido (cuidado explicado adiante)
vagas INT número inteiro não negativo
NOT NULL restrição impede gravar um registro sem aquele campo
UNIQUE restrição impede duplicar um valor (ex.: dois usuários com o mesmo e-mail)
índice em chave estrangeira otimização acelera buscas e junções (JOIN) que filtram por aquela coluna

⚠️ Atenção DATETIME no MySQL grava data e hora sem informação de fuso — é literalmente "19:00 no dia 15", sem dizer em qual fuso horário. Se a aplicação gravar horários locais (fuso de Sinop, UTC−4) e outra parte do sistema assumir UTC (o padrão do JavaScript com new Date().toISOString()), o horário exibido para o usuário fica deslocado. A prática mais segura: padronize um único fuso para toda a aplicação — o mais comum é gravar tudo em UTC no banco e converter para o fuso do usuário só na apresentação (no front-end). Esta disciplina, por simplicidade didática, grava os horários já no fuso local do evento; em um sistema com usuários em fusos diferentes, prefira UTC no banco.

Script sql/schema.sql completo

SQL
-- sql/schema.sql
-- Script de criação do banco de dados do UniEventos.
-- Execute com: mysql -u root -p < sql/schema.sql

CREATE DATABASE IF NOT EXISTS unieventos
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE unieventos;

-- tabela de usuários — uid do Firebase Auth vem na Aula 10, já deixamos o campo pronto
CREATE TABLE usuarios (
  id INT AUTO_INCREMENT PRIMARY KEY,
  firebase_uid VARCHAR(128) UNIQUE,
  nome VARCHAR(120) NOT NULL,
  email VARCHAR(160) NOT NULL UNIQUE,
  criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- tabela de eventos
CREATE TABLE eventos (
  id INT AUTO_INCREMENT PRIMARY KEY,
  titulo VARCHAR(160) NOT NULL,
  descricao TEXT,
  categoria ENUM('palestra', 'minicurso', 'workshop') NOT NULL,
  data_hora DATETIME NOT NULL,
  local VARCHAR(160) NOT NULL,
  vagas INT NOT NULL DEFAULT 0,
  imagem_url VARCHAR(400),
  criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

  INDEX idx_eventos_categoria (categoria),
  INDEX idx_eventos_data_hora (data_hora)
);

-- tabela de inscrições — relação N:N entre usuarios e eventos
CREATE TABLE inscricoes (
  id INT AUTO_INCREMENT PRIMARY KEY,
  evento_id INT NOT NULL,
  usuario_id INT NOT NULL,
  criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT fk_inscricoes_evento
    FOREIGN KEY (evento_id) REFERENCES eventos(id)
    ON DELETE CASCADE,

  CONSTRAINT fk_inscricoes_usuario
    FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
    ON DELETE CASCADE,

  -- um mesmo usuário não pode se inscrever duas vezes no mesmo evento
  UNIQUE KEY uk_inscricao_unica (evento_id, usuario_id)
);

-- dados de exemplo
INSERT INTO usuarios (firebase_uid, nome, email) VALUES
  ('uid-exemplo-001', 'Ana Souza', 'ana.souza@exemplo.com'),
  ('uid-exemplo-002', 'Bruno Lima', 'bruno.lima@exemplo.com');

INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url) VALUES
  ('Semana Acadêmica de Computação', 'Palestras e minicursos sobre o mercado de tecnologia.', 'palestra', '2026-10-15 19:00:00', 'Auditório FACET', 80, 'https://picsum.photos/seed/semana-computacao/400/240'),
  ('Minicurso de Vue 3', 'Introdução prática ao framework Vue com Composition API.', 'minicurso', '2026-10-20 14:00:00', 'Laboratório 3', 30, 'https://picsum.photos/seed/minicurso-vue/400/240'),
  ('Workshop de Firebase e Express', 'Construindo uma API real do zero.', 'workshop', '2026-10-28 19:30:00', 'Laboratório 1', 25, 'https://picsum.photos/seed/workshop-firebase/400/240');

INSERT INTO inscricoes (evento_id, usuario_id) VALUES
  (1, 1),
  (1, 2),
  (2, 1);

ON DELETE CASCADE garante que, se um evento for removido, todas as inscrições associadas a ele são removidas automaticamente pelo próprio banco — sem precisar de código na aplicação para limpar registros órfãos. A restrição UNIQUE KEY uk_inscricao_unica (evento_id, usuario_id) impede, no nível do banco, que o mesmo usuário se inscreva duas vezes no mesmo evento — mesmo que a aplicação, por algum bug, tentasse permitir.

💡 Dica utf8mb4 (em vez do antigo utf8 do MySQL, que na verdade só suporta um subconjunto do Unicode) é o padrão recomendado hoje — suporta acentos, emojis e qualquer caractere completo do Unicode sem surpresas.

3. Instalando o MySQL

Três caminhos chegam ao mesmo lugar: um servidor MySQL 8 escutando em localhost:3306. Escolha o que for mais conveniente para o seu sistema operacional e siga — não é preciso instalar mais de um.

Linux (Debian/Ubuntu)

Terminal
sudo apt update
sudo apt install mysql-server
sudo systemctl start mysql
sudo mysql_secure_installation   # define senha do root e remove configurações inseguras padrão

Windows

Baixe o MySQL Installer em dev.mysql.com/downloads/installer, escolha "Server only" (ou "Full" se quiser o Workbench junto), e siga o assistente — ele já pede para definir a senha do usuário root durante a instalação.

Alternativa: Docker (qualquer sistema operacional)

Se você já tem Docker instalado, essa é a forma mais rápida de ter um MySQL isolado, sem instalar nada permanentemente no sistema:

Terminal
docker run --name mysql-fds \
  -e MYSQL_ROOT_PASSWORD=senhaDeDesenvolvimento123 \
  -e MYSQL_DATABASE=unieventos \
  -p 3306:3306 \
  -d mysql:8

Isso sobe um contêiner MySQL 8, já criando o banco unieventos, expondo a porta padrão 3306 na sua máquina. Para parar e voltar a usar depois:

Terminal
docker stop mysql-fds     # para o contêiner
docker start mysql-fds    # volta a rodar, com os dados preservados

⚠️ Atenção A senha do exemplo (senhaDeDesenvolvimento123) é só para desenvolvimento local. Nunca reutilize senhas de exemplo de material didático em nada que vá para produção.

💡 Dica Se você usa Docker no dia a dia, considere adicionar um docker-compose.yml ao repositório unieventos-api, versionando a configuração do banco de desenvolvimento junto do código — assim qualquer colega que clonar o projeto sobe o mesmo ambiente com um único docker compose up -d, sem precisar copiar o comando docker run manualmente.

Ferramentas para explorar o banco visualmente

Ferramenta Característica
MySQL Workbench oficial da Oracle/MySQL, completa, modelagem visual de schema
DBeaver multiplataforma, suporta vários SGBDs além de MySQL, gratuita
extensão MySQL do VS Code fica dentro do próprio editor, boa para consultas rápidas sem trocar de janela
linha de comando (mysql) sempre disponível, sem instalação extra, ótima para scripts e automação
phpMyAdmin interface web, comum em hospedagens compartilhadas; menos usada em desenvolvimento local

Escolha uma, conecte em localhost:3306 com o usuário root e a senha definida, e rode o sql/schema.sql — ou pela ferramenta gráfica, ou direto no terminal:

Terminal
mysql -u root -p < sql/schema.sql

Depois de rodar o script, use a ferramenta escolhida para navegar visualmente pelas tabelas criadas, conferir os INSERTs de exemplo e, se quiser, gerar um diagrama entidade-relacionamento a partir do schema existente — a maioria dessas ferramentas faz engenharia reversa do banco para um diagrama automaticamente, útil para conferir se as relações ficaram como o desenhado na §2.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Factory / Object Pool e Repository

mysql2.createPool(...) é uma aplicação combinada de dois padrões criacionais. Factory Method: você não instancia uma conexão diretamente com new Conexao() — chama uma função de fábrica (createPool) que encapsula a lógica de criação e devolve o objeto pronto para uso, escondendo os detalhes de configuração interna. Object Pool: em vez de criar uma conexão nova para cada requisição (caro: negociar protocolo, autenticar, alocar recursos no servidor de banco), o pool mantém um conjunto de conexões já abertas, prontas, emprestando uma a cada consulta e devolvendo-a ao pool quando termina — reduzindo drasticamente o custo de abrir/fechar conexão repetidamente.

A camada de Repository, que construímos a seguir, é um padrão estrutural de organização: isola todo o SQL da aplicação dentro de funções com nomes de domínio (buscarEventoPorId, inserirEvento), para que o resto do código nunca precise saber que existe SQL por trás — só chama métodos. Trocar de MySQL para outro banco (Aula 12, com Supabase) significa reescrever o repositório, sem tocar em serviço, controlador ou rotas.

4. mysql2/promise na prática

Terminal
npm install mysql2

Pool de conexões: por que, e não conexão única

Uma conexão única a um banco de dados atende uma consulta por vez — se sua API recebe cinco requisições simultâneas, e cada uma precisa consultar o banco, quatro delas ficam esperando a primeira terminar. Um pool de conexões mantém várias conexões abertas simultaneamente, e o driver empresta uma livre para cada consulta, devolvendo ao pool quando ela termina.

JavaScript
// src/bancoDeDados.js
import mysql from 'mysql2/promise'

export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,   // se todas as conexões estiverem ocupadas, espera na fila em vez de falhar
  connectionLimit: 10,        // no máximo 10 conexões simultâneas no pool
  namedPlaceholders: true,    // permite usar :nome em vez de só "?" nas queries
})

waitForConnections: true significa que, se as 10 conexões do connectionLimit estiverem todas ocupadas no momento de uma nova consulta, o driver enfileira a requisição e espera uma liberar, em vez de lançar erro imediatamente. namedPlaceholders: true habilita a sintaxe :nomeDoParametro nas consultas, além da tradicional ? posicional — útil quando a query tem muitos parâmetros e a ordem fica difícil de acompanhar.

pool.query vs pool.execute

JavaScript
// pool.query: envia a consulta e os valores juntos, o driver monta e executa
const [linhas] = await pool.query('SELECT * FROM eventos WHERE categoria = ?', ['palestra'])

// pool.execute: usa prepared statements no protocolo do MySQL — o SQL é compilado
// uma vez pelo servidor e reutilizado, mais eficiente para consultas repetidas
const [linhas2] = await pool.execute('SELECT * FROM eventos WHERE categoria = ?', ['palestra'])

Para a maioria dos casos, o comportamento observável é o mesmo — a diferença é performance em consultas repetidas com muita frequência (prepared statements do execute compensam o custo extra de preparo quando a mesma consulta roda muitas vezes). Esta disciplina usa pool.execute como padrão no repositório, por ser a prática mais recomendada em produção.

Consultas parametrizadas — e o ataque que elas evitam

Nunca, em hipótese alguma, concatene valores vindos do usuário diretamente numa string SQL:

JavaScript
// NUNCA FAÇA ISSO — vulnerável a SQL injection
const categoria = req.query.categoria
const sql = `SELECT * FROM eventos WHERE categoria = '${categoria}'`
const [linhas] = await pool.query(sql)

Se alguém enviar categoria como ' OR 1=1 --, a string final montada fica:

SQL
SELECT * FROM eventos WHERE categoria = '' OR 1=1 --'

OR 1=1 é sempre verdadeiro, e -- comenta o resto da linha — a consulta passa a devolver todos os eventos da tabela, ignorando completamente o filtro pretendido. Em consultas de autenticação, o mesmo tipo de ataque pode permitir login sem senha correta; em DELETE/UPDATE malformados dessa forma, pode apagar ou alterar a tabela inteira.

A correção é sempre usar placeholders (? ou :nome), nunca concatenação:

JavaScript
// CORRETO — consulta parametrizada
const categoria = req.query.categoria
const [linhas] = await pool.execute('SELECT * FROM eventos WHERE categoria = ?', [categoria])

Com placeholder, o driver envia a consulta e os valores separadamente para o servidor MySQL — o valor nunca é interpretado como parte da sintaxe SQL, não importa o que ele contenha. ' OR 1=1 -- viraria, nesse caso, literalmente o texto que está sendo procurado na coluna categoria, e não devolveria nada (porque nenhuma categoria se chama isso).

⚠️ Atenção SQL injection é uma das vulnerabilidades mais antigas e mais exploradas da web, e ainda aparece em sistemas reais porque alguém, em algum momento, concatenou uma string "só dessa vez". A regra não tem exceção: todo valor vindo de fora (query string, corpo da requisição, cabeçalho) entra numa query como parâmetro, nunca como texto concatenado.

Lendo o resultado de cada tipo de consulta

JavaScript
// SELECT: o resultado é um array de linhas (mesmo com 0 ou 1 resultado)
const [linhas] = await pool.execute('SELECT * FROM eventos WHERE id = ?', [1])
const evento = linhas[0] // undefined se não encontrou

// INSERT: o resultado é um objeto com metadados da inserção
const [resultado] = await pool.execute(
  'INSERT INTO eventos (titulo, categoria, data_hora, local, vagas) VALUES (?, ?, ?, ?, ?)',
  ['Palestra de teste', 'palestra', '2026-11-05 19:00:00', 'Auditório FACET', 60],
)
console.log(resultado.insertId)       // id gerado pelo AUTO_INCREMENT
console.log(resultado.affectedRows)   // quantas linhas foram afetadas (1, aqui)

// UPDATE / DELETE: também devolvem affectedRows
const [resultadoUpdate] = await pool.execute('UPDATE eventos SET vagas = ? WHERE id = ?', [50, 1])
console.log(resultadoUpdate.affectedRows) // 0 se o id não existia, 1 se atualizou

pool.query/pool.execute sempre devolvem um array de dois elementos — por isso o padrão const [linhas] = await ... (desestruturação, já vista desde a Aula 01). O primeiro elemento é o resultado propriamente dito; o segundo (normalmente descartado com const [linhas], ignorando a segunda posição) traz metadados de campos, que raramente usamos diretamente.

Consultas relacionais com JOIN

A vantagem de ter um banco relacional aparece quando você precisa combinar dados de mais de uma tabela numa única consulta — algo que, com dados em memória (Aulas 07–08), exigia laços manuais em JavaScript para "juntar" arrays.

JavaScript
// buscar as inscrições de um evento, já trazendo o nome e e-mail de cada inscrito,
// numa única ida ao banco — sem precisar de uma segunda consulta por usuário
const [inscricoesDoEvento] = await pool.execute(
  `SELECT i.id, i.criado_em, u.nome, u.email
   FROM inscricoes i
   INNER JOIN usuarios u ON u.id = i.usuario_id
   WHERE i.evento_id = ?
   ORDER BY i.criado_em ASC`,
  [eventoId],
)

INNER JOIN combina linhas de inscricoes com as linhas correspondentes de usuarios, casando pela condição u.id = i.usuario_id — exatamente a relação de chave estrangeira definida no schema.sql. O resultado já vem com os dados prontos para a resposta da API, sem processamento adicional em JavaScript.

Transações: inscrever em evento e decrementar vagas

Considere a operação "inscrever um usuário num evento": ela precisa (1) verificar se há vaga, (2) inserir a inscrição, e (3) decrementar o contador de vagas. Se o passo 2 tiver sucesso mas o passo 3 falhar (por exemplo, o servidor cair no meio), o banco fica em um estado inconsistente — uma inscrição existe, mas a vaga não foi descontada. Uma transação garante que um grupo de operações aconteça tudo ou nada.

JavaScript
// src/repositories/inscricoesRepository.js
import { pool } from '../bancoDeDados.js'
import { ErroHttp } from '../erros/ErroHttp.js'

export async function inscreverUsuarioNoEvento(eventoId, usuarioId) {
  // pool.getConnection() empresta UMA conexão específica do pool, exclusiva para esta transação
  const conexao = await pool.getConnection()

  try {
    await conexao.beginTransaction()

    // trava a linha do evento para leitura, evitando que duas inscrições simultâneas
    // leiam "vagas: 1" ao mesmo tempo e ambas decidam que podem inscrever
    const [eventos] = await conexao.execute(
      'SELECT vagas FROM eventos WHERE id = ? FOR UPDATE',
      [eventoId],
    )

    if (eventos.length === 0) {
      throw new ErroHttp(404, 'Evento não encontrado')
    }

    if (eventos[0].vagas <= 0) {
      throw new ErroHttp(422, 'Não há vagas disponíveis para este evento')
    }

    await conexao.execute(
      'INSERT INTO inscricoes (evento_id, usuario_id) VALUES (?, ?)',
      [eventoId, usuarioId],
    )

    await conexao.execute(
      'UPDATE eventos SET vagas = vagas - 1 WHERE id = ?',
      [eventoId],
    )

    // só grava tudo em definitivo se as três operações acima passaram sem erro
    await conexao.commit()
  } catch (erro) {
    // desfaz TUDO que essa transação tentou fazer — o banco volta ao estado anterior
    await conexao.rollback()
    throw erro
  } finally {
    // devolve a conexão ao pool, sempre — sucesso ou falha
    conexao.release()
  }
}

FOR UPDATE no SELECT trava a linha lida até o fim da transação, impedindo que outra transação concorrente leia o mesmo valor de vagas antes do commit — evitando o cenário de duas inscrições simultâneas "roubarem" a última vaga ao mesmo tempo.

⚠️ Atenção conexao.release() no finally é obrigatório. Se você esquecer de liberar uma conexão emprestada do pool, ela fica presa — e depois de connectionLimit conexões presas, o pool se esgota e toda nova consulta trava esperando uma conexão livre que nunca aparece. Isso é a causa mais comum de uma API que "funciona bem no início e trava depois de um tempo".

O caminho de sucesso e o caminho de falha, lado a lado:

Texto
  beginTransaction()
        │
        ▼
  SELECT ... FOR UPDATE  (lê e trava a linha do evento)
        │
        ▼
  vagas > 0? ──── não ────► throw erroValidacao(422)
        │ sim                      │
        ▼                          ▼
  INSERT em inscricoes        catch: rollback()
        │                     (nada gravado)
        ▼                          │
  UPDATE vagas = vagas - 1         │
        │                          │
        ▼                          │
  commit()                         │
  (tudo gravado)                   │
        │                          │
        └──────────┬───────────────┘
                    ▼
             finally: release()
             (conexão sempre volta ao pool)

Note que release() roda em ambos os caminhos — é justamente o papel do finally: executar independentemente de a try ter chegado ao commit() ou de o catch ter chegado ao rollback().

5. Configuração por ambiente

Terminal
# .env (nunca commitar)
PORTA=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=senhaDeDesenvolvimento123
DB_NAME=unieventos
Terminal
# .env.example (versionado, sem valores sigilosos)
PORTA=3000
DB_HOST=localhost
DB_USER=
DB_PASSWORD=
DB_NAME=unieventos
Terminal
node --watch --env-file=.env src/servidor.js

Cada ambiente (sua máquina, a de um colega, um servidor de produção futuro) tem seu próprio .env, com valores possivelmente diferentes — mas o mesmo código-fonte funciona em todos, porque nada de configuração está fixado (hardcoded) no JavaScript.

💻 Mão na massa — camadas repositório, serviço e controlador

A partir de agora a unieventos-api ganha três camadas com responsabilidades separadas:

Texto
requisição HTTP
      │
      ▼
  controller   — lê req, chama o service, monta a resposta HTTP (não sabe SQL)
      │
      ▼
  service      — regra de negócio (ex.: "vagas não pode ficar negativo")
      │
      ▼
  repository   — só SQL: monta e executa queries, devolve dados "crus"
      │
      ▼
  MySQL

O controller não sabe que existe SQL — ele lida só com req/res e delega tudo ao service. O service não sabe que existe req/res — ele recebe parâmetros simples e devolve dados ou lança erros de negócio. O repository não sabe nada sobre HTTP — só executa SQL e devolve linhas. Essa separação permite testar a regra de negócio sem precisar simular uma requisição HTTP, e trocar o banco de dados (Aula 12, Supabase) sem tocar em controller nem service.

Passo 1 — repositório de eventos

JavaScript
// src/repositories/eventosRepository.js
import { pool } from '../bancoDeDados.js'

export async function listarEventos({ categoria, ordenarPor, direcao, limite, offset }) {
  const colunasPermitidas = ['id', 'titulo', 'data_hora', 'vagas']
  const coluna = colunasPermitidas.includes(ordenarPor) ? ordenarPor : 'id'
  const sentidoOrdenacao = direcao === 'desc' ? 'DESC' : 'ASC'

  // nomes de coluna/direção não podem ser parametrizados com "?" (só valores podem);
  // por isso validamos contra uma lista fixa (colunasPermitidas) antes de montar a string
  let sql = 'SELECT * FROM eventos'
  const parametros = []

  if (categoria) {
    sql += ' WHERE categoria = ?'
    parametros.push(categoria)
  }

  sql += ` ORDER BY ${coluna} ${sentidoOrdenacao} LIMIT ? OFFSET ?`
  parametros.push(limite, offset)

  const [linhas] = await pool.execute(sql, parametros)
  return linhas
}

export async function contarEventos(categoria) {
  let sql = 'SELECT COUNT(*) AS total FROM eventos'
  const parametros = []

  if (categoria) {
    sql += ' WHERE categoria = ?'
    parametros.push(categoria)
  }

  const [linhas] = await pool.execute(sql, parametros)
  return linhas[0].total
}

export async function buscarEventoPorId(id) {
  const [linhas] = await pool.execute('SELECT * FROM eventos WHERE id = ?', [id])
  return linhas[0] || null
}

export async function inserirEvento(evento) {
  const [resultado] = await pool.execute(
    `INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
     VALUES (?, ?, ?, ?, ?, ?, ?)`,
    [
      evento.titulo,
      evento.descricao || null,
      evento.categoria,
      evento.dataHora,
      evento.local,
      evento.vagas,
      evento.imagemUrl || null,
    ],
  )
  return buscarEventoPorId(resultado.insertId)
}

export async function substituirEvento(id, evento) {
  const [resultado] = await pool.execute(
    `UPDATE eventos
     SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
     WHERE id = ?`,
    [
      evento.titulo,
      evento.descricao || null,
      evento.categoria,
      evento.dataHora,
      evento.local,
      evento.vagas,
      evento.imagemUrl || null,
      id,
    ],
  )
  if (resultado.affectedRows === 0) return null
  return buscarEventoPorId(id)
}

export async function atualizarEventoParcial(id, campos) {
  const colunasPermitidas = {
    titulo: 'titulo',
    descricao: 'descricao',
    categoria: 'categoria',
    dataHora: 'data_hora',
    local: 'local',
    vagas: 'vagas',
    imagemUrl: 'imagem_url',
  }

  const atribuicoes = []
  const parametros = []

  for (const [chave, valor] of Object.entries(campos)) {
    if (colunasPermitidas[chave]) {
      atribuicoes.push(`${colunasPermitidas[chave]} = ?`)
      parametros.push(valor)
    }
  }

  if (atribuicoes.length === 0) return buscarEventoPorId(id)

  parametros.push(id)
  const [resultado] = await pool.execute(
    `UPDATE eventos SET ${atribuicoes.join(', ')} WHERE id = ?`,
    parametros,
  )
  if (resultado.affectedRows === 0) return null
  return buscarEventoPorId(id)
}

export async function removerEvento(id) {
  const [resultado] = await pool.execute('DELETE FROM eventos WHERE id = ?', [id])
  return resultado.affectedRows > 0
}

⚠️ Atenção Note que nomes de coluna e direção de ordenação (ORDER BY coluna ASC/DESC) não podem vir de placeholder ? — o protocolo de prepared statements do MySQL só parametriza valores, não identificadores de coluna nem palavras-chave SQL. É por isso que listarEventos valida ordenarPor contra uma lista fixa (colunasPermitidas) antes de montar a string com esse nome — validar contra uma lista fechada de valores aceitos é seguro; aceitar qualquer string do usuário nessa posição reabriria a porta para injection.

Passo 2 — serviço de eventos

JavaScript
// src/services/eventosService.js
import * as eventosRepository from '../repositories/eventosRepository.js'
import { erroNaoEncontrado, erroValidacao } from '../erros/ErroHttp.js'

export async function obterListaDeEventos({ categoria, ordenarPor, direcao, pagina, limite }) {
  const paginaSegura = Math.max(1, Number(pagina) || 1)
  const limiteSeguro = Math.min(100, Math.max(1, Number(limite) || 10))
  const offset = (paginaSegura - 1) * limiteSeguro

  const [eventos, total] = await Promise.all([
    eventosRepository.listarEventos({ categoria, ordenarPor, direcao, limite: limiteSeguro, offset }),
    eventosRepository.contarEventos(categoria),
  ])

  return {
    eventos,
    meta: { pagina: paginaSegura, limite: limiteSeguro, total },
  }
}

export async function obterEventoPorId(id) {
  const evento = await eventosRepository.buscarEventoPorId(id)
  if (!evento) {
    throw erroNaoEncontrado('Evento não encontrado')
  }
  return evento
}

export async function criarEvento(dadosEvento) {
  if (dadosEvento.vagas < 0) {
    throw erroValidacao('Vagas não pode ser negativo')
  }
  return eventosRepository.inserirEvento(dadosEvento)
}

export async function atualizarEventoCompleto(id, dadosEvento) {
  const eventoAtualizado = await eventosRepository.substituirEvento(id, dadosEvento)
  if (!eventoAtualizado) {
    throw erroNaoEncontrado('Evento não encontrado')
  }
  return eventoAtualizado
}

export async function atualizarEventoParcial(id, campos) {
  const eventoAtualizado = await eventosRepository.atualizarEventoParcial(id, campos)
  if (!eventoAtualizado) {
    throw erroNaoEncontrado('Evento não encontrado')
  }
  return eventoAtualizado
}

export async function excluirEvento(id) {
  const removeu = await eventosRepository.removerEvento(id)
  if (!removeu) {
    throw erroNaoEncontrado('Evento não encontrado')
  }
}

O service centraliza regras que o repository não deveria conhecer (como "vagas não pode ser negativo") e traduz "não encontrado no banco" (null) em um erro de domínio (erroNaoEncontrado) — o controller nunca precisa checar if (!evento) porque o service já garante isso via exceção.

Passo 3 — controlador de eventos

JavaScript
// src/controllers/eventosController.js
import * as eventosService from '../services/eventosService.js'

export async function listar(req, res) {
  const { categoria, ordenarPor, direcao, pagina, limite } = req.query
  const { eventos, meta } = await eventosService.obterListaDeEventos({
    categoria,
    ordenarPor,
    direcao,
    pagina,
    limite,
  })
  res.json({ dados: eventos, meta })
}

export async function buscarPorId(req, res) {
  const evento = await eventosService.obterEventoPorId(Number(req.params.id))
  res.json({ dados: evento })
}

export async function criar(req, res) {
  const novoEvento = await eventosService.criarEvento(req.body)
  res.status(201).location(`/api/v1/eventos/${novoEvento.id}`).json({ dados: novoEvento })
}

export async function substituir(req, res) {
  const eventoAtualizado = await eventosService.atualizarEventoCompleto(Number(req.params.id), req.body)
  res.json({ dados: eventoAtualizado })
}

export async function atualizarParcial(req, res) {
  const eventoAtualizado = await eventosService.atualizarEventoParcial(Number(req.params.id), req.body)
  res.json({ dados: eventoAtualizado })
}

export async function excluir(req, res) {
  await eventosService.excluirEvento(Number(req.params.id))
  res.status(204).send()
}

Repare que nenhum controller trata erro manualmente — todo throw (vindo do service, vindo do repository, vindo de qualquer lugar da cadeia de await) é capturado automaticamente pelo Express 5 e cai no tratadorDeErros da Aula 08, sem nenhuma mudança nele.

Passo 4 — rotas usando o controller

JavaScript
// src/routes/eventos.routes.js
import { Router } from 'express'
import * as eventosController from '../controllers/eventosController.js'
import { validar } from '../middlewares/validador.js'
import { schemaEvento, schemaEventoParcial } from '../schemas/evento.schema.js'

const router = Router()

router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
router.post('/', validar(schemaEvento), eventosController.criar)
router.put('/:id', validar(schemaEvento), eventosController.substituir)
router.patch('/:id', validar(schemaEventoParcial), eventosController.atualizarParcial)
router.delete('/:id', eventosController.excluir)

export default router

Compare com o eventos.routes.js da Aula 08: a assinatura de cada rota é idêntica (mesmo método, mesmo caminho, mesmo middleware de validação). Só o corpo mudou de "manipula um array" para "chama um controller que fala com MySQL por baixo". Esse é o ponto central da aula: o contrato HTTP não mudou, então o front-end não precisa de nenhuma alteração.

Passo 5 — atualizando o servidor.js

JavaScript
// src/servidor.js
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import compression from 'compression'
import morgan from 'morgan'
import rateLimit from 'express-rate-limit'
import eventosRoutes from './routes/eventos.routes.js'
import { middlewareNaoEncontrado, tratadorDeErros } from './middlewares/erros.js'
import { logger } from './middlewares/logger.js'
import { medidorDeTempo } from './middlewares/medidorDeTempo.js'
import './bancoDeDados.js' // garante que o pool é criado na subida do servidor

const app = express()

app.use(cors())
app.use(helmet())
app.use(compression())
app.use(express.json())
app.use(morgan('dev'))
app.use(logger)
app.use(medidorDeTempo)

const limitador = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })
app.use('/api/', limitador)

app.use('/api/v1/eventos', eventosRoutes)

app.use(middlewareNaoEncontrado)
app.use(tratadorDeErros)

const porta = process.env.PORTA || 3000

app.listen(porta, () => {
  console.log(`unieventos-api rodando em http://localhost:${porta}`)
})

Teste com o mesmo requests.http da Aula 08 — nenhuma linha dele precisa mudar. Se algum teste que passava antes agora falha, o problema está na camada MySQL nova, não no contrato da API.

🧪 Laboratório

1. Repositório de usuários. Escreva src/repositories/usuariosRepository.js com listarUsuarios(), buscarUsuarioPorId(id) e inserirUsuario({ nome, email }). Use consultas parametrizadas em todas.

Dica

Siga exatamente o padrão de eventosRepository.js: pool.execute(sql, parametros), desestruturando [linhas] do retorno.

2. Endpoint de inscrição. Crie POST /api/v1/eventos/:id/inscricoes que recebe { "usuarioId": N } no corpo e chama inscreverUsuarioNoEvento (já escrita nesta aula). Teste o caso de sucesso e o caso de vagas esgotadas (zere as vagas de um evento no banco antes de testar).

Dica

O erro de vagas esgotadas já vem como ErroHttp(422, ...) de dentro da transação — seu controller só precisa dar await e deixar o Express capturar automaticamente.

3. Ataque de SQL injection controlado. Na sua máquina de desenvolvimento, temporariamente reescreva buscarEventoPorId para concatenar a string (sem placeholder), e tente buscar com um id malicioso do tipo 1 OR 1=1. Observe o resultado. Depois reverta para a versão parametrizada e repita o teste, confirmando que o ataque não funciona mais.

Dica

Como id nessa rota já passa por Number(req.params.id) no controller, o ataque de string não chega inteiro ao repository nesse caso específico — para realmente ver o ataque funcionar, teste diretamente no eventosRepository, chamando a função com uma string maliciosa manualmente, sem o Number() do meio do caminho. Isso mostra por que duas camadas de proteção (validação de tipo + parametrização) são melhores que uma só.

4. Índice e EXPLAIN. Rode EXPLAIN SELECT * FROM eventos WHERE categoria = 'palestra' no MySQL Workbench ou DBeaver, antes e depois de remover o índice idx_eventos_categoria (DROP INDEX idx_eventos_categoria ON eventos). Compare o campo rows do resultado (recrie o índice depois do teste).

Dica

Com o índice, o MySQL deve mostrar type: ref e um número baixo em rows. Sem o índice, type: ALL (varredura completa da tabela) e rows igual ao total de linhas da tabela.

5. Transação com falha proposital. No meio de inscreverUsuarioNoEvento, adicione temporariamente um throw new Error('falha proposital') logo depois do INSERT na tabela inscricoes, antes do UPDATE de vagas. Rode a função, confirme que a inscrição não aparece na tabela (porque o rollback desfez tudo), e remova o throw de teste depois.

Dica

Consulte a tabela inscricoes direto pelo Workbench/DBeaver antes e depois de rodar o teste, para confirmar visualmente que nada foi persistido.

6. Endpoint de listagem com JOIN. Crie GET /api/v1/eventos/:id/inscricoes que devolve a lista de inscritos de um evento, usando a consulta JOIN desta aula, no formato de envelope { "dados": [...] }. Trate o caso de evento inexistente com 404.

Dica

Siga a mesma separação em camadas: uma função no repository (listarInscricoesDoEvento), verificação de existência do evento no service (reaproveite obterEventoPorId), e um controller enxuto.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
ER_ACCESS_DENIED_ERROR usuário ou senha incorretos no .env confira DB_USER/DB_PASSWORD; teste login manual com mysql -u usuario -p
ECONNREFUSED MySQL não está rodando, ou porta/host errados confirme systemctl status mysql (Linux) ou o contêiner Docker rodando (docker ps)
ER_NO_SUCH_TABLE schema.sql não foi executado, ou executado no banco errado rode mysql -u root -p < sql/schema.sql; confira USE unieventos; no início do script
ER_BAD_DB_ERROR DB_NAME no .env não corresponde ao banco criado confira o nome exato do banco criado pelo CREATE DATABASE
Datas retornam com horário deslocado fuso horário do servidor MySQL diferente do esperado pela aplicação padronize o fuso do servidor e/ou converta explicitamente no código, sem assumir local implícito
ER_DUP_ENTRY tentou inserir um valor que viola UNIQUE (e-mail repetido, inscrição duplicada) trate esse erro específico no service, devolvendo 409 Conflict com mensagem clara
Pool trava depois de um tempo de uso conexão emprestada com getConnection() nunca foi liberada com .release() sempre libere no finally, mesmo em caminhos de erro
Too many connections no lado do servidor MySQL connectionLimit do pool maior que o limite configurado no servidor MySQL ajuste connectionLimit para um valor compatível com a capacidade do servidor
req.body chega vazio no POST de inscrição testou direto no banco sem passar pela API, ou esqueceu Content-Type: application/json no requests.http confirme o cabeçalho e o corpo no arquivo .http
resultado.insertId vem 0 ou undefined a tabela não tem coluna AUTO_INCREMENT, ou a query não era um INSERT confira o CREATE TABLE; insertId só é preenchido em INSERT sobre coluna AUTO_INCREMENT
Erro de sintaxe SQL só em produção, funcionava local diferença de versão do MySQL entre ambientes, ou script schema.sql não aplicado no novo ambiente garanta que schema.sql seja executado em todo ambiente novo antes de subir a API
PROTOCOL_CONNECTION_LOST durante uso prolongado conexão do pool expirou por inatividade (timeout do servidor MySQL) normal em pools ociosos; o mysql2 reabre conexões automaticamente na próxima consulta — se persistir, revise connectionLimit e tempo de vida da conexão

🏠 Atividade assíncrona (1 h)

No seu projeto autoral:

  1. Modele as tabelas do seu domínio em sql/schema.sql, com ao menos duas tabelas relacionadas por chave estrangeira (equivalente a eventos/inscricoes, adaptado ao seu tema).
  2. Crie o banco (nativo ou Docker) e execute o script.
  3. Migre seu repositório, serviço e controlador da Aula 08 (em memória) para MySQL, seguindo exatamente a separação em camadas desta aula.
  4. Rode novamente o seu requests.http da Aula 08 sem alterar nenhuma linha — confirme que todos os testes continuam passando, agora contra o MySQL.
  5. Implemente pelo menos uma operação transacional própria do seu domínio (qualquer ação que precise de "tudo ou nada" entre duas tabelas).

Critério de pronto: sua API autoral persiste em MySQL, os testes do requests.http da Aula 08 passam sem modificação, e o front-end autoral continua funcionando sem alterações — prova de que a migração foi transparente para quem consome a API.

✅ Checkpoint do projeto autoral

  • [ ] sql/schema.sql versionado, com CREATE DATABASE, CREATE TABLE, chaves e INSERTs de exemplo.
  • [ ] MySQL rodando localmente (nativo ou Docker), banco criado a partir do script.
  • [ ] .env/.env.example com as quatro variáveis de conexão (DB_HOST, DB_USER, DB_PASSWORD, DB_NAME), .env nunca commitado.
  • [ ] Pool de conexões criado com createPool, usado em toda consulta — nenhuma conexão avulsa.
  • [ ] Todas as consultas parametrizadas — nenhuma concatenação de valor de usuário em string SQL.
  • [ ] Camadas repository/service/controller separadas, cada uma com responsabilidade única.
  • [ ] Ao menos uma operação usando transação (beginTransaction/commit/rollback).
  • [ ] requests.http da Aula 08 passando sem alterações contra a API já em MySQL.

📚 Para aprofundar

  • Documentação oficial do MySQL 8 — dev.mysql.com/doc/refman/8.0 (seções de tipos de dados, FOREIGN KEY, transações).
  • Documentação do driver mysql2github.com/sidorares/node-mysql2 (README cobre pool, prepared statements e Promise API).
  • OWASP — SQL Injection Prevention Cheat Sheet — referência de mercado sobre o assunto.
  • Documentação do Docker Hub para a imagem oficial mysqlhub.docker.com/_/mysql.
  • MySQL 8 Reference Manual — capítulo The InnoDB Storage Engine — para entender transações, FOR UPDATE e isolamento em profundidade.
  • Documentação do MySQL Workbench — dev.mysql.com/doc/workbench/en — modelagem visual (ER Diagram) a partir de um schema existente.
  • Plano de curso FACET-SNP-310 — bibliografia básica, capítulos sobre bancos de dados relacionais e persistência.

Na Aula 10, a API que você acabou de migrar para MySQL ganha autenticação de verdade: o Firebase Authentication entra em cena para identificar quem faz cada requisição, e você vai proteger rotas — como criar, editar e remover eventos — para que só usuários autenticados (e autorizados) possam executá-las.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 10 — Requisições autenticadas com Firebase

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 09 o UniEventos passou a persistir eventos no MySQL, com a API unieventos-api seguindo a arquitetura controller → service → repository. Qualquer pessoa com acesso à API conseguia criar, editar ou excluir um evento — não havia noção de "quem" fazia a requisição. Hoje isso muda: vamos exigir identidade.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Explicar a diferença entre autenticação e autorização, e por que senha em texto puro no banco é um erro grave.
  • Descrever a estrutura de um JWT (header, payload, assinatura) e explicar por que ele é assinado, não criptografado.
  • Implementar cadastro, login, logout e login com Google usando o SDK modular do Firebase Auth.
  • Construir uma store Pinia de autenticação que resolve corretamente o problema do F5 (recarregar a página autenticado).
  • Proteger rotas do Vue Router com beforeEach aguardando a inicialização da autenticação.
  • Enviar o token do usuário em cada requisição Axios via interceptor e validá-lo no back-end com firebase-admin.
  • Diferenciar dois níveis de proteção (rota no front = UX; middleware no back = segurança) e implementar autorização por papel com custom claims.

📋 Pré-requisitos desta aula

Checklist antes de começar:

  • [ ] unieventos-web rodando com Vue Router e Pinia configurados (Aulas 04–06).
  • [ ] unieventos-api rodando com Express 5, endpoints de eventos e persistência MySQL (Aulas 07–09).
  • [ ] Projeto Firebase criado (Aula 07) — anote o projectId.
  • [ ] Node.js 22.22.2 e npm 10.9.7 instalados (node -v, npm -v).
  • [ ] Acesso ao console do Firebase com o projeto do UniEventos.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Autenticação × autorização; anatomia de um JWT; habilitar provedores no console; Firebase Auth no front (cadastro, login, logout)
2 50 min Store de autenticação em Pinia; guard de rota; interceptor Axios enviando o token
3 50 min Verificação do token no back-end com firebase-admin; middleware autenticar/autorizar; custom claims; testes com e sem token

1. Autenticação não é autorização

São duas perguntas diferentes, e misturar as duas é a origem de muito bug de segurança:

  • Autenticação responde "quem é você?". O sistema confirma sua identidade — normalmente com e-mail e senha, ou delegando a um provedor como Google.
  • Autorização responde "o que você pode fazer?". Depois de saber quem você é, o sistema decide se você pode ler, criar, editar ou excluir um recurso.

Um usuário autenticado pode não estar autorizado a excluir um evento — só o administrador está. Um visitante não autenticado pode estar autorizado a ler a lista de eventos, que é pública. As duas coisas são independentes e o back-end precisa checar as duas, sempre, endpoint por endpoint.

Por que não guardar senha no seu próprio banco (se puder evitar)

Até aqui o UniEventos não tinha usuários — só eventos. Se fôssemos implementar login "na mão", a tentação seria criar uma tabela usuarios com uma coluna senha. Isso é perigoso por dois motivos:

  1. Nunca se guarda a senha em texto puro. Se o banco vazar, todas as senhas vazam — e como a maioria das pessoas reutiliza senha entre sites, o estrago vai muito além do seu sistema.
  2. Hash não é criptografia. Criptografia é reversível (existe uma chave para desfazer). Hash é uma função de mão única: você transforma a senha em uma sequência de caracteres da qual, na prática, não dá para voltar. No login, você faz o hash da senha digitada e compara com o hash guardado — nunca descriptografa nada. Bibliotecas como bcrypt fazem isso com "salt" (um valor aleatório por usuário) para que duas pessoas com a mesma senha não gerem o mesmo hash, e com um custo computacional propositalmente alto, para dificultar ataques de força bruta.

Fazer isso corretamente — hash com salt, custo ajustável, fluxo de "esqueci minha senha", verificação de e-mail, proteção contra força bruta, login social — é trabalho considerável e cheio de detalhes fáceis de errar. Por isso, na disciplina (e em grande parte dos projetos reais de pequeno e médio porte) delegamos a identidade a um provedor especializado: o Firebase Authentication. Ele guarda a senha (com hash correto, num banco que não é o seu), emite um token assinado provando quem é o usuário, e você só precisa validar esse token.

⚠️ Atenção Delegar autenticação não elimina a responsabilidade de proteger seus endpoints. O Firebase resolve "provar quem é o usuário". Decidir "o que esse usuário pode fazer no meu sistema" continua sendo trabalho do seu back-end.

2. Anatomia de um JWT

O Firebase (e a grande maioria dos sistemas de autenticação modernos) usa JSON Web Token (JWT) como formato do token de identidade. Um JWT é uma string com três partes separadas por ponto:

Texto
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9.eyJuYW1lIjoiTWFyaWEgU2lsdmEiLCJlbWFpbCI6Im1hcmlhQGV4ZW1wbG8uY29tIiwiYWRtaW4iOnRydWUsImlhdCI6MTc2MTQ4MDAwMCwiZXhwIjoxNzYxNDgzNjAwfQ.QqE8f3s1Zx7pR2nL9mK4vT6wY0aB1cD8eF3gH5iJ7kL

header . payload . assinatura

Cada parte é um objeto codificado em Base64URL. Decodificando as duas primeiras (a assinatura não se decodifica — ela não é Base64 de um JSON, é um bloco de bytes criptográfico):

Header — diz qual algoritmo assinou o token:

JSON
{
  "alg": "RS256",
  "kid": "abc123"
}

Payload — as "claims" (afirmações) sobre o usuário. É aqui que vive a informação:

JSON
{
  "name": "Maria Silva",
  "email": "maria@exemplo.com",
  "admin": true,
  "iat": 1761480000,
  "exp": 1761483600
}

iat (issued at) e exp (expiration) são timestamps Unix. admin é um exemplo de custom claim — vamos usar exatamente isso na seção 6 para autorização.

🔎 Por baixo do capô Um JWT é assinado, não é criptografado. Qualquer pessoa pode pegar esse token e decodificar o header e o payload num site como jwt.io ou com atob() no console do navegador — não há segredo nenhum escondido ali, e por isso nunca coloque dados sensíveis no payload (senha, número de cartão, CPF). A assinatura (terceira parte) é o que garante que ninguém alterou o conteúdo sem ter a chave privada do emissor. Se você mudar um único caractere do payload — por exemplo, trocar "admin": false para "admin": true — a assinatura deixa de bater, e quem valida o token (no nosso caso, o firebase-admin no back-end) rejeita o token inteiro.

Access token × refresh token

O Firebase trabalha com dois tokens:

  • ID token (access token): de curta duração (1 hora), é o que você envia em cada requisição para provar identidade. É o JWT que acabamos de decodificar.
  • Refresh token: de longa duração, fica guardado pelo SDK do Firebase e é usado automaticamente, nos bastidores, para pedir um novo ID token quando o atual expira — sem exigir que o usuário faça login de novo.

Essa separação existe porque um token de vida curta limita o estrago se ele vazar (ex.: em um log, em uma extensão maliciosa do navegador), enquanto o refresh token, mais sensível, fica protegido e raramente trafega.

📌 Na prova JWT tem três partes (header.payload.assinatura), é codificado em Base64URL (não criptografado) e assinado (não pode ser alterado sem invalidar a assinatura). ID token expira em 1h; o SDK renova sozinho usando o refresh token.

3. Habilitando autenticação no console do Firebase

No console do Firebase, projeto do UniEventos:

  1. Menu lateral → Build → Authentication → Get started.
  2. Aba Sign-in methodAdd new provider.
  3. Habilite Email/Password (o toggle simples, sem "passwordless").
  4. Habilite também Google — escolha um e-mail de suporte do projeto e salve.

Isso é configuração de infraestrutura, feita uma vez. O código vem agora.

4. Firebase Auth no front — SDK modular

O pacote já está instalado desde a Aula 07 (firebase@12.17.1). Se o seu projeto ainda não tem, instale:

Terminal
npm install firebase@12.17.1
JavaScript
// src/services/firebase.js
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
import { getFirestore } from 'firebase/firestore'

// Cole aqui o objeto de configuração exibido em
// Configurações do projeto → Geral → Seus apps → Config SDK.
const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
}

const app = initializeApp(firebaseConfig)

export const auth = getAuth(app)
export const db = getFirestore(app)
Terminal
# .env (na raiz de unieventos-web, sem aspas, sem espaço ao redor do =)
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos-xxxxx.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos-xxxxx
VITE_FIREBASE_STORAGE_BUCKET=unieventos-xxxxx.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abcdef

⚠️ Atenção firebase.auth() com namespace não existe mais. A única forma correta no SDK 12 é a API modular: importar funções soltas (getAuth, signInWithEmailAndPassword, onAuthStateChanged etc.) de 'firebase/auth' e passar a instância auth como primeiro argumento. Se você encontrar tutorial usando firebase.auth().signInWithEmailAndPassword(...), está desatualizado — não copie.

Um serviço dedicado para as operações de autenticação, separado do firebase.js de inicialização:

JavaScript
// src/services/authService.js
import {
  createUserWithEmailAndPassword,
  signInWithEmailAndPassword,
  signOut,
  onAuthStateChanged,
  updateProfile,
  sendPasswordResetEmail,
  GoogleAuthProvider,
  signInWithPopup,
} from 'firebase/auth'
import { auth } from './firebase'

// Traduz os códigos de erro mais comuns do Firebase Auth para mensagens
// em português — o usuário final não precisa saber o que é "auth/weak-password".
const MENSAGENS_ERRO = {
  'auth/invalid-credential': 'E-mail ou senha incorretos.',
  'auth/invalid-email': 'E-mail em formato inválido.',
  'auth/email-already-in-use': 'Este e-mail já está cadastrado.',
  'auth/weak-password': 'A senha precisa ter pelo menos 6 caracteres.',
  'auth/network-request-failed': 'Falha de conexão. Verifique sua internet.',
  'auth/too-many-requests': 'Muitas tentativas seguidas. Aguarde um instante.',
  'auth/popup-closed-by-user': 'Janela de login fechada antes de concluir.',
}

function traduzirErro(erro) {
  const mensagem = MENSAGENS_ERRO[erro.code]
  return mensagem ?? 'Não foi possível concluir a operação. Tente novamente.'
}

export async function cadastrar(nome, email, senha) {
  try {
    const credencial = await createUserWithEmailAndPassword(auth, email, senha)
    // O Firebase não pede nome no cadastro por e-mail/senha — setamos depois.
    await updateProfile(credencial.user, { displayName: nome })
    return credencial.user
  } catch (erro) {
    throw new Error(traduzirErro(erro))
  }
}

export async function entrar(email, senha) {
  try {
    const credencial = await signInWithEmailAndPassword(auth, email, senha)
    return credencial.user
  } catch (erro) {
    throw new Error(traduzirErro(erro))
  }
}

export async function entrarComGoogle() {
  try {
    const provedor = new GoogleAuthProvider()
    const credencial = await signInWithPopup(auth, provedor)
    return credencial.user
  } catch (erro) {
    throw new Error(traduzirErro(erro))
  }
}

export async function sair() {
  await signOut(auth)
}

export async function solicitarRedefinicaoSenha(email) {
  try {
    await sendPasswordResetEmail(auth, email)
  } catch (erro) {
    throw new Error(traduzirErro(erro))
  }
}

// Registra um observador do estado de login. Retorna a função de
// cancelamento — quem chamar deve guardá-la e invocar ao desmontar.
export function observarAutenticacao(callback) {
  return onAuthStateChanged(auth, callback)
}

💡 Dica onAuthStateChanged dispara sempre que o estado de login muda — login, logout, e também na primeira carga da página, depois que o SDK verifica o refresh token salvo no navegador. É esse terceiro caso que vamos explorar na store, a seguir.

5. Store de autenticação: resolvendo o problema do F5

Se você guardar o usuário logado só em uma variável reativa comum, ao apertar F5 ela reseta para null — mesmo que o usuário continue logado no Firebase. O SDK vai confirmar isso, mas de forma assíncrona, alguns milissegundos depois do primeiro render. Se o seu guard de rota checar usuario antes desse retorno, ele vai redirecionar um usuário legitimamente logado para a tela de login. É um bug clássico.

A solução: a store expõe uma Promise de inicialização, e o guard de rota aguarda essa Promise antes de decidir.

JavaScript
// src/stores/authStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { observarAutenticacao } from '@/services/authService'

export const useAuthStore = defineStore('auth', () => {
  const usuario = ref(null)
  const carregando = ref(false)
  const inicializado = ref(false)

  // Promise única, compartilhada por todos que chamarem inicializar().
  // Evita registrar o observador do Firebase mais de uma vez.
  let promessaInicializacao = null

  function inicializar() {
    if (promessaInicializacao) return promessaInicializacao

    promessaInicializacao = new Promise((resolve) => {
      observarAutenticacao((usuarioFirebase) => {
        usuario.value = usuarioFirebase
        if (!inicializado.value) {
          inicializado.value = true
          resolve() // só resolve no PRIMEIRO disparo do observador
        }
      })
    })

    return promessaInicializacao
  }

  const estaLogado = computed(() => usuario.value !== null)

  // Custom claim "admin" só aparece depois de setCustomUserClaims (seção 7)
  // e de o usuário obter um novo ID token — ver observação na seção 7.
  const ehAdmin = computed(() => usuario.value?.customClaims?.admin === true)

  return { usuario, carregando, inicializado, inicializar, estaLogado, ehAdmin }
})

🔎 Por baixo do capô onAuthStateChanged dispara de novo toda vez que o token é renovado, mas resolvemos a Promise só na primeira vez (if (!inicializado.value)). Depois disso, os componentes que precisam de reatividade (menu, header) simplesmente leem usuario e estaLogado, que são refs/computed normais e continuam atualizando sozinhos.

ehAdmin como escrito acima lê customClaims diretamente do objeto User do Firebase, que não expõe essa propriedade por padrão — claims custom exigem decodificar o ID token (getIdTokenResult). Ajustamos isso corretamente na seção 7, depois de explicar custom claims no back-end; por ora, mantenha o getter, ele será completado adiante.

6. Protegendo rotas no Vue Router

JavaScript
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue'),
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue'),
    },
    {
      path: '/cadastro',
      name: 'cadastro',
      component: () => import('@/views/CadastroView.vue'),
    },
    {
      path: '/minhas-inscricoes',
      name: 'minhas-inscricoes',
      component: () => import('@/views/MinhasInscricoesView.vue'),
      meta: { requerAuth: true },
    },
    {
      path: '/admin/eventos',
      name: 'admin-eventos',
      component: () => import('@/views/admin/EventosAdminView.vue'),
      meta: { requerAuth: true, requerAdmin: true },
    },
  ],
})

router.beforeEach(async (to) => {
  const authStore = useAuthStore()

  // Aguarda o primeiro retorno do onAuthStateChanged antes de decidir
  // qualquer coisa — sem isso, um F5 numa rota protegida redireciona
  // para /login mesmo com o usuário já autenticado.
  await authStore.inicializar()

  if (to.meta.requerAuth && !authStore.estaLogado) {
    return {
      name: 'login',
      query: { redirect: to.fullPath },
    }
  }

  if (to.meta.requerAdmin && !authStore.ehAdmin) {
    return { name: 'home' }
  }

  return true
})

export default router

Depois do login, a tela de login redireciona de volta para onde o usuário queria ir:

Vue SFC
<!-- src/views/LoginView.vue (trecho de script) -->
<script setup>
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

async function aoLogarComSucesso() {
  const destino = route.query.redirect || '/'
  router.push(destino)
}
</script>

Escondendo itens de menu conforme o estado de login:

Vue SFC
<!-- src/components/BarraNavegacao.vue -->
<script setup>
import { useAuthStore } from '@/stores/authStore'
import { sair } from '@/services/authService'
import { useRouter } from 'vue-router'

const authStore = useAuthStore()
const router = useRouter()

async function aoClicarSair() {
  await sair()
  router.push('/login')
}
</script>

<template>
  <v-app-bar>
    <v-app-bar-title>UniEventos</v-app-bar-title>

    <template v-if="!authStore.estaLogado">
      <v-btn to="/login">Entrar</v-btn>
      <v-btn to="/cadastro">Cadastrar</v-btn>
    </template>

    <template v-else>
      <v-btn v-if="authStore.ehAdmin" to="/admin/eventos">Administração</v-btn>
      <v-btn to="/minhas-inscricoes">Minhas inscrições</v-btn>

      <v-menu>
        <template #activator="{ props }">
          <v-avatar v-bind="props" class="mr-2" style="cursor: pointer">
            <v-img
              v-if="authStore.usuario?.photoURL"
              :src="authStore.usuario.photoURL"
              :alt="authStore.usuario.displayName ?? 'Avatar do usuário'"
            />
            <span v-else>{{ authStore.usuario?.email?.[0]?.toUpperCase() }}</span>
          </v-avatar>
        </template>
        <v-list>
          <v-list-item :title="authStore.usuario?.displayName ?? authStore.usuario?.email" />
          <v-list-item title="Sair" @click="aoClicarSair" />
        </v-list>
      </v-menu>
    </template>
  </v-app-bar>
</template>

⚠️ Atenção Um beforeEach no Router impede que a interface mostre a tela protegida — mas qualquer pessoa pode desligar o JavaScript, chamar a API diretamente com curl ou editar o guard no DevTools. Guard de rota é UX, não segurança. A única barreira real está no back-end, validando o token em cada requisição — é o que vem na seção 7.

🧩 Padrão de projeto em uso: Proxy de proteção + Guard

O Proxy de proteção (variação estrutural do padrão Proxy) intercepta o acesso a um objeto real e decide se o acesso é permitido antes de repassar a chamada. É exatamente o papel do middleware autenticar que construímos na seção 7: ele fica na frente do controller real, verifica credenciais, e só deixa a chamada prosseguir se o token for válido — o controller nunca sabe que existe um "porteiro" antes dele.

O Guard (aqui usado no sentido do Vue Router — um "guarda de rota" comportamental, correlato ao Proxy de proteção do lado do front) cumpre o mesmo papel do lado da navegação: intercepta a transição de rota e decide, antes de renderizar, se ela deve prosseguir, ser bloqueada ou redirecionada. Repare que os dois padrões resolvem o mesmo problema — controlar acesso — em duas camadas diferentes da aplicação, e nenhum substitui o outro.

7. Enviando o token em cada requisição

O usuário logado no Firebase tem um método getIdToken() que devolve o JWT atual (renovando-o automaticamente se estiver perto de expirar). Plugamos isso no interceptor de requisição do Axios, criado na Aula 06:

JavaScript
// src/services/api.js
import axios from 'axios'
import { auth } from './firebase'
import router from '@/router'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api',
})

api.interceptors.request.use(async (config) => {
  const usuarioAtual = auth.currentUser

  if (usuarioAtual) {
    // getIdToken() usa o cache do SDK; só bate na rede do Firebase
    // quando o token está perto de expirar (renovação automática).
    const token = await usuarioAtual.getIdToken()
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})

api.interceptors.response.use(
  (resposta) => resposta,
  (erro) => {
    if (erro.response?.status === 401) {
      // Token ausente, inválido ou expirado sem chance de renovação
      // automática (ex.: usuário revogado no console). Mandamos para
      // o login preservando a rota atual.
      router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
    }
    return Promise.reject(erro)
  },
)

export default api

💡 Dica Não é preciso gerenciar expiração de token manualmente. O SDK do Firebase renova o ID token sozinho (usando o refresh token) sempre que getIdToken() é chamado e o token atual está a menos de 5 minutos de expirar. O interceptor de requisição, ao chamar getIdToken() antes de cada chamada, já se beneficia disso de graça.

8. Verificando o token no back-end com firebase-admin

Do lado do cliente, qualquer um pode afirmar ser quem quiser — inclusive forjar um cabeçalho Authorization. A prova de identidade real só existe quando o back-end valida a assinatura do token contra as chaves públicas do Firebase. É isso que o pacote firebase-admin faz.

8.1 Gerando a chave de conta de serviço

No console do Firebase: Configurações do projeto → Contas de serviço → Gerar nova chave privada. Isso baixa um .json com credenciais completas de administrador do projeto — trate como uma senha.

Terminal
# unieventos-api/.gitignore
node_modules/
.env
serviceAccountKey.json

⚠️ Atenção serviceAccountKey.json nunca vai para o Git. Se você já commitou por engano, o arquivo precisa ser considerado comprometido: revogue a chave no console (Contas de serviço → gerenciar chaves) e gere outra. Em produção (Render, Railway etc.) prefira uma variável de ambiente com o JSON inteiro codificado em base64, decodificada na inicialização — assim nenhum arquivo sensível precisa existir no disco do servidor.

Terminal
# instalação, versão travada conforme especificação da disciplina
npm install firebase-admin@14.2.0
JavaScript
// unieventos-api/src/config/firebaseAdmin.js
import { initializeApp, cert, getApps } from 'firebase-admin/app'
import { getAuth } from 'firebase-admin/auth'
import fs from 'node:fs'

function carregarCredencial() {
  // Em produção: variável de ambiente com o JSON em base64.
  if (process.env.FIREBASE_SERVICE_ACCOUNT_BASE64) {
    const json = Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_BASE64, 'base64').toString('utf-8')
    return JSON.parse(json)
  }

  // Em desenvolvimento: arquivo local, fora do Git.
  const conteudo = fs.readFileSync(new URL('../../serviceAccountKey.json', import.meta.url), 'utf-8')
  return JSON.parse(conteudo)
}

// getApps() evita inicializar duas vezes se este módulo for importado
// em mais de um lugar (ex.: em testes).
if (getApps().length === 0) {
  initializeApp({ credential: cert(carregarCredencial()) })
}

export const authAdmin = getAuth()

8.2 Middleware autenticar

JavaScript
// unieventos-api/src/middlewares/autenticar.js
import { authAdmin } from '../config/firebaseAdmin.js'

export async function autenticar(req, res, next) {
  const cabecalho = req.headers.authorization

  if (!cabecalho?.startsWith('Bearer ')) {
    return res.status(401).json({ erro: 'Token de autenticação ausente.' })
  }

  const token = cabecalho.replace('Bearer ', '')

  try {
    const tokenDecodificado = await authAdmin.verifyIdToken(token)

    // Popula req.usuario para os middlewares e controllers seguintes
    // usarem — igual fizemos com req.body validado na Aula 08.
    req.usuario = {
      uid: tokenDecodificado.uid,
      email: tokenDecodificado.email,
      admin: tokenDecodificado.admin === true, // custom claim, seção 8.4
    }

    next()
  } catch (erro) {
    // Cobre token expirado, assinatura inválida, token forjado etc.
    return res.status(401).json({ erro: 'Token inválido ou expirado.' })
  }
}

Express 5 captura erros de handlers async automaticamente (Aula 08), mas aqui usamos try/catch de propósito: um token inválido não é um erro inesperado do servidor (500), é uma resposta de negócio esperada (401). Deixar o errorHandler central tratar isso como 500 estaria errado.

8.3 Middleware autorizar

JavaScript
// unieventos-api/src/middlewares/autorizar.js
export function autorizar(papeis = []) {
  return (req, res, next) => {
    if (!req.usuario) {
      // autenticar() deve sempre rodar antes de autorizar() na cadeia
      return res.status(401).json({ erro: 'Token de autenticação ausente.' })
    }

    const temPermissao = papeis.includes('admin') ? req.usuario.admin : true

    if (!temPermissao) {
      return res.status(403).json({ erro: 'Você não tem permissão para esta ação.' })
    }

    next()
  }
}

🔎 Por baixo do capô 401 (Unauthorized) significa "eu não sei quem você é" — token ausente ou inválido. 403 (Forbidden) significa "eu sei quem você é, mas você não pode fazer isso" — token válido, mas sem a permissão necessária. Misturar os dois confunde quem está depurando o front.

8.4 Custom claims: marcando um usuário como admin

Custom claims são pares chave-valor extras que o Firebase embute no payload do JWT, definidos pelo back-end (nunca pelo próprio usuário). Um script único, rodado manualmente, promove um usuário a administrador:

JavaScript
// unieventos-api/scripts/promoverAdmin.js
// Uso: node scripts/promoverAdmin.js email@exemplo.com
import '../src/config/firebaseAdmin.js'
import { getAuth } from 'firebase-admin/auth'

const email = process.argv[2]

if (!email) {
  console.error('Uso: node scripts/promoverAdmin.js <email>')
  process.exit(1)
}

const auth = getAuth()
const usuario = await auth.getUserByEmail(email)

await auth.setCustomUserClaims(usuario.uid, { admin: true })

console.log(`${email} agora é administrador.`)
Terminal
node scripts/promoverAdmin.js professor@unemat.br

⚠️ Atenção Custom claims só aparecem em um novo ID token. Se o usuário já estava logado quando você rodou o script, ele precisa deslogar e logar de novo (ou o front precisa forçar getIdToken(true), com true pedindo renovação forçada) para o token trazer admin: true. É um erro comum: "rodei o script e continua sem permissão" — o token antigo, em cache no navegador, simplesmente ainda não tem a claim.

Com isso, completamos o ehAdmin da store (seção 5), que ficou pendente. A forma correta de ler a claim no front é via getIdTokenResult(), não pela propriedade customClaims (que não existe no objeto User):

JavaScript
// src/stores/authStore.js — ajuste da action inicializar()
function inicializar() {
  if (promessaInicializacao) return promessaInicializacao

  promessaInicializacao = new Promise((resolve) => {
    observarAutenticacao(async (usuarioFirebase) => {
      if (usuarioFirebase) {
        const resultado = await usuarioFirebase.getIdTokenResult()
        usuario.value = usuarioFirebase
        ehAdminClaim.value = resultado.claims.admin === true
      } else {
        usuario.value = null
        ehAdminClaim.value = false
      }

      if (!inicializado.value) {
        inicializado.value = true
        resolve()
      }
    })
  })

  return promessaInicializacao
}
JavaScript
// e trocar o computed ehAdmin por uma ref simples atualizada acima
const ehAdminClaim = ref(false)
const ehAdmin = computed(() => ehAdminClaim.value)

8.5 Aplicando nos endpoints de eventos

JavaScript
// unieventos-api/src/routes/eventosRoutes.js
import { Router } from 'express'
import { autenticar } from '../middlewares/autenticar.js'
import { autorizar } from '../middlewares/autorizar.js'
import * as eventosController from '../controllers/eventosController.js'

const router = Router()

// Leitura pública — qualquer visitante, sem token, vê os eventos
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)

// Escrita exige apenas estar autenticado
router.post('/', autenticar, eventosController.criar)
router.put('/:id', autenticar, eventosController.atualizar)

// Exclusão exige estar autenticado E ser admin
router.delete('/:id', autenticar, autorizar(['admin']), eventosController.remover)

export default router

💻 Mão na massa — telas de autenticação completas

Passo 1 — Tela de cadastro

Vue SFC
<!-- src/views/CadastroView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { cadastrar } from '@/services/authService'

const router = useRouter()

const nome = ref('')
const email = ref('')
const senha = ref('')
const confirmarSenha = ref('')
const erro = ref('')
const carregando = ref(false)

const regraObrigatorio = (v) => !!v || 'Campo obrigatório'
const regraEmail = (v) => /.+@.+\..+/.test(v) || 'E-mail inválido'
const regraSenhaMinima = (v) => v.length >= 6 || 'Mínimo de 6 caracteres'
const regraSenhasIguais = (v) => v === senha.value || 'As senhas não coincidem'

async function aoSubmeter() {
  erro.value = ''
  carregando.value = true
  try {
    await cadastrar(nome.value, email.value, senha.value)
    router.push('/')
  } catch (e) {
    erro.value = e.message
  } finally {
    carregando.value = false
  }
}
</script>

<template>
  <v-container class="d-flex align-center justify-center" style="min-height: 80vh">
    <v-card max-width="420" width="100%" class="pa-4">
      <v-card-title>Criar conta</v-card-title>

      <v-form @submit.prevent="aoSubmeter">
        <v-card-text>
          <v-alert v-if="erro" type="error" class="mb-4" density="compact">
            {{ erro }}
          </v-alert>

          <v-text-field
            v-model="nome"
            label="Nome completo"
            :rules="[regraObrigatorio]"
          />
          <v-text-field
            v-model="email"
            label="E-mail"
            type="email"
            :rules="[regraObrigatorio, regraEmail]"
          />
          <v-text-field
            v-model="senha"
            label="Senha"
            type="password"
            :rules="[regraObrigatorio, regraSenhaMinima]"
          />
          <v-text-field
            v-model="confirmarSenha"
            label="Confirmar senha"
            type="password"
            :rules="[regraObrigatorio, regraSenhasIguais]"
          />
        </v-card-text>

        <v-card-actions>
          <v-btn type="submit" color="primary" block :loading="carregando">
            Cadastrar
          </v-btn>
        </v-card-actions>
      </v-form>

      <v-card-text class="text-center">
        Já tem conta? <router-link to="/login">Entrar</router-link>
      </v-card-text>
    </v-card>
  </v-container>
</template>

Passo 2 — Tela de login, com Google e redirecionamento

Vue SFC
<!-- src/views/LoginView.vue -->
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { entrar, entrarComGoogle, solicitarRedefinicaoSenha } from '@/services/authService'

const route = useRoute()
const router = useRouter()

const email = ref('')
const senha = ref('')
const erro = ref('')
const mensagemSucesso = ref('')
const carregando = ref(false)

function irParaDestino() {
  const destino = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
  router.push(destino)
}

async function aoSubmeter() {
  erro.value = ''
  carregando.value = true
  try {
    await entrar(email.value, senha.value)
    irParaDestino()
  } catch (e) {
    erro.value = e.message
  } finally {
    carregando.value = false
  }
}

async function aoClicarGoogle() {
  erro.value = ''
  carregando.value = true
  try {
    await entrarComGoogle()
    irParaDestino()
  } catch (e) {
    erro.value = e.message
  } finally {
    carregando.value = false
  }
}

async function aoEsquecerSenha() {
  erro.value = ''
  mensagemSucesso.value = ''
  if (!email.value) {
    erro.value = 'Informe o e-mail para receber o link de redefinição.'
    return
  }
  try {
    await solicitarRedefinicaoSenha(email.value)
    mensagemSucesso.value = 'Enviamos um link de redefinição para o seu e-mail.'
  } catch (e) {
    erro.value = e.message
  }
}
</script>

<template>
  <v-container class="d-flex align-center justify-center" style="min-height: 80vh">
    <v-card max-width="420" width="100%" class="pa-4">
      <v-card-title>Entrar</v-card-title>

      <v-form @submit.prevent="aoSubmeter">
        <v-card-text>
          <v-alert v-if="erro" type="error" class="mb-4" density="compact">
            {{ erro }}
          </v-alert>
          <v-alert v-if="mensagemSucesso" type="success" class="mb-4" density="compact">
            {{ mensagemSucesso }}
          </v-alert>

          <v-text-field v-model="email" label="E-mail" type="email" />
          <v-text-field v-model="senha" label="Senha" type="password" />

          <v-btn variant="text" size="small" @click="aoEsquecerSenha">
            Esqueci minha senha
          </v-btn>
        </v-card-text>

        <v-card-actions class="flex-column">
          <v-btn type="submit" color="primary" block :loading="carregando">
            Entrar
          </v-btn>
          <v-btn
            variant="outlined"
            block
            class="mt-2"
            prepend-icon="mdi-google"
            :loading="carregando"
            @click="aoClicarGoogle"
          >
            Entrar com Google
          </v-btn>
        </v-card-actions>
      </v-form>

      <v-card-text class="text-center">
        Não tem conta? <router-link to="/cadastro">Cadastrar</router-link>
      </v-card-text>
    </v-card>
  </v-container>
</template>

Passo 3 — Inicializando a store no main.js

JavaScript
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/authStore'

import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'

const vuetify = createVuetify({ theme: { defaultTheme: 'light' } })

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)

// Dispara a inicialização o quanto antes; o router aguarda a mesma
// Promise no beforeEach, então não há corrida entre os dois.
useAuthStore().inicializar()

app.mount('#app')

Passo 4 — Área administrativa protegida

Vue SFC
<!-- src/views/admin/EventosAdminView.vue -->
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/authStore'

const authStore = useAuthStore()

onMounted(() => {
  // Se chegou até aqui, o guard de rota já garantiu requerAuth + requerAdmin.
  console.log('Admin logado:', authStore.usuario.email)
})
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Administração de eventos</h1>
    <p>Bem-vindo(a), {{ authStore.usuario?.displayName ?? authStore.usuario?.email }}.</p>
    <!-- CRUD completo de eventos vem na Aula 11 -->
  </v-container>
</template>

Passo 5 — Testando a API manualmente

Sem token — deve retornar 401:

Terminal
curl -i http://localhost:3000/api/eventos -X POST \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Semana da Computação"}'

Com token inválido (qualquer string) — também 401:

Terminal
curl -i http://localhost:3000/api/eventos -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer token-forjado-qualquer" \
  -d '{"titulo":"Semana da Computação"}'

Com token válido — copie o token real do DevTools (aba Network, requisição feita pelo front logado, cabeçalho Authorization) e cole aqui:

Terminal
curl -i http://localhost:3000/api/eventos -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer COLE_O_TOKEN_AQUI" \
  -d '{"titulo":"Semana da Computação","categoria":"palestra","data_hora":"2026-12-01T19:00:00","local":"Auditório","vagas":80}'

Tentando excluir sem ser admin (usuário autenticado comum) — deve retornar 403:

Terminal
curl -i http://localhost:3000/api/eventos/1 -X DELETE \
  -H "Authorization: Bearer TOKEN_DE_USUARIO_COMUM"

🧪 Laboratório

1. Cadastro e login funcionando. Crie uma conta pelo formulário de cadastro do seu projeto autoral, faça logout e faça login de novo.

DicaAbra o DevTools → Application → verifique se há chaves salvas pelo Firebase no IndexedDB/LocalStorage após o login.

2. Login com Google. Habilite o provedor Google no console e teste entrarComGoogle().

DicaSe o popup fechar sozinho sem erro visível, confira o console — geralmente é domínio não autorizado em Authentication → Settings → Authorized domains.

3. Rota protegida. Crie uma rota meta: { requerAuth: true } no seu projeto e confirme que, deslogado, você é redirecionado para /login?redirect=... e volta para a rota certa após logar.

DicaTeste apertando F5 na rota protegida já logado — não pode redirecionar para login.

4. Middleware autenticar na API. Proteja um endpoint de escrita do seu projeto autoral e teste os três cenários de curl da seção anterior.

DicaUm token expira em 1h — se testar depois de muito tempo, gere outro logando de novo no front.

5. Custom claim de admin. Rode o script promoverAdmin.js com seu próprio e-mail, deslogue e logue de novo, e confirme que authStore.ehAdmin fica true e que o menu de administração aparece.

DicaSe continuar `false`, o token em cache é o antigo — force `getIdTokenResult(true)` ou deslogue mesmo.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
401 mesmo logado no front Interceptor não aguardou getIdToken() (esqueceu await) Confirme que a função do interceptor é async e usa await usuarioAtual.getIdToken()
Guard redireciona para login mesmo autenticado, só no F5 beforeEach não aguardou inicializar() Adicione await authStore.inicializar() como primeira linha do guard
"auth/network-request-failed" Sem internet, ou domínio bloqueado por extensão/firewall Verificar conexão; testar em aba anônima sem extensões
Token válido no Postman mas 401 na API Relógio do servidor fora de sincronia (token "ainda não válido" ou "expirado" por diferença de horário) Sincronizar o relógio do servidor (NTP); em nuvem isso raramente acontece, mas em VM local pode
CORS bloqueia a requisição com Authorization cors() no Express sem liberar o header customizado Configurar cors({ origin: 'http://localhost:5173', allowedHeaders: ['Content-Type', 'Authorization'] })
admin sempre false mesmo após setCustomUserClaims Token antigo em cache, claim não propagada Deslogar e logar de novo, ou getIdTokenResult(true) para forçar renovação
req.usuario é undefined no controller autorizar usado sem autenticar antes na cadeia de middlewares Sempre montar a rota como autenticar, autorizar([...]), nessa ordem

🏠 Atividade assíncrona (1 h)

No seu projeto autoral: implemente cadastro, login, logout e proteção de pelo menos uma rota do front (requerAuth: true) e um endpoint de escrita da API (autenticar). Grave um GIF ou vídeo curto (menos de 1 minuto) mostrando: (1) tentativa de acessar a rota protegida deslogado sendo redirecionada, (2) login, (3) acesso liberado, (4) curl sem token retornando 401. Suba o material (código + evidência) no repositório do projeto e envie o link pelo SIGAA.

Critério de pronto: os quatro passos do vídeo aparecem, e o commit com a implementação está no repositório.

✅ Checkpoint do projeto autoral

  • [ ] src/services/firebase.js inicializado com variáveis de ambiente (nada de chave hardcoded no código).
  • [ ] Cadastro, login, logout e login com Google funcionando na interface.
  • [ ] stores/authStore.js com inicializar(), estaLogado e ehAdmin implementados corretamente.
  • [ ] Pelo menos uma rota protegida com meta: { requerAuth: true } funcionando após F5.
  • [ ] Interceptor Axios enviando Authorization: Bearer <token> em toda requisição autenticada.
  • [ ] API com firebase-admin configurado e serviceAccountKey.json fora do Git.
  • [ ] Pelo menos um endpoint de escrita protegido por autenticar, e um por autorizar(['admin']).
  • [ ] Testes manuais com curl (sem token, token inválido, token válido) documentados.

📚 Para aprofundar

Na Aula 11 fechamos o ciclo: CRUD completo de eventos, ponta a ponta, autenticado — Vue consumindo a API Express, que persiste no MySQL, tudo validado com o token do Firebase que construímos hoje.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 11 — Integrando front-end com back-end: CRUD

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 10 o UniEventos passou a exigir token do Firebase para escrever dados, e a API passou a validar esse token com firebase-admin. Todas as peças já existem separadas: Vue no front, Express no back, MySQL persistindo, Firebase autenticando. Hoje é a aula de fechar o ciclo — o CRUD completo de eventos, ponta a ponta, com as duas pontas conversando por um contrato bem definido.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Documentar um contrato de API em tabela, cobrindo os 5 endpoints de um recurso CRUD.
  • Completar a camada controller → service → repository com regras de negócio, paginação e busca.
  • Construir uma camada services/ no front alinhada exatamente ao contrato da API.
  • Implementar uma store Pinia de domínio com estados de lista, item atual, carregamento e erro.
  • Diferenciar atualização otimista de pessimista e justificar a escolha da pessimista para o projeto.
  • Construir telas de listagem, formulário (criar/editar na mesma tela) e confirmação de exclusão em Vuetify.
  • Depurar uma integração front-back usando a aba Network do DevTools e reproduzir requisições em curl.

📋 Pré-requisitos desta aula

Checklist antes de começar:

  • [ ] unieventos-api com autenticação Firebase funcionando (Aula 10) e CRUD básico de eventos no MySQL (Aula 09).
  • [ ] unieventos-web com Pinia, Vue Router, Vuetify e authStore funcionando (Aulas 05–10).
  • [ ] MySQL rodando localmente com a tabela eventos criada.
  • [ ] unieventos-api e unieventos-web rodando em portas diferentes (ex.: 3000 e 5173) — vamos revisitar CORS.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Contrato de API; completar back-end (validação, regras de negócio, paginação, busca)
2 50 min eventosService.js, eventosStore.js, atualização otimista × pessimista, telas de listagem e formulário
3 50 min Upload de imagem; depuração ponta a ponta (Network, curl, CORS); laboratório

1. Contrato de API: o acordo entre as duas pontas

Antes de escrever uma linha de código de integração, front e back precisam concordar sobre um contrato: para cada endpoint, qual método HTTP, qual caminho, o que vai no corpo da requisição, o que volta na resposta, quais status e se exige autenticação. É esse contrato — não o código de um lado ou de outro — que permite que duas pessoas (ou você, em momentos diferentes) trabalhem em front e back sem precisar ler o código um do outro toda hora.

Contrato do recurso evento:

Método Caminho Autenticação
GET /api/eventos Pública
GET /api/eventos/:id Pública
POST /api/eventos Autenticado
PUT /api/eventos/:id Autenticado
DELETE /api/eventos/:id Admin

Detalhando corpo e resposta de cada um:

GET /api/eventos — lista paginada, com filtros por query string.

Query string: ?pagina=1&limite=10&busca=semana&categoria=palestra

JSON
{
  "dados": [
    {
      "id": 1,
      "titulo": "Semana da Computação",
      "descricao": "Palestras e minicursos de tecnologia",
      "categoria": "palestra",
      "data_hora": "2026-12-01T19:00:00.000Z",
      "local": "Auditório Central",
      "vagas": 80,
      "vagas_disponiveis": 62,
      "imagem_url": "https://.../semana-computacao.jpg"
    }
  ],
  "paginacao": { "pagina": 1, "limite": 10, "total": 34, "totalPaginas": 4 }
}

Status: 200 OK.

GET /api/eventos/:id — um evento. Status 200 OK ou 404 Not Found com { "erro": "Evento não encontrado." }.

POST /api/eventos — corpo:

JSON
{
  "titulo": "Semana da Computação",
  "descricao": "Palestras e minicursos de tecnologia",
  "categoria": "palestra",
  "data_hora": "2026-12-01T19:00:00",
  "local": "Auditório Central",
  "vagas": 80,
  "imagem_url": "https://.../semana-computacao.jpg"
}

Resposta: o evento criado, com id, status 201 Created. Erros de validação: 400 Bad Request com { "erro": "...", "detalhes": [...] }. Sem token: 401.

PUT /api/eventos/:id — mesmo corpo do POST (campos parciais também aceitos). Resposta: o evento atualizado, 200 OK. Sem token: 401. Não é dono nem admin: decisão de negócio do projeto (aqui, qualquer autenticado pode editar — ver seção 3). Evento inexistente: 404.

DELETE /api/eventos/:id — sem corpo. Resposta: 204 No Content. Sem token: 401. Sem ser admin: 403. Evento com inscritos: 409 Conflict com { "erro": "Não é possível excluir evento com inscritos." }.

💡 Dica Escreva esse contrato antes de codificar, mesmo sozinho. Ele vira a fonte da verdade quando front e back divergem — e em equipes reais costuma virar um arquivo OpenAPI/Swagger, que veremos na Aula 14. Por ora, uma tabela em Markdown já resolve.

2. Back-end: completando controller → service → repository

Revisamos a estrutura da Aula 09 e adicionamos: validação com zod, regras de negócio, paginação e busca.

2.1 Repository — só acesso a dados, sem regra de negócio

JavaScript
// unieventos-api/src/repositories/eventosRepository.js
import { pool } from '../config/database.js'

export async function listar({ pagina, limite, busca, categoria }) {
  const offset = (pagina - 1) * limite
  const condicoes = []
  const parametros = []

  if (busca) {
    condicoes.push('(titulo LIKE ? OR descricao LIKE ?)')
    parametros.push(`%${busca}%`, `%${busca}%`)
  }
  if (categoria) {
    condicoes.push('categoria = ?')
    parametros.push(categoria)
  }

  const clausulaWhere = condicoes.length > 0 ? `WHERE ${condicoes.join(' AND ')}` : ''

  const [linhas] = await pool.query(
    `SELECT e.*, (e.vagas - COALESCE(COUNT(i.id), 0)) AS vagas_disponiveis
     FROM eventos e
     LEFT JOIN inscricoes i ON i.evento_id = e.id
     ${clausulaWhere}
     GROUP BY e.id
     ORDER BY e.data_hora ASC
     LIMIT ? OFFSET ?`,
    [...parametros, limite, offset],
  )

  const [[{ total }]] = await pool.query(
    `SELECT COUNT(*) AS total FROM eventos e ${clausulaWhere}`,
    parametros,
  )

  return { linhas, total }
}

export async function buscarPorId(id) {
  const [linhas] = await pool.query(
    `SELECT e.*, (e.vagas - COALESCE(COUNT(i.id), 0)) AS vagas_disponiveis
     FROM eventos e
     LEFT JOIN inscricoes i ON i.evento_id = e.id
     WHERE e.id = ?
     GROUP BY e.id`,
    [id],
  )
  return linhas[0] ?? null
}

export async function contarInscritos(id, conexao = pool) {
  const [[{ total }]] = await conexao.query(
    'SELECT COUNT(*) AS total FROM inscricoes WHERE evento_id = ?',
    [id],
  )
  return total
}

export async function criar(evento) {
  const [resultado] = await pool.query(
    `INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
     VALUES (?, ?, ?, ?, ?, ?, ?)`,
    [
      evento.titulo,
      evento.descricao,
      evento.categoria,
      evento.data_hora,
      evento.local,
      evento.vagas,
      evento.imagem_url ?? null,
    ],
  )
  return buscarPorId(resultado.insertId)
}

export async function atualizar(id, evento) {
  await pool.query(
    `UPDATE eventos
     SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
     WHERE id = ?`,
    [
      evento.titulo,
      evento.descricao,
      evento.categoria,
      evento.data_hora,
      evento.local,
      evento.vagas,
      evento.imagem_url ?? null,
      id,
    ],
  )
  return buscarPorId(id)
}

export async function remover(id) {
  await pool.query('DELETE FROM eventos WHERE id = ?', [id])
}

export async function decrementarVagaEmTransacao(id) {
  // Transação: ler vagas disponíveis e inserir a inscrição são duas
  // operações que precisam ser atômicas — senão dois usuários podem
  // "ganhar" a última vaga ao mesmo tempo (condição de corrida).
  const conexao = await pool.getConnection()
  try {
    await conexao.beginTransaction()

    const [linhas] = await conexao.query(
      'SELECT vagas, (SELECT COUNT(*) FROM inscricoes WHERE evento_id = ?) AS inscritos FROM eventos WHERE id = ? FOR UPDATE',
      [id, id],
    )

    const evento = linhas[0]
    if (!evento) {
      await conexao.rollback()
      throw new Error('EVENTO_NAO_ENCONTRADO')
    }
    if (evento.inscritos >= evento.vagas) {
      await conexao.rollback()
      throw new Error('SEM_VAGAS')
    }

    await conexao.commit()
    return true
  } catch (erro) {
    await conexao.rollback()
    throw erro
  } finally {
    conexao.release()
  }
}

🔎 Por baixo do capô FOR UPDATE trava a linha lida até o fim da transação, impedindo que outra requisição simultânea leia o mesmo número de vagas antes do commit. Sem isso, duas requisições concorrentes poderiam ambas ler "1 vaga disponível" e ambas inserirem a inscrição, estourando a capacidade do evento.

2.2 Service — regras de negócio e validação

JavaScript
// unieventos-api/src/services/eventosService.js
import { z } from 'zod'
import * as eventosRepository from '../repositories/eventosRepository.js'

export const esquemaEvento = z.object({
  titulo: z.string().trim().min(3, 'Título precisa ter ao menos 3 caracteres'),
  descricao: z.string().trim().min(10, 'Descrição precisa ter ao menos 10 caracteres'),
  categoria: z.enum(['palestra', 'minicurso', 'workshop'], {
    message: 'Categoria precisa ser palestra, minicurso ou workshop',
  }),
  data_hora: z
    .string()
    .datetime({ offset: true, message: 'Data e hora em formato ISO inválido' })
    .or(z.string().min(1)) // aceita também "2026-12-01T19:00:00" sem offset
    .refine((valor) => !Number.isNaN(Date.parse(valor)), 'Data e hora inválidas')
    .refine((valor) => new Date(valor).getTime() > Date.now(), 'A data do evento não pode estar no passado'),
  local: z.string().trim().min(3, 'Local precisa ter ao menos 3 caracteres'),
  vagas: z.number().int().positive('Vagas precisa ser um número positivo'),
  imagem_url: z.url('URL de imagem inválida').optional().or(z.literal('')),
})

export const esquemaEventoParcial = esquemaEvento.partial()

export async function listar({ pagina = 1, limite = 10, busca, categoria }) {
  const paginaSegura = Math.max(1, Number(pagina))
  const limiteSeguro = Math.min(50, Math.max(1, Number(limite)))

  const { linhas, total } = await eventosRepository.listar({
    pagina: paginaSegura,
    limite: limiteSeguro,
    busca,
    categoria,
  })

  return {
    dados: linhas,
    paginacao: {
      pagina: paginaSegura,
      limite: limiteSeguro,
      total,
      totalPaginas: Math.ceil(total / limiteSeguro),
    },
  }
}

export async function buscarPorId(id) {
  const evento = await eventosRepository.buscarPorId(id)
  if (!evento) {
    const erro = new Error('Evento não encontrado.')
    erro.status = 404
    throw erro
  }
  return evento
}

export async function criar(dadosBrutos) {
  const dados = esquemaEvento.parse(dadosBrutos)
  return eventosRepository.criar(dados)
}

export async function atualizar(id, dadosBrutos) {
  await buscarPorId(id) // garante 404 antes de tentar validar/atualizar
  const dados = esquemaEvento.parse(dadosBrutos)
  return eventosRepository.atualizar(id, dados)
}

export async function remover(id) {
  await buscarPorId(id)

  const inscritos = await eventosRepository.contarInscritos(id)
  if (inscritos > 0) {
    const erro = new Error('Não é possível excluir evento com inscritos.')
    erro.status = 409
    throw erro
  }

  await eventosRepository.remover(id)
}

2.3 Controller — só orquestra requisição/resposta

JavaScript
// unieventos-api/src/controllers/eventosController.js
import * as eventosService from '../services/eventosService.js'

export async function listar(req, res) {
  const { pagina, limite, busca, categoria } = req.query
  const resultado = await eventosService.listar({ pagina, limite, busca, categoria })
  res.json(resultado)
}

export async function buscarPorId(req, res) {
  const evento = await eventosService.buscarPorId(req.params.id)
  res.json(evento)
}

export async function criar(req, res) {
  const evento = await eventosService.criar(req.body)
  res.status(201).json(evento)
}

export async function atualizar(req, res) {
  const evento = await eventosService.atualizar(req.params.id, req.body)
  res.json(evento)
}

export async function remover(req, res) {
  await eventosService.remover(req.params.id)
  res.status(204).send()
}

Sem try/catch nos controllers: Express 5 encaminha automaticamente qualquer rejeição de handler async para o middleware de erro central, criado na Aula 08. Só precisamos garantir que esse middleware trate ZodError (400), erros com .status customizado (404, 409) e, por padrão, 500:

JavaScript
// unieventos-api/src/middlewares/tratadorErros.js
import { ZodError } from 'zod'

export function tratadorErros(erro, req, res, next) {
  if (erro instanceof ZodError) {
    return res.status(400).json({
      erro: 'Dados inválidos.',
      detalhes: erro.issues.map((i) => ({ campo: i.path.join('.'), mensagem: i.message })),
    })
  }

  if (erro.status) {
    return res.status(erro.status).json({ erro: erro.message })
  }

  console.error(erro)
  res.status(500).json({ erro: 'Erro interno do servidor.' })
}

2.4 Rotas com validação por middleware Zod

Reaproveitando o padrão de validação da Aula 08, mas agora com o esquema parcial para PUT:

JavaScript
// unieventos-api/src/middlewares/validar.js
export function validar(esquema) {
  return (req, res, next) => {
    req.body = esquema.parse(req.body) // lança ZodError, capturado pelo tratadorErros
    next()
  }
}
JavaScript
// unieventos-api/src/routes/eventosRoutes.js
import { Router } from 'express'
import { autenticar } from '../middlewares/autenticar.js'
import { autorizar } from '../middlewares/autorizar.js'
import { validar } from '../middlewares/validar.js'
import { esquemaEvento, esquemaEventoParcial } from '../services/eventosService.js'
import * as eventosController from '../controllers/eventosController.js'

const router = Router()

router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
router.post('/', autenticar, validar(esquemaEvento), eventosController.criar)
router.put('/:id', autenticar, validar(esquemaEventoParcial), eventosController.atualizar)
router.delete('/:id', autenticar, autorizar(['admin']), eventosController.remover)

export default router

3. Front-end: services/ alinhado ao contrato

JavaScript
// src/services/eventosService.js
import api from './api'

export function listarEventos({ pagina = 1, limite = 10, busca = '', categoria = '' } = {}) {
  return api
    .get('/eventos', { params: { pagina, limite, busca, categoria } })
    .then((resposta) => resposta.data)
}

export function buscarEvento(id) {
  return api.get(`/eventos/${id}`).then((resposta) => resposta.data)
}

export function criarEvento(evento) {
  return api.post('/eventos', evento).then((resposta) => resposta.data)
}

export function atualizarEvento(id, evento) {
  return api.put(`/eventos/${id}`, evento).then((resposta) => resposta.data)
}

export function removerEvento(id) {
  return api.delete(`/eventos/${id}`)
}

💡 Dica Repare que cada função do service tem exatamente uma responsabilidade e um nome que espelha o contrato da seção 1. Ninguém que ler esse arquivo precisa saber que por trás existe Axios, interceptors ou token — e é exatamente esse esconderijo que a store vai explorar.

🧩 Padrão de projeto em uso: Facade

A camada services/ do front é um Facade (padrão estrutural): oferece uma interface simples (listarEventos(), criarEvento()) escondendo a complexidade de configurar o Axios, montar query string, tratar cabeçalhos de autenticação e formatar a resposta. A store, os componentes e as views nunca chamam api.get(...) diretamente — eles conversam só com o Facade. Se amanhã trocarmos Axios por fetch nativo, ou a URL base da API mudar de estrutura, só o services/ muda; store e telas continuam iguais. Voltaremos a esse mesmo princípio na Aula 12, quando o Adapter permitir trocar Express+MySQL por Supabase sem tocar no front.

4. Store Pinia: estado da lista, item atual e paginação

JavaScript
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as eventosService from '@/services/eventosService'

export const useEventosStore = defineStore('eventos', () => {
  const lista = ref([])
  const itemAtual = ref(null)
  const carregando = ref(false)
  const erro = ref(null)
  const paginacao = ref({ pagina: 1, limite: 10, total: 0, totalPaginas: 0 })

  async function carregar({ pagina = 1, limite = 10 } = {}) {
    carregando.value = true
    erro.value = null
    try {
      const resultado = await eventosService.listarEventos({ pagina, limite })
      lista.value = resultado.dados
      paginacao.value = resultado.paginacao
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Não foi possível carregar os eventos.'
    } finally {
      carregando.value = false
    }
  }

  async function buscar({ termo = '', categoria = '', pagina = 1 } = {}) {
    carregando.value = true
    erro.value = null
    try {
      const resultado = await eventosService.listarEventos({
        pagina,
        limite: paginacao.value.limite,
        busca: termo,
        categoria,
      })
      lista.value = resultado.dados
      paginacao.value = resultado.paginacao
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Não foi possível buscar os eventos.'
    } finally {
      carregando.value = false
    }
  }

  async function carregarUm(id) {
    carregando.value = true
    erro.value = null
    try {
      itemAtual.value = await eventosService.buscarEvento(id)
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Evento não encontrado.'
    } finally {
      carregando.value = false
    }
  }

  // Atualização PESSIMISTA: só mexemos no estado local depois que o
  // servidor confirmar. Mais lento na percepção do usuário, mas nunca
  // mostra dado que pode não ter sido salvo de fato — ver seção 5.
  async function criar(evento) {
    carregando.value = true
    erro.value = null
    try {
      const novoEvento = await eventosService.criarEvento(evento)
      lista.value = [novoEvento, ...lista.value]
      return novoEvento
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Não foi possível criar o evento.'
      throw e
    } finally {
      carregando.value = false
    }
  }

  async function atualizar(id, evento) {
    carregando.value = true
    erro.value = null
    try {
      const eventoAtualizado = await eventosService.atualizarEvento(id, evento)
      const indice = lista.value.findIndex((e) => e.id === Number(id))
      if (indice !== -1) lista.value[indice] = eventoAtualizado
      return eventoAtualizado
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Não foi possível atualizar o evento.'
      throw e
    } finally {
      carregando.value = false
    }
  }

  async function remover(id) {
    carregando.value = true
    erro.value = null
    try {
      await eventosService.removerEvento(id)
      lista.value = lista.value.filter((e) => e.id !== Number(id))
    } catch (e) {
      erro.value = e.response?.data?.erro ?? 'Não foi possível excluir o evento.'
      throw e
    } finally {
      carregando.value = false
    }
  }

  return { lista, itemAtual, carregando, erro, paginacao, carregar, buscar, carregarUm, criar, atualizar, remover }
})

5. Atualização otimista × pessimista

Duas estratégias para refletir uma mudança na interface depois de uma ação do usuário (criar, editar, excluir):

  • Otimista: a interface muda imediatamente, antes da resposta do servidor chegar — assumindo que vai dar certo. Se der errado, é preciso desfazer a mudança local e mostrar um erro. Percepção de velocidade excelente; complexidade de "desfazer" real.
  • Pessimista: a interface só muda depois que o servidor confirmar o sucesso. Mais lenta na percepção (o usuário espera o carregando), mas nunca mente sobre o estado — o que a tela mostra é sempre o que o servidor de fato tem salvo.

A store acima implementa a pessimista de propósito: cada ação (criar, atualizar, remover) só atualiza lista.value depois do await na chamada de serviço resolver com sucesso. Para um CRUD acadêmico, essa é a escolha mais segura — evita o cenário em que o aluno vê "Evento criado!" na tela, mas na verdade a validação do back-end rejeitou e nada foi salvo.

📌 Na prova Otimista = muda a tela antes de saber o resultado (rápido, mas exige lógica de desfazer). Pessimista = muda a tela só após confirmação do servidor (mais lento, mais seguro). Nesta disciplina, sempre pessimista.

💻 Mão na massa — telas de CRUD completas

Passo 1 — Listagem com v-data-table, busca e paginação no servidor

Vue SFC
<!-- src/views/EventosListaView.vue -->
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useEventosStore } from '@/stores/eventosStore'
import { useAuthStore } from '@/stores/authStore'
import DialogoConfirmacao from '@/components/DialogoConfirmacao.vue'

const router = useRouter()
const eventosStore = useEventosStore()
const authStore = useAuthStore()

const termoBusca = ref('')
const opcoesTabela = ref({ page: 1, itemsPerPage: 10 })
const dialogoExclusaoAberto = ref(false)
const eventoParaExcluir = ref(null)
const snackbar = ref({ aberto: false, texto: '', cor: 'success' })

const cabecalhos = [
  { title: 'Título', key: 'titulo' },
  { title: 'Categoria', key: 'categoria' },
  { title: 'Data', key: 'data_hora' },
  { title: 'Vagas', key: 'vagas_disponiveis' },
  { title: 'Ações', key: 'acoes', sortable: false },
]

async function carregarPagina() {
  await eventosStore.buscar({
    termo: termoBusca.value,
    pagina: opcoesTabela.value.page,
  })
}

onMounted(carregarPagina)
watch(() => opcoesTabela.value.page, carregarPagina)

let temporizadorBusca = null
watch(termoBusca, () => {
  clearTimeout(temporizadorBusca)
  // debounce simples: espera 400ms sem digitar antes de buscar de novo
  temporizadorBusca = setTimeout(() => {
    opcoesTabela.value.page = 1
    carregarPagina()
  }, 400)
})

function formatarData(isoString) {
  return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(isoString))
}

function abrirNovo() {
  router.push({ name: 'evento-form' })
}

function abrirEdicao(evento) {
  router.push({ name: 'evento-form', params: { id: evento.id } })
}

function pedirConfirmacaoExclusao(evento) {
  eventoParaExcluir.value = evento
  dialogoExclusaoAberto.value = true
}

async function confirmarExclusao() {
  try {
    await eventosStore.remover(eventoParaExcluir.value.id)
    snackbar.value = { aberto: true, texto: 'Evento excluído com sucesso.', cor: 'success' }
  } catch (e) {
    snackbar.value = { aberto: true, texto: eventosStore.erro ?? 'Erro ao excluir.', cor: 'error' }
  } finally {
    dialogoExclusaoAberto.value = false
  }
}
</script>

<template>
  <v-container>
    <div class="d-flex align-center justify-space-between mb-4">
      <h1 class="text-h4">Eventos</h1>
      <v-btn v-if="authStore.estaLogado" color="primary" prepend-icon="mdi-plus" @click="abrirNovo">
        Novo evento
      </v-btn>
    </div>

    <v-text-field
      v-model="termoBusca"
      label="Buscar por título ou descrição"
      prepend-inner-icon="mdi-magnify"
      clearable
      class="mb-4"
    />

    <v-progress-linear v-if="eventosStore.carregando" indeterminate color="primary" class="mb-2" />

    <v-alert v-if="eventosStore.erro" type="error" class="mb-4">
      {{ eventosStore.erro }}
    </v-alert>

    <v-data-table-server
      v-model:page="opcoesTabela.page"
      v-model:items-per-page="opcoesTabela.itemsPerPage"
      :headers="cabecalhos"
      :items="eventosStore.lista"
      :items-length="eventosStore.paginacao.total"
      :loading="eventosStore.carregando"
      item-value="id"
    >
      <template #item.data_hora="{ item }">
        {{ formatarData(item.data_hora) }}
      </template>

      <template #item.acoes="{ item }">
        <v-btn
          v-if="authStore.estaLogado"
          icon="mdi-pencil"
          size="small"
          variant="text"
          @click="abrirEdicao(item)"
        />
        <v-btn
          v-if="authStore.ehAdmin"
          icon="mdi-delete"
          size="small"
          variant="text"
          color="error"
          @click="pedirConfirmacaoExclusao(item)"
        />
      </template>

      <template #no-data>
        <p class="pa-4">Nenhum evento encontrado.</p>
      </template>
    </v-data-table-server>

    <DialogoConfirmacao
      v-model="dialogoExclusaoAberto"
      titulo="Excluir evento"
      :mensagem="`Tem certeza que deseja excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
      @confirmar="confirmarExclusao"
    />

    <v-snackbar v-model="snackbar.aberto" :color="snackbar.cor" timeout="4000">
      {{ snackbar.texto }}
    </v-snackbar>
  </v-container>
</template>

Passo 2 — Componente de confirmação reutilizável

Vue SFC
<!-- src/components/DialogoConfirmacao.vue -->
<script setup>
defineProps({
  modelValue: { type: Boolean, required: true },
  titulo: { type: String, default: 'Confirmar ação' },
  mensagem: { type: String, required: true },
})

const emit = defineEmits(['update:modelValue', 'confirmar'])

function cancelar() {
  emit('update:modelValue', false)
}

function confirmar() {
  emit('confirmar')
}
</script>

<template>
  <v-dialog :model-value="modelValue" max-width="420" @update:model-value="$emit('update:modelValue', $event)">
    <v-card>
      <v-card-title>{{ titulo }}</v-card-title>
      <v-card-text>{{ mensagem }}</v-card-text>
      <v-card-actions>
        <v-spacer />
        <v-btn variant="text" @click="cancelar">Cancelar</v-btn>
        <v-btn color="error" variant="flat" @click="confirmar">Excluir</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

Passo 3 — Formulário único para criar e editar

A mesma tela serve para os dois casos: a rota /eventos/novo não tem :id, e /eventos/:id/editar tem. O componente decide o modo olhando route.params.id.

JavaScript
// src/router/index.js — trecho das rotas de evento (adicionar ao array de routes)
{
  path: '/eventos/novo',
  name: 'evento-form',
  component: () => import('@/views/EventoFormView.vue'),
  meta: { requerAuth: true },
},
{
  path: '/eventos/:id/editar',
  name: 'evento-form-editar',
  component: () => import('@/views/EventoFormView.vue'),
  meta: { requerAuth: true },
  props: true,
},
Vue SFC
<!-- src/views/EventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventosStore } from '@/stores/eventosStore'

const props = defineProps({
  id: { type: [String, Number], default: null },
})

const route = useRoute()
const router = useRouter()
const eventosStore = useEventosStore()

const idEvento = computed(() => props.id ?? route.params.id ?? null)
const ehEdicao = computed(() => idEvento.value !== null)

const form = ref({
  titulo: '',
  descricao: '',
  categoria: 'palestra',
  data_hora: '',
  local: '',
  vagas: 1,
  imagem_url: '',
})

const categorias = [
  { title: 'Palestra', value: 'palestra' },
  { title: 'Minicurso', value: 'minicurso' },
  { title: 'Workshop', value: 'workshop' },
]

const salvando = ref(false)
const erroSubmissao = ref('')

const regraObrigatorio = (v) => !!v || 'Campo obrigatório'
const regraVagasPositiva = (v) => Number(v) > 0 || 'Vagas precisa ser maior que zero'

onMounted(async () => {
  if (ehEdicao.value) {
    await eventosStore.carregarUm(idEvento.value)
    if (eventosStore.itemAtual) {
      const evento = eventosStore.itemAtual
      form.value = {
        titulo: evento.titulo,
        descricao: evento.descricao,
        categoria: evento.categoria,
        data_hora: evento.data_hora?.slice(0, 16), // ISO -> formato do input datetime-local
        local: evento.local,
        vagas: evento.vagas,
        imagem_url: evento.imagem_url ?? '',
      }
    }
  }
})

async function aoSubmeter() {
  erroSubmissao.value = ''
  salvando.value = true
  try {
    const payload = { ...form.value, vagas: Number(form.value.vagas) }
    if (ehEdicao.value) {
      await eventosStore.atualizar(idEvento.value, payload)
    } else {
      await eventosStore.criar(payload)
    }
    router.push({ name: 'eventos-lista' })
  } catch (e) {
    erroSubmissao.value = eventosStore.erro ?? 'Não foi possível salvar o evento.'
  } finally {
    salvando.value = false
  }
}
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">{{ ehEdicao ? 'Editar evento' : 'Novo evento' }}</h1>

    <v-skeleton-loader v-if="eventosStore.carregando && ehEdicao" type="article" />

    <v-form v-else @submit.prevent="aoSubmeter">
      <v-alert v-if="erroSubmissao" type="error" class="mb-4" density="compact">
        {{ erroSubmissao }}
      </v-alert>

      <v-text-field v-model="form.titulo" label="Título" :rules="[regraObrigatorio]" />
      <v-textarea v-model="form.descricao" label="Descrição" :rules="[regraObrigatorio]" />
      <v-select v-model="form.categoria" :items="categorias" label="Categoria" />
      <v-text-field v-model="form.data_hora" type="datetime-local" label="Data e hora" :rules="[regraObrigatorio]" />
      <v-text-field v-model="form.local" label="Local" :rules="[regraObrigatorio]" />
      <v-text-field v-model.number="form.vagas" type="number" label="Vagas" :rules="[regraObrigatorio, regraVagasPositiva]" />
      <v-text-field v-model="form.imagem_url" label="URL da imagem (opcional)" />

      <v-btn type="submit" color="primary" :loading="salvando">
        {{ ehEdicao ? 'Salvar alterações' : 'Criar evento' }}
      </v-btn>
      <v-btn variant="text" class="ml-2" :to="{ name: 'eventos-lista' }">Cancelar</v-btn>
    </v-form>
  </v-container>
</template>

⚠️ Atenção data_hora?.slice(0, 16) funciona porque o back-end devolve um ISO 8601 completo (2026-12-01T19:00:00.000Z) e o input datetime-local espera AAAA-MM-DDTHH:mm. É um detalhe de formato pequeno, mas quebra silenciosamente se esquecido — o campo simplesmente aparece vazio.

6. Upload de imagem do evento com Firebase Storage

Escolhemos Firebase Storage (o front já tem o SDK do Firebase configurado desde a Aula 07/10) para o upload da imagem do evento.

JavaScript
// src/services/storageService.js
import { getStorage, ref as storageRef, uploadBytes, getDownloadURL } from 'firebase/storage'
import { auth } from './firebase'

const storage = getStorage()

export async function enviarImagemEvento(arquivo) {
  if (!auth.currentUser) {
    throw new Error('É preciso estar autenticado para enviar imagens.')
  }

  const nomeUnico = `${Date.now()}-${arquivo.name}`
  const caminho = `eventos/${nomeUnico}`
  const referencia = storageRef(storage, caminho)

  await uploadBytes(referencia, arquivo)
  return getDownloadURL(referencia)
}
Vue SFC
<!-- trecho a adicionar em EventoFormView.vue: campo de upload -->
<script setup>
// ...imports existentes
import { enviarImagemEvento } from '@/services/storageService'

const enviandoImagem = ref(false)

async function aoSelecionarImagem(arquivos) {
  const arquivo = arquivos?.[0]
  if (!arquivo) return

  enviandoImagem.value = true
  try {
    form.value.imagem_url = await enviarImagemEvento(arquivo)
  } catch (e) {
    erroSubmissao.value = 'Falha ao enviar imagem: ' + e.message
  } finally {
    enviandoImagem.value = false
  }
}
</script>

<template>
  <!-- dentro do v-form, antes do botão de submit -->
  <v-file-input
    label="Imagem do evento"
    accept="image/*"
    prepend-icon="mdi-camera"
    :loading="enviandoImagem"
    @update:model-value="aoSelecionarImagem"
  />
  <v-img v-if="form.imagem_url" :src="form.imagem_url" max-height="200" class="mb-4" cover />
</template>

💡 Dica A alternativa é usar multer no Express, recebendo o arquivo direto no back-end (multipart/form-data) e salvando em disco ou repassando para um storage. É uma escolha igualmente válida — inclusive mais simples de proteger, já que o upload passa pelos seus próprios middlewares de autenticação. A vantagem do Firebase Storage é tirar carga de rede do seu servidor: o arquivo vai direto do navegador para o Firebase, e sua API só recebe a URL final, pequena, no corpo do POST/PUT.

7. Depuração ponta a ponta

Quando o front manda uma requisição e algo dá errado, o fluxo de depuração é sempre o mesmo:

  1. Aba Network do DevTools. Filtre por Fetch/XHR, clique na requisição. Aba Headers mostra método, URL, status. Aba Payload (ou Request) mostra o corpo enviado. Aba Response mostra o corpo devolvido pelo servidor — é aqui que aparece a mensagem de erro do tratadorErros.
  2. Reproduza em curl. Copie a requisição do Network (botão direito → Copy → Copy as cURL) ou monte à mão:
Terminal
curl -i http://localhost:3000/api/eventos \
  -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "titulo": "Minicurso de Docker",
    "descricao": "Introdução prática a containers",
    "categoria": "minicurso",
    "data_hora": "2026-12-10T14:00:00",
    "local": "Laboratório 3",
    "vagas": 30
  }'

Isso isola o problema: se o curl reproduz o erro, o problema é no back-end (ou nos dados enviados). Se o curl funciona mas o front falha, o problema é no front (token não enviado, payload montado errado, CORS).

  1. Leia os logs do servidor. O terminal onde unieventos-api está rodando mostra qualquer console.error do tratadorErros e, se usar morgan ou similar, cada requisição recebida — confirme que ela chegou, com o método e caminho certos.

CORS: erros mais comuns e configuração correta

CORS (Cross-Origin Resource Sharing) é uma proteção do navegador, não do servidor — ele bloqueia a resposta de chegar ao JavaScript da página quando origem (protocolo + domínio + porta) da página é diferente da origem da API, a menos que o servidor autorize explicitamente via cabeçalhos.

Sintomas típicos no console do navegador:

  • has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present → o servidor não está usando cors(), ou está usando com origin que não bate com a URL do front.
  • Request header field authorization is not allowed by Access-Control-Allow-Headers → o servidor não liberou explicitamente o cabeçalho Authorization.
  • Requisição aparece como OPTIONS seguida de falha → é o preflight automático do navegador para métodos como PUT/DELETE ou cabeçalhos customizados; se o servidor não responde 200/204 a esse OPTIONS, o navegador cancela a requisição real.

Configuração correta para o UniEventos:

JavaScript
// unieventos-api/src/servidor.js (trecho)
import express from 'express'
import cors from 'cors'

const app = express()

app.use(
  cors({
    origin: process.env.FRONT_URL ?? 'http://localhost:5173',
    credentials: true,
    allowedHeaders: ['Content-Type', 'Authorization'],
  }),
)

app.use(express.json())

⚠️ Atenção origin: '*' (liberar qualquer origem) parece resolver tudo rápido, mas não funciona junto com credentials: true — o navegador rejeita essa combinação por especificação. Como o UniEventos usa Authorization (não cookies), credentials: true nem é estritamente necessário aqui, mas vale registrar: se um dia usar cookies de sessão, origin precisa ser um domínio explícito, nunca *.

🧪 Laboratório

1. Contrato documentado. Escreva a tabela de contrato (seção 1) para uma entidade do seu projeto autoral.

DicaCinco linhas — uma por endpoint — método, caminho, autenticação. Corpo e resposta podem ir em blocos JSON abaixo da tabela.

2. Back-end completo. Implemente controller → service → repository da sua entidade principal com validação zod, paginação e ao menos uma regra de negócio (ex.: não aceitar valor negativo, não excluir se houver dependência).

DicaReaproveite a estrutura de `eventosService.js` — troque só os campos do `z.object`.

3. Store pessimista. Implemente a store Pinia da entidade com lista, carregando, erro e as ações CRUD, todas aguardando confirmação do servidor antes de mudar o estado local.

DicaTodo `try` termina em `finally { carregando.value = false }` — não esqueça, senão a tela trava em loading para sempre em caso de erro.

4. Telas de listagem e formulário. Construa ListaView com v-data-table-server (busca + paginação) e FormView servindo criar e editar pela mesma rota parametrizada.

DicaConfira o formato do campo de data — é a causa mais comum de formulário de edição aparecer "vazio" mesmo com dado no banco.

5. Depuração guiada. Provoque de propósito um erro 400 (mande um campo inválido) e um erro de CORS (mude temporariamente o origin do cors() para uma URL errada). Documente, com print da aba Network, o que cada um parece no navegador.

DicaDepois do teste de CORS, não esqueça de voltar o `origin` correto — é fácil esquecer e passar a aula seguinte "quebrada".

🐛 Erros comuns e como resolver

Sintoma Causa Solução
Formulário de edição abre vazio Formato de data_hora incompatível com datetime-local Usar .slice(0, 16) no ISO recebido antes de atribuir ao v-model
Lista não atualiza após criar/editar Store otimista incompleta, ou índice errado ao substituir item Conferir findIndex comparando tipos (Number(id) × item.id)
Erro 400 sem detalhe visível na tela Front não está lendo e.response.data.erro Padronizar leitura do erro em todo catch da store
CORS bloqueia só PUT/DELETE, GET funciona Preflight OPTIONS não tratado — normalmente falta de cors() global antes das rotas Garantir app.use(cors(...)) antes de app.use('/api/eventos', ...)
Upload de imagem falha com "permission denied" Regra do Firebase Storage exige autenticação e o usuário não está logado Checar auth.currentUser antes de chamar uploadBytes
curl funciona mas o front não Token não está sendo enviado pelo interceptor, ou front aponta para porta errada Conferir VITE_API_URL e o cabeçalho Authorization na aba Network
Paginação trava na página 2+ v-data-table-server não está usando items-length vindo do back, ou store não repassa paginacao.total Confirmar que paginacao.total reflete o total real, não o tamanho da página atual

🏠 Atividade assíncrona (1 h)

No seu projeto autoral: implemente o CRUD completo (os 5 endpoints do contrato) de uma segunda entidade, diferente da que você já trabalhou no laboratório de hoje. Ela deve ter, no mínimo, uma regra de negócio própria (ex.: não permitir dois registros com o mesmo nome, não excluir se estiver em uso por outra entidade). Documente o contrato dela em uma tabela, igual à da seção 1, e inclua no README do repositório.

Critério de pronto: os 5 endpoints respondem corretamente (teste com curl), a store e as telas de listagem/formulário funcionam no front, e o contrato está documentado no README.

✅ Checkpoint do projeto autoral

  • [ ] Contrato de API documentado em tabela para pelo menos duas entidades.
  • [ ] Back-end com controller → service → repository, validação zod e ao menos uma regra de negócio por entidade.
  • [ ] Paginação e busca por query string funcionando no endpoint de listagem.
  • [ ] services/ do front alinhado ao contrato, sem chamadas diretas a api.get/post/... fora dessa camada.
  • [ ] Store Pinia com lista, itemAtual, carregando, erro, paginacao e atualização pessimista.
  • [ ] Telas de listagem (busca + paginação), formulário único (criar/editar) e diálogo de confirmação de exclusão.
  • [ ] Upload de imagem funcionando (Firebase Storage ou multer) em pelo menos uma entidade.
  • [ ] CORS configurado corretamente entre front e back, sem origin: '*' combinado com credentials: true.

📚 Para aprofundar

Na Aula 12 trocamos de fornecedor: o mesmo CRUD de eventos, agora falando com Supabase — Postgres gerenciado, autenticação própria e Row Level Security no lugar da validação manual de dono/admin que fizemos hoje na API Express.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 12 — CRUD com banco em nuvem (Supabase)

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

Na Aula 11 fechamos o CRUD de eventos ponta a ponta: Vue chamando services/, Express validando e persistindo no MySQL, Firebase autenticando. Hoje mudamos de fornecedor: o mesmo recurso evento, agora falando direto com o Supabase — sem API própria no meio. É a mesma pergunta de arquitetura de sempre ("onde mora a lógica?"), respondida de um jeito diferente.

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Comparar API própria (Express+MySQL), Firebase e Supabase, e justificar quando cada um é a escolha certa.
  • Criar um projeto Supabase, entender a diferença entre chave anon e service_role, e nunca expor a segunda no front.
  • Criar tabelas com SQL diretamente no SQL Editor, com uuid como chave primária e timestamptz para datas.
  • Explicar o que é Row Level Security, por que o Supabase exige, e reconhecer a armadilha do data: [] silencioso.
  • Escrever policies de leitura pública, inserção autenticada e edição/exclusão restrita ao dono.
  • Usar @supabase/supabase-js para select, insert, update, delete, joins e paginação, sempre tratando { data, error }.
  • Implementar login com Supabase Auth e conectar auth.uid() às policies.
  • Assinar mudanças em tempo real com Realtime e implementar o padrão Adapter trocando o back-end via variável de ambiente.

📋 Pré-requisitos desta aula

Checklist antes de começar:

  • [ ] unieventos-web funcionando com o CRUD da Aula 11 (Express+MySQL+Firebase).
  • [ ] Conta no supabase.com (login com GitHub é o mais rápido).
  • [ ] Node.js 22.22.2 e npm 10.9.7 instalados.
  • [ ] Confortável com SQL básico (SELECT, INSERT, CREATE TABLE) — revisado na Aula 09 no contexto do MySQL.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Comparação Express×Firebase×Supabase; criar projeto; chaves; criar tabelas por SQL; Row Level Security e policies
2 50 min @supabase/supabase-js: CRUD completo, joins, paginação, Supabase Auth
3 50 min Storage, Realtime, padrão Adapter, laboratório comparativo

1. Três formas de resolver o mesmo problema

O UniEventos já tem back-end funcionando: Express + MySQL, com autenticação Firebase por cima. Por que aprender mais uma abordagem?

Porque na vida profissional você vai escolher — e a escolha tem trade-offs reais, não é só gosto. Comparação honesta:

Critério API própria (Express+MySQL) Firebase Supabase
O que resolve Controle total sobre lógica e dados Auth + Firestore/Storage prontos, sem servidor próprio Postgres gerenciado + Auth + Storage, sem servidor próprio
Banco de dados Você escolhe e administra (MySQL aqui) Firestore (NoSQL, documentos) Postgres (SQL relacional, o mesmo paradigma do MySQL)
Onde mora a regra de negócio No seu back-end, você escreve tudo Cloud Functions (custo extra) ou no front (arriscado) SQL/policies no banco, ou funções Postgres, ou API própria por cima
Curva de aprendizado Alta (você monta tudo) Média (SDK, mas modelo de dados diferente) Baixa se já sabe SQL
Vendor lock-in Nenhum — seu código, seu servidor Alto — Firestore não é portável Médio — é Postgres puro por baixo, mais fácil de migrar
Quando escolher Regra de negócio complexa, controle fino, já tem back-end Protótipo rápido, app mobile-first, tempo real nativo Precisa de SQL relacional gerenciado, quer Postgres sem administrar servidor

💡 Dica Não existe "o melhor" fora de contexto. O UniEventos usa MySQL porque a disciplina precisa ensinar SQL relacional e arquitetura em camadas. Se o requisito fosse "app mobile com sincronização offline automática", Firebase seria mais natural. Se o requisito fosse "preciso de Postgres gerenciado sem administrar servidor, com auth pronta", Supabase entra bem. Custo de saída (trocar de fornecedor depois) também pesa: Postgres é um padrão aberto, então uma base Supabase se exporta e migra com muito menos atrito que uma base Firestore.

Sobre custo, e por que isso importa desde já

Os três caminhos têm modelos de cobrança bem diferentes, e vale entender isso antes de escolher, não depois que a fatura chegar:

  • API própria (Express+MySQL): você paga o servidor (VM, container, PaaS) e o banco, direto, independente de quantas requisições ou quanto tráfego passa. Previsível, mas você também é responsável por escalar, fazer backup e manter tudo no ar.
  • Firebase: camada gratuita generosa para protótipos, mas cobra por leituras/escritas no Firestore e por armazenamento e tráfego de saída (egress) — em produtos com alto volume de leitura (uma lista que recarrega toda hora, por exemplo), o custo pode crescer rápido e de forma menos previsível.
  • Supabase: também tem camada gratuita (com o projeto "pausando" após um tempo sem uso no plano free), e cobra por armazenamento de banco, egress e por hora de computação do banco nos planos pagos. Como é Postgres puro por baixo, migrar para um Postgres autogerenciado depois (se o custo justificar) é factível sem reescrever o modelo de dados.

"Custo de saída" (egress) é o valor cobrado por dados que saem do provedor em direção ao seu usuário — toda resposta de select, toda imagem baixada do Storage, conta. É um item fácil de esquecer ao estimar custo de um app com uso intenso de leitura, como uma lista de eventos que recarrega a cada navegação.

📌 Na prova Os três modelos resolvem "onde guardar e servir dados", mas com contratos de responsabilidade diferentes: API própria = você administra tudo, custo previsível, controle total. Firebase = NoSQL gerenciado, ótimo para tempo real e mobile, lock-in alto. Supabase = Postgres gerenciado, SQL relacional, lock-in menor por ser padrão aberto.

2. Criando o projeto no Supabase

  1. Em supabase.com, New project. Escolha organização, nome (unieventos), senha do banco (guarde — é a senha do Postgres, usada em conexões diretas) e região (mais próxima do Brasil, ex. São Paulo/sa-east-1 se disponível).
  2. Aguarde o provisionamento (1–2 minutos).
  3. No painel do projeto, vá em Project Settings → API. Anote: - Project URL — algo como https://xxxxxxxxxxxx.supabase.co. - anon / public key — chave longa, começando com eyJ... (é um JWT também). Pode ir no front. - service_role key — outra chave longa. Nunca vai para o front.

⚠️ Atenção A chave anon é pública por design — ela vai no bundle JavaScript do seu front, qualquer pessoa que abrir o DevTools consegue vê-la. Isso é esperado e seguro desde que o Row Level Security esteja configurado corretamente (seção 4): a chave anon só consegue fazer o que as policies permitirem. Já a service_role ignora RLS completamente — com ela, qualquer requisição lê e escreve qualquer linha de qualquer tabela, sem checagem nenhuma. Se ela vazar no front, é o mesmo que vazar acesso total ao banco. Use service_role só em ambiente de servidor (scripts administrativos, back-end próprio), nunca em código que roda no navegador.

SQL Editor e Table Editor

No menu lateral: SQL Editor (para rodar comandos SQL diretamente, o que faremos agora) e Table Editor (interface visual tipo planilha, útil para inspecionar dados rapidamente — mas hoje vamos criar tudo por SQL, para reforçar o que você já sabe da Aula 09).

3. Criando as tabelas

No SQL Editor, uma nova query:

SQL
-- Tabela de eventos. uuid como PK (padrão do Supabase/Postgres,
-- gerado automaticamente, sem depender de auto-incremento sequencial).
create table eventos (
  id uuid primary key default gen_random_uuid(),
  titulo text not null,
  descricao text not null,
  categoria text not null check (categoria in ('palestra', 'minicurso', 'workshop')),
  data_hora timestamptz not null,
  local text not null,
  vagas integer not null check (vagas > 0),
  imagem_url text,
  usuario_id uuid not null references auth.users(id),
  criado_em timestamptz not null default now()
);

-- Tabela de inscrições, referenciando eventos e o usuário autenticado.
create table inscricoes (
  id uuid primary key default gen_random_uuid(),
  evento_id uuid not null references eventos(id) on delete cascade,
  usuario_id uuid not null references auth.users(id),
  criado_em timestamptz not null default now(),
  unique (evento_id, usuario_id) -- um usuário não se inscreve duas vezes no mesmo evento
);

🔎 Por baixo do capô timestamptz (timestamp with time zone) guarda o instante em UTC internamente e converte na leitura/escrita conforme o fuso da sessão — é o tipo certo para datas que cruzam fusos horários, diferente de um timestamp sem fuso, que é ambíguo. auth.users é uma tabela que o próprio Supabase Auth já cria e mantém — é para lá que signUp/signInWithPassword gravam. references auth.users(id) garante, no nível do banco, que todo evento pertence a um usuário real.

Rode o SQL (botão Run ou Ctrl+Enter). Confirme no Table Editor que as duas tabelas apareceram.

4. Row Level Security: a armadilha nº1

Por padrão, o Supabase cria toda tabela sem RLS habilitado — o que na prática significa "qualquer um com a chave anon lê e escreve tudo", porque o Postgres do Supabase é acessado via API REST autogerada por cima do banco. Isso é perigoso, então o primeiro passo depois de criar uma tabela de verdade é:

SQL
alter table eventos enable row level security;
alter table inscricoes enable row level security;

Rode isso agora e tente buscar eventos do front (ou do próprio SQL Editor simulando a role anon) — o retorno vai ser uma lista vazia, sem nenhum erro:

JSON
{ "data": [], "error": null }

⚠️ Atenção — a armadilha nº1 do Supabase Uma tabela com RLS habilitado e sem nenhuma policy não gera erro de permissão — ela simplesmente se comporta como se estivesse vazia para quem não é dono/service_role. É a causa mais comum de "meu código está certo mas não retorna nada" com Supabase. Sempre que você habilitar RLS numa tabela nova, o próximo passo, sem exceção, é escrever as policies dela.

O que é RLS e por que existe

Row Level Security é um recurso nativo do Postgres: em vez de controlar acesso só por tabela (você pode ou não fazer SELECT em eventos), ele controla acesso linha por linha, com uma condição SQL avaliada para cada linha. O Supabase se apoia nisso porque expõe o banco diretamente via API para o front — sem RLS, qualquer chave anon vazada (e ela É pública) daria acesso irrestrito. RLS é o que torna seguro o front conversar direto com o banco.

Policies: leitura pública, inserção autenticada, edição/exclusão só do dono

SQL
-- LEITURA: qualquer pessoa (mesmo não autenticada) pode ver eventos.
create policy "eventos_leitura_publica"
on eventos for select
using (true);

-- INSERÇÃO: só usuários autenticados podem criar evento, e o evento
-- criado precisa pertencer a quem está criando (não dá para criar
-- em nome de outro usuário).
create policy "eventos_insercao_autenticada"
on eventos for insert
to authenticated
with check (auth.uid() = usuario_id);

-- EDIÇÃO: só o dono do evento pode editar.
create policy "eventos_edicao_dono"
on eventos for update
to authenticated
using (auth.uid() = usuario_id)
with check (auth.uid() = usuario_id);

-- EXCLUSÃO: só o dono pode excluir.
create policy "eventos_exclusao_dono"
on eventos for delete
to authenticated
using (auth.uid() = usuario_id);
SQL
-- Inscrições: leitura pública (para mostrar vagas ocupadas),
-- inserção só autenticado e só em nome de si mesmo,
-- exclusão só de si mesmo (cancelar a própria inscrição).
create policy "inscricoes_leitura_publica"
on inscricoes for select
using (true);

create policy "inscricoes_insercao_propria"
on inscricoes for insert
to authenticated
with check (auth.uid() = usuario_id);

create policy "inscricoes_exclusao_propria"
on inscricoes for delete
to authenticated
using (auth.uid() = usuario_id);

USING × WITH CHECK

As duas cláusulas parecem sinônimos, mas checam momentos diferentes:

  • USING filtra quais linhas existentes a operação pode enxergar/afetar. Vale para SELECT, UPDATE e DELETE — é a condição "essa linha, que já está no banco, pode ser vista/alterada/apagada por você?".
  • WITH CHECK valida os dados da linha depois da operação (ou os dados que vão ser inseridos). Vale para INSERT e UPDATE — é a condição "o resultado desta escrita é permitido?".

Em um UPDATE, as duas coexistem e respondem perguntas diferentes: USING decide se você pode tocar naquela linha específica (ex.: só se usuario_id já era seu); WITH CHECK decide se o novo valor que você está tentando gravar é aceitável (ex.: impedir que você mude usuario_id da linha para outra pessoa, "roubando" o evento).

📌 Na prova USING = filtro sobre a linha que já existe (quem pode ver/mexer). WITH CHECK = validação sobre o dado que está sendo escrito (o resultado é permitido?). INSERT só tem WITH CHECK (não existe linha "antes"). SELECT/DELETE só têm USING. UPDATE tem os dois.

🧩 Padrão de projeto em uso: Adapter

O padrão Adapter (estrutural) permite que duas interfaces incompatíveis trabalhem juntas, criando uma camada intermediária que traduz uma para a outra. É exatamente o que vamos construir na seção 8: duas implementações de eventosRepo — uma fala com a API Express (Aula 11), outra fala direto com o Supabase — mas as duas expõem a mesma interface (listar(), buscarPorId(), criar(), atualizar(), remover()). O resto do front (store, telas) não sabe, e não precisa saber, qual das duas está em uso. Trocar de fornecedor de dados vira uma linha de variável de ambiente, não uma reescrita de tela.

5. @supabase/supabase-js: cliente e operações básicas

Terminal
npm install @supabase/supabase-js@2.112.3
JavaScript
// src/services/supabase.js
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)
Terminal
# .env
VITE_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

select, filtros, ordenação e paginação

JavaScript
// consultas de exemplo — cole no console do navegador ou num componente de teste

// Selecionar colunas específicas
const { data, error } = await supabase
  .from('eventos')
  .select('id, titulo, categoria, data_hora, vagas')

// Filtros: eq, neq, gt, lt, like, ilike, in
const { data: palestras } = await supabase
  .from('eventos')
  .select('*')
  .eq('categoria', 'palestra')

const { data: buscaPorTitulo } = await supabase
  .from('eventos')
  .select('*')
  .ilike('titulo', '%semana%') // ilike = LIKE case-insensitive

const { data: futuros } = await supabase
  .from('eventos')
  .select('*')
  .gt('data_hora', new Date().toISOString())

const { data: algumasCategorias } = await supabase
  .from('eventos')
  .select('*')
  .in('categoria', ['palestra', 'workshop'])

// Ordenação
const { data: ordenados } = await supabase
  .from('eventos')
  .select('*')
  .order('data_hora', { ascending: true })

// Paginação: range(inicio, fim), ambos inclusive, base 0
const pagina = 1
const porPagina = 10
const inicio = (pagina - 1) * porPagina
const fim = inicio + porPagina - 1

const { data: pagina1, count } = await supabase
  .from('eventos')
  .select('*', { count: 'exact' }) // pede o total de linhas junto
  .order('data_hora', { ascending: true })
  .range(inicio, fim)

// Buscar um único registro (lança erro se vier mais de uma linha
// ou se nenhuma linha for encontrada)
const { data: evento, error: erroUnico } = await supabase
  .from('eventos')
  .select('*')
  .eq('id', 'algum-uuid-aqui')
  .single()

⚠️ Atenção single() estoura em erro se a consulta não retornar exatamente uma linha — nem zero, nem duas ou mais. Se o id pode não existir (ex.: usuário editou a URL na mão), trate o error em vez de assumir que data sempre vem preenchido. Para o caso "pode não existir, e tudo bem", use .maybeSingle() no lugar de .single() — ele devolve data: null sem erro quando não encontra.

{ data, error }: por que try/catch sozinho não basta

O supabase-js não lança exceção para a maioria dos erros de banco (violação de policy, coluna inexistente, check constraint falhando). Em vez disso, ele sempre resolve a Promise com sucesso e devolve um objeto { data, error } — se error não for null, a operação falhou, mas nenhuma exceção foi lançada e um try/catch ao redor não pega nada:

JavaScript
// ERRADO — o try/catch aqui nunca vê o erro de RLS/validação
try {
  const { data } = await supabase.from('eventos').insert({ titulo: 'X' })
  console.log('Criado:', data) // data pode ser null e o código nem percebe
} catch (e) {
  console.error('Nunca chega aqui para erros de policy/validação')
}

// CORRETO — sempre desestruture e cheque error explicitamente
const { data, error } = await supabase.from('eventos').insert({ titulo: 'X' })
if (error) {
  console.error('Falha ao criar evento:', error.message)
  // trate aqui: mostrar mensagem, não seguir o fluxo, etc.
} else {
  console.log('Criado:', data)
}

🔎 Por baixo do capô Isso é uma escolha de design da biblioteca: erros de banco de dados (RLS negou, constraint violada, coluna não existe) são tratados como resultado esperado da operação, não como falha excepcional do programa — parecido com como uma função de parsing pode devolver null em vez de lançar. try/catch continua útil para erros de rede (sem internet, timeout), mas a lógica de negócio do Supabase sempre passa pelo error do objeto retornado. Esqueça isso e você vai debugar "por que meu insert não fez nada" sem nunca ver a mensagem real.

insert, update, delete

JavaScript
// INSERT — .select() no final devolve a linha criada (senão, data vem null)
const { data: novoEvento, error: erroInsert } = await supabase
  .from('eventos')
  .insert({
    titulo: 'Minicurso de Docker',
    descricao: 'Introdução prática a containers',
    categoria: 'minicurso',
    data_hora: '2026-12-10T14:00:00-04:00',
    local: 'Laboratório 3',
    vagas: 30,
    usuario_id: (await supabase.auth.getUser()).data.user.id,
  })
  .select()
  .single()

// UPDATE — sempre com .eq() para não atualizar a tabela inteira
const { data: eventoAtualizado, error: erroUpdate } = await supabase
  .from('eventos')
  .update({ vagas: 40 })
  .eq('id', novoEvento.id)
  .select()
  .single()

// DELETE
const { error: erroDelete } = await supabase
  .from('eventos')
  .delete()
  .eq('id', novoEvento.id)

⚠️ Atenção Um update() ou delete() sem .eq(...) (ou outro filtro) tenta afetar a tabela inteira. O RLS te protege de estragos globais (a policy usuario_id = auth.uid() limita às suas próprias linhas), mas mesmo dentro das suas linhas isso é raramente o que você quer. Sempre filtre pelo identificador específico.

Joins por relacionamento

O Supabase entende as foreign keys que você declarou e permite buscar dados relacionados dentro do mesmo select, sem escrever JOIN manualmente:

JavaScript
// Buscar eventos já trazendo as inscrições relacionadas
const { data: eventosComInscritos, error } = await supabase
  .from('eventos')
  .select('*, inscricoes(*)')

// eventosComInscritos[0].inscricoes é um array com as inscrições daquele evento

// Contagem de relacionados sem trazer todas as linhas
const { data: eventosComContagem } = await supabase
  .from('eventos')
  .select('*, inscricoes(count)')

6. Supabase Auth

JavaScript
// src/services/supabaseAuthService.js
import { supabase } from './supabase'

export async function cadastrar(email, senha) {
  const { data, error } = await supabase.auth.signUp({ email, password: senha })
  if (error) throw new Error(error.message)
  return data.user
}

export async function entrar(email, senha) {
  const { data, error } = await supabase.auth.signInWithPassword({ email, password: senha })
  if (error) throw new Error(error.message)
  return data.user
}

export async function sair() {
  const { error } = await supabase.auth.signOut()
  if (error) throw new Error(error.message)
}

export async function obterSessaoAtual() {
  const { data } = await supabase.auth.getSession()
  return data.session
}

// Observa login/logout/renovação de token, igual ao onAuthStateChanged
// do Firebase que vimos na Aula 10.
export function observarAutenticacao(callback) {
  const { data: assinatura } = supabase.auth.onAuthStateChange((_evento, sessao) => {
    callback(sessao)
  })
  return assinatura.subscription.unsubscribe // função de cancelamento
}

A ligação entre Auth e RLS é direta: quando o front faz uma chamada autenticada, o supabase-js anexa automaticamente o token de sessão, e as policies usam auth.uid() para saber quem está pedindo. É o mesmo princípio do middleware autenticar da Aula 10 (ler o token, extrair a identidade) — só que aqui a checagem acontece dentro do banco, não numa camada de middleware que você escreve.

A store de autenticação segue exatamente a mesma forma da Aula 10 — Pinia, estado usuario/carregando/inicializado, Promise resolvida no primeiro evento do observador, guard de rota aguardando essa Promise. Só troca o serviço por baixo: observarAutenticacao do Supabase no lugar de onAuthStateChanged do Firebase.

JavaScript
// src/stores/authStoreSupabase.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { obterSessaoAtual, observarAutenticacao } from '@/services/supabaseAuthService'

export const useAuthStore = defineStore('auth', () => {
  const usuario = ref(null)
  const carregando = ref(false)
  const inicializado = ref(false)

  let promessaInicializacao = null

  function inicializar() {
    if (promessaInicializacao) return promessaInicializacao

    promessaInicializacao = new Promise((resolve) => {
      // Primeiro, lê a sessão já persistida (ex.: recarregou a página).
      obterSessaoAtual().then((sessao) => {
        usuario.value = sessao?.user ?? null
      })

      // Depois, mantém o estado sincronizado com login/logout/renovação.
      observarAutenticacao((sessao) => {
        usuario.value = sessao?.user ?? null
        if (!inicializado.value) {
          inicializado.value = true
          resolve()
        }
      })
    })

    return promessaInicializacao
  }

  const estaLogado = computed(() => usuario.value !== null)

  return { usuario, carregando, inicializado, inicializar, estaLogado }
})
JavaScript
// src/router/index.js — guard idêntico em espírito ao da Aula 10,
// trocando authStore de Firebase pela variante Supabase.
router.beforeEach(async (to) => {
  const authStore = useAuthStore()
  await authStore.inicializar()

  if (to.meta.requerAuth && !authStore.estaLogado) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  return true
})

💡 Dica Repare que a forma do problema — "aguardar a primeira resolução do observador antes de deixar o guard decidir" — é idêntica entre Firebase e Supabase, mesmo os dois SDKs sendo de fornecedores diferentes. É um sinal de que o problema (evitar redirecionamento indevido no F5) é estrutural do padrão "autenticação assíncrona no cliente", não uma peculiaridade de um SDK específico.

7. Storage e Realtime

Storage: bucket público, upload, URL pública

No painel: Storage → New bucket, nome eventos-imagens, marque Public bucket.

JavaScript
// src/services/supabaseStorageService.js
import { supabase } from './supabase'

export async function enviarImagemEvento(arquivo) {
  const nomeUnico = `${Date.now()}-${arquivo.name}`

  const { error } = await supabase.storage
    .from('eventos-imagens')
    .upload(nomeUnico, arquivo)

  if (error) throw new Error('Falha ao enviar imagem: ' + error.message)

  const { data } = supabase.storage
    .from('eventos-imagens')
    .getPublicUrl(nomeUnico)

  return data.publicUrl
}

Realtime: a lista se atualizando sozinha

JavaScript
// trecho de EventosListaView.vue (variante Supabase)
import { onMounted, onUnmounted } from 'vue'
import { supabase } from '@/services/supabase'

let canal = null

onMounted(() => {
  canal = supabase
    .channel('eventos-mudancas')
    .on(
      'postgres_changes',
      { event: '*', schema: 'public', table: 'eventos' },
      (payload) => {
        console.log('Mudança recebida:', payload.eventType, payload.new ?? payload.old)
        eventosStore.carregar() // recarrega a lista quando algo muda
      },
    )
    .subscribe()
})

onUnmounted(() => {
  if (canal) supabase.removeChannel(canal)
})

Abra o UniEventos em duas abas lado a lado. Crie um evento em uma; a lista da outra atualiza sozinha, sem F5. É o momento em que a turma costuma reagir — vale demonstrar ao vivo antes de explicar o código.

🔎 Por baixo do capô Realtime do Supabase se apoia na replicação lógica do Postgres (logical replication): o banco publica um fluxo de mudanças (postgres_changes), e o supabase-js mantém um WebSocket assinando esse fluxo filtrado pela tabela/evento que você configurou. Não é polling — é o próprio banco avisando o cliente quando algo muda.

💻 Mão na massa — CRUD direto com Supabase e, depois, o Adapter

Passo 1 — Testando a conexão no console do navegador

Antes de montar telas, confirme que o cliente conecta e que as policies estão certas. Com unieventos-web rodando (npm run dev), abra o DevTools no navegador, importe o cliente e rode uma consulta:

JavaScript
// cole no console do navegador, na página do seu app rodando com Vite
const { supabase } = await import('/src/services/supabase.js')

const { data, error } = await supabase.from('eventos').select('*')
console.log({ data, error })

Se data vier [] e error vier null, e você já cadastrou alguma linha pelo Table Editor, é a armadilha da seção 4: falta a policy de leitura. Se error trouxer uma mensagem sobre coluna ou relação inexistente, revise o SQL de criação da tabela.

Passo 2 — Tela de listagem consumindo o Supabase diretamente

Antes de introduzir o Adapter, vale montar a versão mais direta — a store chamando o supabase-js sem nenhuma camada de repositório no meio. É o ponto de partida mais simples, e o que a maioria dos tutoriais mostra.

JavaScript
// src/stores/eventosStoreSupabase.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { supabase } from '@/services/supabase'

export const useEventosStore = defineStore('eventos', () => {
  const lista = ref([])
  const itemAtual = ref(null)
  const carregando = ref(false)
  const erro = ref(null)
  const paginacao = ref({ pagina: 1, limite: 10, total: 0, totalPaginas: 0 })

  async function carregar({ pagina = 1, limite = 10 } = {}) {
    carregando.value = true
    erro.value = null

    const inicio = (pagina - 1) * limite
    const fim = inicio + limite - 1

    const { data, error, count } = await supabase
      .from('eventos')
      .select('*', { count: 'exact' })
      .order('data_hora', { ascending: true })
      .range(inicio, fim)

    if (error) {
      erro.value = error.message
    } else {
      lista.value = data
      paginacao.value = { pagina, limite, total: count, totalPaginas: Math.ceil(count / limite) }
    }

    carregando.value = false
  }

  async function carregarUm(id) {
    carregando.value = true
    erro.value = null

    const { data, error } = await supabase.from('eventos').select('*').eq('id', id).maybeSingle()

    if (error) {
      erro.value = error.message
    } else if (!data) {
      erro.value = 'Evento não encontrado.'
    } else {
      itemAtual.value = data
    }

    carregando.value = false
  }

  async function criar(evento) {
    carregando.value = true
    erro.value = null

    const { data: sessao } = await supabase.auth.getUser()
    const { data, error } = await supabase
      .from('eventos')
      .insert({ ...evento, usuario_id: sessao.user.id })
      .select()
      .single()

    carregando.value = false
    if (error) {
      erro.value = error.message
      throw new Error(error.message)
    }

    lista.value = [data, ...lista.value]
    return data
  }

  async function atualizar(id, evento) {
    carregando.value = true
    erro.value = null

    const { data, error } = await supabase.from('eventos').update(evento).eq('id', id).select().single()

    carregando.value = false
    if (error) {
      erro.value = error.message
      throw new Error(error.message)
    }

    const indice = lista.value.findIndex((e) => e.id === id)
    if (indice !== -1) lista.value[indice] = data
    return data
  }

  async function remover(id) {
    carregando.value = true
    erro.value = null

    const { error } = await supabase.from('eventos').delete().eq('id', id)

    carregando.value = false
    if (error) {
      erro.value = error.message
      throw new Error(error.message)
    }

    lista.value = lista.value.filter((e) => e.id !== id)
  }

  return { lista, itemAtual, carregando, erro, paginacao, carregar, carregarUm, criar, atualizar, remover }
})
Vue SFC
<!-- src/views/EventosListaSupabaseView.vue -->
<script setup>
import { onMounted } from 'vue'
import { useEventosStore } from '@/stores/eventosStoreSupabase'
import { useAuthStore } from '@/stores/authStoreSupabase'

const eventosStore = useEventosStore()
const authStore = useAuthStore()

onMounted(() => eventosStore.carregar())

function formatarData(isoString) {
  return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(isoString))
}
</script>

<template>
  <v-container>
    <h1 class="text-h4 mb-4">Eventos (Supabase)</h1>

    <v-progress-linear v-if="eventosStore.carregando" indeterminate color="primary" class="mb-2" />
    <v-alert v-if="eventosStore.erro" type="error" class="mb-4">{{ eventosStore.erro }}</v-alert>

    <v-row>
      <v-col v-for="evento in eventosStore.lista" :key="evento.id" cols="12" sm="6" md="4">
        <v-card>
          <v-img v-if="evento.imagem_url" :src="evento.imagem_url" height="140" cover />
          <v-card-title>{{ evento.titulo }}</v-card-title>
          <v-card-subtitle>{{ formatarData(evento.data_hora) }} · {{ evento.local }}</v-card-subtitle>
          <v-card-text>{{ evento.descricao }}</v-card-text>
          <v-card-actions v-if="authStore.usuario?.id === evento.usuario_id">
            <v-btn variant="text" :to="`/eventos-supabase/${evento.id}/editar`">Editar</v-btn>
            <v-btn variant="text" color="error" @click="eventosStore.remover(evento.id)">Excluir</v-btn>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>

    <p v-if="!eventosStore.carregando && eventosStore.lista.length === 0">Nenhum evento cadastrado ainda.</p>
  </v-container>
</template>

⚠️ Atenção authStore.usuario?.id === evento.usuario_id no template controla só a exibição do botão — é UX, igual ao guard de rota da Aula 10. A garantia de verdade é a policy eventos_edicao_dono (seção 4): mesmo que alguém forje uma requisição de update para um evento alheio direto contra a API do Supabase, o banco recusa porque auth.uid() não bate com usuario_id.

Do CRUD direto ao Adapter

Com o CRUD direto funcionando, damos o passo seguinte: extrair uma interface comum que permita alternar entre a API Express (Aula 11) e o Supabase sem tocar em store nem em tela.

Passo 3 — Interface comum e implementação para a API Express

JavaScript
// src/repositories/eventosRepoExpress.js
import api from '@/services/api'

export const eventosRepoExpress = {
  async listar({ pagina = 1, limite = 10 } = {}) {
    const resposta = await api.get('/eventos', { params: { pagina, limite } })
    return resposta.data // { dados, paginacao }
  },

  async buscarPorId(id) {
    const resposta = await api.get(`/eventos/${id}`)
    return resposta.data
  },

  async criar(evento) {
    const resposta = await api.post('/eventos', evento)
    return resposta.data
  },

  async atualizar(id, evento) {
    const resposta = await api.put(`/eventos/${id}`, evento)
    return resposta.data
  },

  async remover(id) {
    await api.delete(`/eventos/${id}`)
  },
}

Passo 4 — Mesma interface, implementação Supabase

JavaScript
// src/repositories/eventosRepoSupabase.js
import { supabase } from '@/services/supabase'

export const eventosRepoSupabase = {
  async listar({ pagina = 1, limite = 10 } = {}) {
    const inicio = (pagina - 1) * limite
    const fim = inicio + limite - 1

    const { data, error, count } = await supabase
      .from('eventos')
      .select('*', { count: 'exact' })
      .order('data_hora', { ascending: true })
      .range(inicio, fim)

    if (error) throw new Error(error.message)

    // Formato devolvido igual ao da API Express — é isso que faz o
    // Adapter funcionar: a FORMA da resposta precisa ser a mesma.
    return {
      dados: data,
      paginacao: { pagina, limite, total: count, totalPaginas: Math.ceil(count / limite) },
    }
  },

  async buscarPorId(id) {
    const { data, error } = await supabase.from('eventos').select('*').eq('id', id).single()
    if (error) throw new Error(error.message)
    return data
  },

  async criar(evento) {
    const { data: sessao } = await supabase.auth.getUser()
    const { data, error } = await supabase
      .from('eventos')
      .insert({ ...evento, usuario_id: sessao.user.id })
      .select()
      .single()
    if (error) throw new Error(error.message)
    return data
  },

  async atualizar(id, evento) {
    const { data, error } = await supabase
      .from('eventos')
      .update(evento)
      .eq('id', id)
      .select()
      .single()
    if (error) throw new Error(error.message)
    return data
  },

  async remover(id) {
    const { error } = await supabase.from('eventos').delete().eq('id', id)
    if (error) throw new Error(error.message)
  },
}

Passo 5 — Trocando a implementação por variável de ambiente

JavaScript
// src/repositories/eventosRepo.js
import { eventosRepoExpress } from './eventosRepoExpress'
import { eventosRepoSupabase } from './eventosRepoSupabase'

// VITE_BACKEND=express ou VITE_BACKEND=supabase no .env
const backendEscolhido = import.meta.env.VITE_BACKEND ?? 'express'

export const eventosRepo = backendEscolhido === 'supabase' ? eventosRepoSupabase : eventosRepoExpress
JavaScript
// src/services/eventosService.js — reescrito para usar o Adapter
import { eventosRepo } from '@/repositories/eventosRepo'

export function listarEventos(params) {
  return eventosRepo.listar(params)
}

export function buscarEvento(id) {
  return eventosRepo.buscarPorId(id)
}

export function criarEvento(evento) {
  return eventosRepo.criar(evento)
}

export function atualizarEvento(id, evento) {
  return eventosRepo.atualizar(id, evento)
}

export function removerEvento(id) {
  return eventosRepo.remover(id)
}
Terminal
# .env — uma linha decide qual back-end o front usa
VITE_BACKEND=supabase

Nenhuma linha da store (eventosStore.js) ou das telas (EventosListaView.vue, EventoFormView.vue) precisa mudar. Isso é o Adapter cumprindo sua função: a store continua chamando eventosService.listarEventos(...), que continua chamando eventosRepo.listar(...) — só a implementação por trás mudou, escolhida por uma variável de ambiente.

📌 Na prova Facade (Aula 11) simplifica uma interface complexa. Adapter (esta aula) traduz uma interface para outra, permitindo trocar a implementação sem o cliente perceber. A camada services/ do UniEventos usa os dois: é Facade em relação às telas (esconde detalhes de HTTP/Supabase) e se apoia num Adapter (eventosRepo) para trocar de fornecedor por baixo.

🧪 Laboratório

1. Projeto e tabelas. Crie seu projeto no Supabase e as tabelas da sua entidade principal (autoral), com uuid como PK, timestamptz para datas e RLS habilitado desde o início.

DicaHabilite RLS na mesma migração/script SQL em que cria a tabela — não deixe para depois, é fácil esquecer.

2. Policies completas. Escreva as quatro policies (leitura pública, inserção autenticada, edição e exclusão só do dono) para sua tabela principal.

DicaTeste cada uma isoladamente: logado como usuário A, tente editar uma linha do usuário B — deve falhar silenciosamente (nenhuma linha afetada), não com erro.

3. CRUD com supabase-js. Implemente select, insert, update, delete da sua entidade, sempre desestruturando { data, error } e tratando o erro.

DicaSe `data` vier vazio sem erro nenhum, sua primeira suspeita deve ser RLS sem policy — releia a seção 4 antes de procurar bug no seu código.

4. Realtime funcionando. Assine mudanças na sua tabela principal e demonstre, em duas abas, uma lista atualizando sozinha.

DicaConfirme que o Realtime está habilitado para a tabela em Database → Replication no painel do Supabase — em alguns planos/tabelas ele vem desligado por padrão.

5. Adapter comparativo. Implemente as duas versões do repositório (Repo...Express e Repo...Supabase) para sua entidade principal, com a mesma interface, e alterne entre elas por variável de ambiente.

DicaO ponto de verificação: você deve conseguir trocar `VITE_BACKEND` no `.env`, reiniciar o `npm run dev`, e a tela continuar funcionando sem tocar em nenhuma linha de `store` ou `view`.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
data: [] sem erro nenhum RLS habilitado, sem policy correspondente à operação Escrever a policy que falta; conferir se cobre select/insert/update/delete conforme necessário
Erro "JWT expired" ou 401 genérico Chave anon errada, ou copiada de outro projeto Reconferir VITE_SUPABASE_URL e VITE_SUPABASE_ANON_KEY em Project Settings → API
insert/update falha sem mensagem clara na tela Esqueceu de checar error do retorno (usou só try/catch) Sempre desestruturar { data, error } e tratar error explicitamente
Policy de update "não funciona" mesmo parecendo certa Faltou with check, então a policy só filtra a linha original mas aceita qualquer novo valor (ou vice-versa) Escrever using e with check juntos em policies de update
Erro de sintaxe SQL mencionando palavra reservada Coluna nomeada order, user, group etc. sem aspas Evitar nomes reservados; se inevitável, usar aspas duplas ("order") em todo lugar
.single() lança erro "multiple (or no) rows returned" Consulta não bateu em exatamente uma linha Usar .maybeSingle() se zero linhas é um caso válido; revisar o filtro se esperava uma única linha
Realtime não dispara nada Tabela sem replicação habilitada, ou canal não te inscreveu no evento certo Checar Database → Replication; conferir schema: 'public', table: 'nome_certo' no .on(...)

🏠 Atividade assíncrona (1 h)

Recrie o CRUD da sua entidade principal usando Supabase (se ainda não completou no laboratório) e escreva uma análise comparativa de 1 página entre a abordagem Express+MySQL (Aula 11) e a abordagem Supabase (hoje), cobrindo: quantidade de código escrito em cada uma, onde ficou a validação e a regra de negócio em cada caso, o que foi mais rápido de implementar, o que você confiaria menos sem testes automatizados, e qual você escolheria para o seu projeto autoral final — com justificativa. Este texto é conteúdo de estudo para o exame final.

Critério de pronto: CRUD Supabase funcionando (RLS + policies + operações básicas) e o texto comparativo entregue, com pelo menos os cinco pontos acima abordados.

✅ Checkpoint do projeto autoral

  • [ ] Projeto Supabase criado, com VITE_SUPABASE_URL e VITE_SUPABASE_ANON_KEY no .env (nunca a service_role).
  • [ ] Tabelas da entidade principal criadas por SQL, com uuid como PK e timestamptz onde há data/hora.
  • [ ] RLS habilitado em toda tabela nova, com as quatro policies (leitura pública, inserção autenticada, edição e exclusão do dono) escritas e testadas.
  • [ ] CRUD completo com supabase-js, sempre tratando { data, error }.
  • [ ] Login/logout via Supabase Auth conectado às policies por auth.uid().
  • [ ] Realtime funcionando em pelo menos uma tela.
  • [ ] eventosRepo (ou equivalente autoral) implementado nas duas versões (API própria e Supabase), com troca por variável de ambiente.
  • [ ] Análise comparativa de 1 página escrita e guardada no repositório.

📚 Para aprofundar

A Aula 13 muda de foco: em vez de mais um fornecedor, vamos refatorar e consolidar o back-end do UniEventos — revisando a arquitetura em camadas, aplicando injeção de dependência e organizando tudo o que construímos nas Aulas 07 a 12 num back-end coeso e defensável.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 13 — Desenvolvimento do back-end em camadas

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Organizar uma API Express em camadas (routes → controllers → services → repositories → db), aplicando a regra de dependência entre elas.
  • Aplicar injeção de dependência sem framework, passando repositórios para services por parâmetro/factory, e explicar por que isso torna o código testável.
  • Centralizar e validar a configuração da aplicação com zod em src/config/index.js, falhando rápido quando falta uma variável de ambiente.
  • Construir uma hierarquia de erros de domínio e um tratador de erros central que mapeia cada erro para o status HTTP correto, com logs estruturados via pino.
  • Aplicar proteções básicas de segurança (helmet, express-rate-limit, CORS restritivo, limite de payload) e relacioná-las ao OWASP Top 10.
  • Escrever testes automatizados de integração (rota, com supertest) e unitários (service, com repositório falso) usando vitest.
  • Criar e executar migrations de banco de dados com scripts numerados e uma tabela de controle, sem depender de schema.sql manual.

📋 Pré-requisitos desta aula

  • API unieventos-api funcionando com Express 5, persistência em MySQL (Aula 09) e autenticação Firebase (Aula 10), com CRUD completo (Aula 11).
  • Estrutura mínima de pastas src/routes, src/controllers já existente (mesmo que ainda sem separação completa em services/repositories).
  • Node.js 22 LTS e MySQL rodando localmente (ou acessível via DATABASE_URL).

Checklist antes de começar:

  • [ ] npm run dev sobe a API sem erro.
  • [ ] Existe pelo menos um endpoint de eventos funcionando (GET /api/eventos).
  • [ ] Você sabe onde estão as credenciais do banco no seu .env.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Arquitetura em camadas, injeção de dependência, configuração centralizada com zod
2 50 min Hierarquia de erros, segurança prática (helmet, rate limit, CORS), OWASP Top 10
3 50 min Testes com vitest + supertest, migrations com scripts numerados

Retomando a Aula 12

Na Aula 12 trocamos o MySQL por Supabase sem alterar uma linha do front-end, porque a camada services/ já escondia a origem dos dados atrás de uma interface única — o padrão Adapter em ação. Isso só foi possível porque o back-end já tinha, mesmo que informalmente, uma separação entre "o que a rota expõe" e "de onde o dado vem". Hoje formalizamos essa separação: paramos de escrever back-end que "funciona" e passamos a escrever back-end que se sustenta — testável, seguro, com erros previsíveis e configuração validada.

1. Arquitetura em camadas revisitada

Até aqui, o unieventos-api cresceu organicamente: rota chama controller, controller consulta o banco direto, às vezes até valida direto na rota. Isso funciona para um protótipo, mas não escala — cada mudança no banco obriga a mexer em código que deveria só cuidar de HTTP, e não dá para testar regra de negócio sem subir um banco de verdade.

A solução é formalizar cinco responsabilidades separadas:

Texto
routes/        → só sabe de HTTP: métodos, caminhos, parâmetros, chama o controller
controllers/    → traduz requisição/resposta HTTP para chamadas de service
services/       → regra de negócio pura, não sabe de Express nem de SQL
repositories/   → sabe conversar com a fonte de dados (MySQL, Supabase, memória...)
db/            → conexão de baixo nível (pool do mysql2, cliente do Supabase)

Mais três pastas de apoio, que qualquer camada pode usar:

Texto
middlewares/   → funções que interceptam a requisição (auth, validação, log, segurança)
validators/    → esquemas zod que descrevem o formato esperado de cada entrada
utils/         → funções puras reaproveitáveis (logger, formatação, helpers)
config/        → leitura e validação centralizada de variáveis de ambiente

1.1 O fluxo de uma requisição, camada por camada

Texto
Cliente HTTP (front-end / Postman)
        │
        │ POST /api/eventos  { titulo, categoria, vagas, ... }
        ▼
┌─────────────────────┐
│ middlewares globais  │  helmet, cors, express.json, rate-limit, log
└─────────┬────────────┘
          ▼
┌─────────────────────┐
│ routes/eventos.js    │  define o path e delega ao controller
└─────────┬────────────┘
          ▼
┌─────────────────────┐
│ middlewares de rota  │  autenticação (verificarToken), validação (validar(schema))
└─────────┬────────────┘
          ▼
┌──────────────────────────┐
│ controllers/eventosController │  lê req.body/req.params, chama o service,
│                                │  monta a resposta HTTP (status + JSON)
└─────────┬──────────────────────┘
          ▼
┌──────────────────────────┐
│ services/eventosService   │  regra de negócio: "vagas não pode ser negativo",
│                            │  "só o dono pode editar", orquestra repositórios
└─────────┬──────────────────────┘
          ▼
┌──────────────────────────┐
│ repositories/eventosRepository │  monta e executa a query, mapeia linha → objeto
└─────────┬──────────────────────┘
          ▼
┌──────────────────────────┐
│ db/pool.js                │  conexão física com o MySQL (ou outro SGBD)
└────────────────────────────┘

A resposta sobe pelo mesmo caminho, em ordem inversa: o repository devolve dados brutos, o service aplica regra de negócio e devolve um objeto de domínio, o controller decide o status HTTP e serializa em JSON, o Express entrega ao cliente.

1.2 A regra de dependência

⚠️ Atenção Camada de fora pode conhecer e importar a de dentro. Camada de dentro nunca pode importar a de fora.

Na prática:

  • routes pode importar controllers. controllers não pode importar routes.
  • controllers pode importar services. services não pode importar controllers nem req/res do Express.
  • services pode importar repositories. repositories não pode importar services.
  • repositories pode importar db. db não sabe que repositories existe.

O motivo: quanto mais "para dentro", mais a camada deveria ser reutilizável e testável sem HTTP nem banco real. Um service que importa req/res do Express está, na prática, acoplado ao protocolo HTTP — e não dá mais para chamá-lo a partir de um job agendado, de um teste unitário puro, ou de uma futura API GraphQL sem reescrever regra de negócio.

🔎 Por baixo do capô Essa regra é uma versão simplificada da Dependency Inversion Principle (o "D" do SOLID) e da Arquitetura Limpa (Clean Architecture, Robert C. Martin): as regras de negócio no centro, os detalhes de infraestrutura (HTTP, banco, filesystem) na borda, sempre apontando para dentro.

2. Injeção de dependência sem framework

O problema mais comum em back-ends que "crescem sem arquitetura" é o service importando o repository diretamente no topo do arquivo. Funciona, mas prende o service a uma implementação específica — impossível testar sem banco de verdade, impossível trocar de fonte de dados sem editar o service.

Antes — import direto, acoplado:

JavaScript
// src/services/eventosService.ANTES.js
// PROBLEMA: este arquivo só funciona se existir um MySQL de verdade rodando.
// Não dá para testar a regra "vagas não pode ser negativo" sem banco.
import { pool } from '../db/pool.js'

export async function listarEventos() {
  const [linhas] = await pool.query('SELECT * FROM eventos ORDER BY data_hora')
  return linhas
}

export async function criarEvento(dados) {
  if (dados.vagas < 0) {
    throw new Error('vagas não pode ser negativo')
  }
  const [resultado] = await pool.query(
    'INSERT INTO eventos (titulo, categoria, vagas) VALUES (?, ?, ?)',
    [dados.titulo, dados.categoria, dados.vagas],
  )
  return { id: resultado.insertId, ...dados }
}

Depois — o repositório é injetado (passado por parâmetro):

JavaScript
// src/services/eventosService.js
// O service NÃO SABE se o repositório fala com MySQL, Supabase ou memória.
// Ele só conhece a INTERFACE: listar(), buscarPorId(), criar(), atualizar(), remover().
import { ErroDeValidacao, ErroNaoEncontrado } from '../errors/index.js'

export function criarServicoDeEventos({ eventosRepository }) {
  return {
    async listarEventos(filtros) {
      return eventosRepository.listar(filtros)
    },

    async buscarEventoPorId(id) {
      const evento = await eventosRepository.buscarPorId(id)
      if (!evento) {
        throw new ErroNaoEncontrado(`Evento ${id} não encontrado`)
      }
      return evento
    },

    async criarEvento(dados) {
      if (dados.vagas < 0) {
        throw new ErroDeValidacao('vagas não pode ser negativo')
      }
      return eventosRepository.criar(dados)
    },

    async atualizarEvento(id, dados) {
      await this.buscarEventoPorId(id) // reaproveita a validação de existência
      return eventosRepository.atualizar(id, dados)
    },

    async removerEvento(id) {
      await this.buscarEventoPorId(id)
      return eventosRepository.remover(id)
    },
  }
}

A função criarServicoDeEventos é uma factory: recebe as dependências como argumento (aqui, um objeto com eventosRepository) e devolve o objeto pronto para uso. Quem monta a aplicação decide qual repositório injetar — em produção, o do MySQL; em teste, um repositório falso em memória, sem precisar de banco nenhum.

JavaScript
// src/app.js (montagem — quem decide as dependências concretas)
import { criarRepositorioDeEventosMySQL } from './repositories/eventosRepository.mysql.js'
import { criarServicoDeEventos } from './services/eventosService.js'

const eventosRepository = criarRepositorioDeEventosMySQL()
const eventosService = criarServicoDeEventos({ eventosRepository })
// eventosService agora pode ser passado ao controller, sem que o service
// jamais tenha importado o pool do MySQL diretamente.

💡 Dica Injeção de dependência não exige framework nenhum em JavaScript — não precisamos de @Injectable() nem de container de DI. Uma função que recebe parâmetros já é injeção de dependência. O nome bonito não deve intimidar: é passar objetos como argumento, em vez de importar dentro do arquivo.

3. Configuração centralizada com zod

Espalhar process.env.ALGUMA_COISA pelo código inteiro é frágil: se a variável não existir, o erro só aparece no meio de uma requisição, em produção, na pior hora. A solução é validar todo o ambiente uma única vez, na inicialização, e falhar rápido se algo estiver faltando.

JavaScript
// src/config/index.js
import { z } from 'zod'
import 'dotenv/config'

const esquemaDeAmbiente = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),

  DB_HOST: z.string().min(1, 'DB_HOST é obrigatória'),
  DB_PORT: z.coerce.number().int().positive().default(3306),
  DB_USER: z.string().min(1, 'DB_USER é obrigatória'),
  DB_PASSWORD: z.string().min(1, 'DB_PASSWORD é obrigatória'),
  DB_NAME: z.string().min(1, 'DB_NAME é obrigatória'),

  FIREBASE_PROJECT_ID: z.string().min(1, 'FIREBASE_PROJECT_ID é obrigatória'),

  CORS_ORIGEM_PERMITIDA: z.string().min(1, 'CORS_ORIGEM_PERMITIDA é obrigatória'),
})

// safeParse NÃO lança exceção — devolve um objeto { success, data } ou { success, error }.
// Isso permite montar uma mensagem de erro clara antes de encerrar o processo.
const resultado = esquemaDeAmbiente.safeParse(process.env)

if (!resultado.success) {
  console.error('❌ Configuração de ambiente inválida:')
  for (const problema of resultado.error.issues) {
    console.error(`  - ${problema.path.join('.')}: ${problema.message}`)
  }
  // Falha rápido: melhor a aplicação nem subir do que subir quebrada.
  process.exit(1)
}

export const config = Object.freeze(resultado.data)
Terminal
# .env.example — copie para .env e preencha com valores reais
NODE_ENV=development
PORT=3000

DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=troque-esta-senha
DB_NAME=uni_eventos

FIREBASE_PROJECT_ID=uni-eventos-12345

CORS_ORIGEM_PERMITIDA=http://localhost:5173

A partir de agora, nenhum outro arquivoprocess.env diretamente — todos importam config de src/config/index.js:

JavaScript
// src/db/pool.js — uso de config em vez de process.env espalhado
import { config } from '../config/index.js'

export const configuracaoDoPool = {
  host: config.DB_HOST,
  port: config.DB_PORT,
  user: config.DB_USER,
  password: config.DB_PASSWORD,
  database: config.DB_NAME,
}

⚠️ Atenção Se você esquecer DB_PASSWORD no .env, o processo não sobe — imprime exatamente qual variável falta e sai com process.exit(1). Isso é intencional: é infinitamente melhor descobrir isso no npm run dev do que às 23h58 tentando fazer o deploy funcionar para a Avaliação 3.

4. Tratamento de erros maduro

4.1 Hierarquia de erros de domínio

JavaScript
// src/errors/index.js
// Erros de DOMÍNIO carregam significado de negócio, não de protocolo HTTP.
// Quem decide o status HTTP é o tratador central (Seção 4.2), não o service.
export class ErroDeAplicacao extends Error {
  constructor(mensagem, status = 500) {
    super(mensagem)
    this.name = this.constructor.name
    this.status = status
    // Marca erros esperados/tratáveis, para diferenciar de bugs inesperados no log.
    this.operacional = true
  }
}

export class ErroDeValidacao extends ErroDeAplicacao {
  constructor(mensagem, detalhes = []) {
    super(mensagem, 400)
    this.detalhes = detalhes
  }
}

export class ErroNaoEncontrado extends ErroDeAplicacao {
  constructor(mensagem = 'Recurso não encontrado') {
    super(mensagem, 404)
  }
}

export class ErroDeAutorizacao extends ErroDeAplicacao {
  constructor(mensagem = 'Você não tem permissão para executar esta ação') {
    super(mensagem, 403)
  }
}

export class ErroDeConflito extends ErroDeAplicacao {
  constructor(mensagem = 'Conflito com o estado atual do recurso') {
    super(mensagem, 409)
  }
}

Usar essa hierarquia no service fica direto:

JavaScript
// trecho de src/services/inscricoesService.js
import { ErroDeConflito, ErroDeAutorizacao } from '../errors/index.js'

async function inscrever({ eventoId, usuarioUid }) {
  const jaInscrito = await inscricoesRepository.existeInscricao(eventoId, usuarioUid)
  if (jaInscrito) {
    throw new ErroDeConflito('Você já está inscrito neste evento')
  }
  // ...
}

async function cancelarInscricao({ inscricaoId, usuarioUidSolicitante }) {
  const inscricao = await inscricoesRepository.buscarPorId(inscricaoId)
  if (inscricao.usuario_uid !== usuarioUidSolicitante) {
    throw new ErroDeAutorizacao('Só é possível cancelar a própria inscrição')
  }
  // ...
}

4.2 Logs estruturados com pino

Terminal
npm install pino pino-http
JavaScript
// src/utils/logger.js
import pino from 'pino'
import { config } from '../config/index.js'

// Em desenvolvimento, log legível por humano (pino-pretty precisa ser instalado à parte
// como devDependency: npm install -D pino-pretty).
// Em produção, log em JSON puro — mais rápido e pronto para ferramentas de observabilidade.
export const logger = pino({
  level: config.NODE_ENV === 'production' ? 'info' : 'debug',
  transport:
    config.NODE_ENV === 'production'
      ? undefined
      : { target: 'pino-pretty', options: { colorize: true } },
})

4.3 O tratador de erros central

JavaScript
// src/middlewares/tratadorDeErros.js
import { logger } from '../utils/logger.js'
import { config } from '../config/index.js'

// Middleware de erro do Express: identificado pela ASSINATURA DE 4 PARÂMETROS.
// Em Express 5, erros lançados dentro de handlers async chegam aqui automaticamente,
// sem precisar de try/catch manual nem de .catch(next) em cada rota.
export function tratadorDeErros(erro, req, res, next) {
  const status = erro.status ?? 500
  const ehErroOperacional = erro.operacional === true

  // Erros operacionais (esperados: validação, não encontrado...) viram log de aviso.
  // Erros não-operacionais (bug inesperado) viram log de erro, com stack completo.
  if (ehErroOperacional) {
    logger.warn({ status, mensagem: erro.message, path: req.path }, 'erro operacional')
  } else {
    logger.error({ status, err: erro, path: req.path }, 'erro inesperado')
  }

  const corpoDaResposta = {
    mensagem: ehErroOperacional ? erro.message : 'Erro interno do servidor',
  }

  if (erro.detalhes) {
    corpoDaResposta.detalhes = erro.detalhes
  }

  // NUNCA vazar stack trace em produção — é informação valiosa para um atacante
  // (caminhos de arquivo, versão de bibliotecas, estrutura interna).
  if (config.NODE_ENV !== 'production') {
    corpoDaResposta.stack = erro.stack
  }

  res.status(status).json(corpoDaResposta)
}
JavaScript
// src/server.js — captura de falhas que escapam do Express
import { app } from './app.js'
import { config } from './config/index.js'
import { logger } from './utils/logger.js'

const servidor = app.listen(config.PORT, () => {
  logger.info(`API rodando na porta ${config.PORT} (${config.NODE_ENV})`)
})

// Promises rejeitadas sem .catch em NENHUM lugar do código (fora do ciclo de
// requisição do Express) caem aqui. Sem isso, o processo Node continua rodando
// em estado inconsistente, silenciosamente.
process.on('unhandledRejection', (motivo) => {
  logger.error({ err: motivo }, 'unhandledRejection não tratada — encerrando processo')
  servidor.close(() => process.exit(1))
})

process.on('uncaughtException', (erro) => {
  logger.error({ err: erro }, 'uncaughtException — encerrando processo')
  process.exit(1)
})

📌 Na prova unhandledRejection captura Promises rejeitadas que ninguém tratou; uncaughtException captura exceções síncronas que escaparam de qualquer try/catch. Nenhum dos dois substitui tratamento de erro local — são uma rede de segurança final, não a primeira linha de defesa.

5. Segurança prática

Regra de ouro: nunca confie em nada que vem do cliente — nem no Content-Type declarado, nem no tamanho do payload, nem nos campos do corpo, nem no token de autenticação sem verificá-lo. Tudo que chega de fora é hostil até prova em contrário.

Terminal
npm install helmet express-rate-limit cors
JavaScript
// src/middlewares/seguranca.js
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import cors from 'cors'
import { config } from '../config/index.js'

// helmet(): define um conjunto de cabeçalhos HTTP de segurança com um só import
// (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security etc.).
export const cabecalhosDeSeguranca = helmet()

// Limita quantas requisições um mesmo IP pode fazer em uma janela de tempo —
// mitiga força bruta em login e ataques de negação de serviço simples.
export const limitadorDeTaxa = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  limit: 100,                // 100 requisições por IP nessa janela
  standardHeaders: true,
  legacyHeaders: false,
  message: { mensagem: 'Muitas requisições. Tente novamente mais tarde.' },
})

// CORS restritivo: só o domínio do front tem permissão — nunca use origin: '*'
// em uma API que aceita cookies ou token de autenticação.
export const corsConfigurado = cors({
  origin: config.CORS_ORIGEM_PERMITIDA,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
})
JavaScript
// trecho de src/app.js — ordem importa: segurança primeiro, depois parsing, depois rotas
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'

export function criarApp({ eventosRepository } = {}) {
  const app = express()

  app.use(cabecalhosDeSeguranca)
  app.use(corsConfigurado)
  app.use(limitadorDeTaxa)

  // Limite de tamanho do corpo: evita que alguém envie um payload de 500 MB
  // para derrubar o processo por consumo de memória.
  app.use(express.json({ limit: '10kb' }))
  app.use(express.urlencoded({ extended: true, limit: '10kb' }))

  // ... rotas registradas depois daqui (Seção "Mão na massa")

  return app
}

⚠️ Atenção express.json({ limit: '10kb' }) rejeita automaticamente corpos maiores com 413 Payload Too Large. Ajuste o limite ao seu domínio — 10kb é generoso para um formulário de evento, mas seria pouco se você aceitasse upload de imagem em base64 no corpo (nesse caso, prefira Storage, como no Supabase da Aula 12).

5.1 Checklist OWASP Top 10 aplicado a esta disciplina

Categoria OWASP O que fazemos no UniEventos
A01 — Quebra de controle de acesso Middleware autenticacao.js (Aula 10) + verificação de dono do recurso nos services (ex.: ErroDeAutorizacao ao cancelar inscrição alheia)
A02 — Falhas criptográficas Senha nunca é gerenciada por nós — delegada ao Firebase Auth; .env fora do controle de versão; HTTPS obrigatório em produção (Aula 15)
A03 — Injeção Queries sempre parametrizadas com ? no mysql2 (nunca concatenação de string); validação de entrada com zod antes de tocar no banco
A04 — Design inseguro Arquitetura em camadas desta aula; regra de negócio centralizada no service, não espalhada em cada rota
A05 — Configuração incorreta helmet, CORS restritivo, .env validado por zod, stack trace escondida em produção
A07 — Falhas de identificação Token do Firebase verificado no back a cada requisição (Aula 10), nunca confiar em usuario_uid vindo do corpo da requisição
A09 — Falhas de log e monitoramento Logs estruturados com pino, diferenciando erro operacional de erro inesperado

🔎 Por baixo do capô Note que "sanitizar entrada" aqui não significa escapar HTML manualmente — significa validar contra um schema (zod) antes de qualquer processamento, e nunca montar SQL por concatenação. Essas duas práticas já eliminam a maior parte da superfície de ataque de injeção em uma API JSON.

🧩 Padrão de projeto em uso

🧩 Padrões de projeto em uso — Builder, Dependency Injection, Singleton, Facade, Repository, Strategy

Esta aula é a mais densa em padrões GoF do semestre, porque a arquitetura em camadas é literalmente a aplicação simultânea de vários deles.

Dependency Injection — Seção 3: criarServicoDeEventos({ eventosRepository }) recebe a dependência em vez de importá-la. O service não conhece a implementação concreta, só a interface (listar, buscarPorId, criar...).

Singleton — o pool de conexões do MySQL (criado na Aula 09 com mysql2.createPool) é instanciado uma única vez por processo e reutilizado por todos os repositórios: ```js // src/db/pool.js import mysql from 'mysql2/promise' import { configuracaoDoPool } from './configuracaoDoPool.js'

let instanciaDoPool // módulo ES: só existe uma vez por processo Node — Singleton natural

export function obterPool() { if (!instanciaDoPool) { instanciaDoPool = mysql.createPool(configuracaoDoPool) } return instanciaDoPool } `` Qualquer repositório que chameobterPool()` recebe a mesma instância — é assim que o Singleton evita esgotar conexões do banco.

Facadeservices/eventosService.js é uma fachada simples sobre o repositório: o controller não precisa saber que, por trás de criarEvento, existem validação de negócio e uma chamada ao banco. Ele só vê uma operação de alto nível.

Repositoryrepositories/eventosRepository.mysql.js encapsula toda a SQL; o resto da aplicação nunca escreve SELECT/INSERT fora dessa camada.

Strategy — a escolha de qual repositório usar em tempo de execução (ver src/repositories/index.js na seção "Mão na massa") é o padrão Strategy: a mesma interface (listar, criar...), implementações intercambiáveis por ambiente (MySQL em produção, memória em teste).

Builder — a montagem de uma query de listagem com filtros opcionais (categoria, texto, paginação) usa um builder que acumula condições passo a passo antes de gerar o SQL final — ver QueryBuilder na Seção 7.2 abaixo.

6. Testes automatizados

6.1 A pirâmide de testes

Texto
        ▲
       ╱ ╲        poucos, lentos, caros de manter
      ╱ E2E╲       (Cypress/Playwright rodando a UI inteira)
     ╱───────╲
    ╱   API/   ╲   médios: sobem a aplicação, testam rotas HTTP reais
   ╱ integração ╲  (supertest — Seção 6.3)
  ╱───────────────╲
 ╱   unitários      ╲  muitos, rápidos, baratos — testam uma função/service
╱  (service, utils)  ╲ isolado, sem rede nem banco (Seção 6.4)
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔

Testar o service unitariamente é mais barato que testar pela UI por três motivos: (1) roda em milissegundos, sem subir navegador nem servidor; (2) não depende de rede nem de banco de dados real, então não quebra por instabilidade externa; (3) aponta exatamente qual regra de negócio falhou, sem precisar navegar até a tela que dispara aquele fluxo.

6.2 Instalando e configurando

Terminal
npm install -D vitest supertest
JSON
// package.json — trecho de "scripts"
{
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
JavaScript
// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
  },
})

6.3 Teste de integração (rota, com supertest)

JavaScript
// test/eventos.rota.test.js
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import { criarApp } from '../src/app.js'
import { criarRepositorioDeEventosEmMemoria } from '../src/repositories/eventosRepository.memoria.js'

// Sobe a aplicação Express de verdade, mas com o repositório de MEMÓRIA —
// nenhum MySQL precisa estar rodando para este teste passar.
function montarAppDeTeste() {
  const eventosRepository = criarRepositorioDeEventosEmMemoria([
    { id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40 },
  ])
  return criarApp({ eventosRepository })
}

describe('rotas de /api/eventos', () => {
  it('GET /api/eventos retorna 200 e um array', async () => {
    const app = montarAppDeTeste()
    const resposta = await request(app).get('/api/eventos')

    expect(resposta.status).toBe(200)
    expect(Array.isArray(resposta.body)).toBe(true)
    expect(resposta.body).toHaveLength(1)
  })

  it('GET /api/eventos/:id inexistente retorna 404 com mensagem', async () => {
    const app = montarAppDeTeste()
    const resposta = await request(app).get('/api/eventos/999')

    expect(resposta.status).toBe(404)
    expect(resposta.body.mensagem).toMatch(/não encontrado/i)
  })

  it('POST /api/eventos sem título retorna 400 (validação zod)', async () => {
    const app = montarAppDeTeste()
    const resposta = await request(app)
      .post('/api/eventos')
      .send({ categoria: 'palestra', vagas: 10 })

    expect(resposta.status).toBe(400)
    expect(resposta.body.detalhes).toBeDefined()
  })

  it('POST /api/eventos válido retorna 201 e o evento criado', async () => {
    const app = montarAppDeTeste()
    const resposta = await request(app)
      .post('/api/eventos')
      .send({ titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60 })

    expect(resposta.status).toBe(201)
    expect(resposta.body.titulo).toBe('Hackathon FACET')
    expect(resposta.body.id).toBeDefined()
  })
})

6.4 Teste unitário (service, com repositório falso)

JavaScript
// test/eventos.service.test.js
import { describe, it, expect } from 'vitest'
import { criarServicoDeEventos } from '../src/services/eventosService.js'

// Repositório FALSO (test double): implementa a mesma interface do repositório
// real, mas guarda tudo em um array na memória do próprio teste — zero I/O.
function criarRepositorioFalso(eventosIniciais = []) {
  const eventos = [...eventosIniciais]
  return {
    async listar() {
      return eventos
    },
    async buscarPorId(id) {
      return eventos.find((evento) => evento.id === id) ?? null
    },
    async criar(dados) {
      const novoEvento = { id: eventos.length + 1, ...dados }
      eventos.push(novoEvento)
      return novoEvento
    },
  }
}

describe('eventosService (unitário)', () => {
  it('listarEventos delega ao repositório e devolve o array', async () => {
    const service = criarServicoDeEventos({
      eventosRepository: criarRepositorioFalso([{ id: 1, titulo: 'Evento A' }]),
    })

    const eventos = await service.listarEventos()

    expect(eventos).toHaveLength(1)
    expect(eventos[0].titulo).toBe('Evento A')
  })

  it('buscarEventoPorId lança ErroNaoEncontrado quando o id não existe', async () => {
    const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })

    await expect(service.buscarEventoPorId(42)).rejects.toThrow('não encontrado')
  })

  it('criarEvento lança ErroDeValidacao quando vagas é negativo', async () => {
    const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })

    await expect(
      service.criarEvento({ titulo: 'Evento inválido', categoria: 'palestra', vagas: -5 }),
    ).rejects.toThrow('vagas não pode ser negativo')
  })
})

Rodando os testes:

Terminal
npm test
Texto
 RUN  v2.1.9 unieventos-api

 ✓ test/eventos.service.test.js (3 tests) 4ms
 ✓ test/eventos.rota.test.js (4 tests) 29ms

 Test Files  2 passed (2)
      Tests  7 passed (7)
   Start at  20:14:02
   Duration  612ms

💡 Dica Sete testes cobrindo as regras mais importantes (listar, 404, validação, criação) já dão confiança real para refatorar sem medo. A meta não é "100% de cobertura" — é cobrir os caminhos de negócio que importam.

7. Migrations de banco

7.1 Por que schema.sql manual não escala

Até a Aula 09, o banco foi criado rodando um schema.sql inteiro na mão. Isso funciona sozinho, mas quebra em equipe: cada pessoa pode ter uma versão diferente do schema local, não há histórico do que mudou e quando, e aplicar uma mudança em produção vira "abrir o MySQL Workbench e rezar". Migration resolve isso: cada mudança de schema vira um arquivo numerado, versionado no Git, aplicado em ordem, uma única vez, com registro em uma tabela de controle.

7.2 Implementação simples: scripts numerados + tabela de controle

SQL
-- migrations/0001_criar_tabela_eventos.sql
CREATE TABLE IF NOT EXISTS eventos (
  id INT AUTO_INCREMENT PRIMARY KEY,
  titulo VARCHAR(150) NOT NULL,
  descricao TEXT,
  categoria ENUM('palestra', 'minicurso', 'workshop') NOT NULL,
  data_hora DATETIME NOT NULL,
  local VARCHAR(150) NOT NULL,
  vagas INT NOT NULL DEFAULT 0,
  imagem_url VARCHAR(255),
  criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SQL
-- migrations/0002_criar_tabela_inscricoes.sql
CREATE TABLE IF NOT EXISTS inscricoes (
  id INT AUTO_INCREMENT PRIMARY KEY,
  evento_id INT NOT NULL,
  usuario_uid VARCHAR(128) NOT NULL,
  criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (evento_id) REFERENCES eventos(id) ON DELETE CASCADE,
  UNIQUE KEY uk_evento_usuario (evento_id, usuario_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SQL
-- migrations/0003_adicionar_indice_categoria.sql
ALTER TABLE eventos ADD INDEX idx_categoria (categoria);
JavaScript
// scripts/migrar.js
// Executor de migrations minimalista: lê migrations/*.sql em ordem numérica,
// aplica só as que ainda não constam na tabela de controle.
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import mysql from 'mysql2/promise'
import { config } from '../src/config/index.js'

const PASTA_DE_MIGRATIONS = new URL('../migrations', import.meta.url)

async function garantirTabelaDeControle(conexao) {
  await conexao.query(`
    CREATE TABLE IF NOT EXISTS migrations_executadas (
      nome_arquivo VARCHAR(255) PRIMARY KEY,
      executado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB
  `)
}

async function listarMigrationsJaExecutadas(conexao) {
  const [linhas] = await conexao.query('SELECT nome_arquivo FROM migrations_executadas')
  return new Set(linhas.map((linha) => linha.nome_arquivo))
}

async function executarMigracoes() {
  const conexao = await mysql.createConnection({
    host: config.DB_HOST,
    port: config.DB_PORT,
    user: config.DB_USER,
    password: config.DB_PASSWORD,
    database: config.DB_NAME,
    multipleStatements: true,
  })

  try {
    await garantirTabelaDeControle(conexao)
    const jaExecutadas = await listarMigrationsJaExecutadas(conexao)

    const arquivos = (await readdir(PASTA_DE_MIGRATIONS))
      .filter((arquivo) => arquivo.endsWith('.sql'))
      .sort() // nomes numerados (0001_..., 0002_...) garantem ordem correta

    let quantidadeAplicada = 0

    for (const arquivo of arquivos) {
      if (jaExecutadas.has(arquivo)) {
        console.log(`↷ ${arquivo} já aplicada, pulando`)
        continue
      }

      const caminhoCompleto = path.join(new URL(PASTA_DE_MIGRATIONS).pathname, arquivo)
      const sql = await readFile(caminhoCompleto, 'utf-8')

      console.log(`▶ aplicando ${arquivo}...`)
      await conexao.query(sql)
      await conexao.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES (?)', [arquivo])
      quantidadeAplicada += 1
      console.log(`✔ ${arquivo} aplicada`)
    }

    console.log(`\nConcluído: ${quantidadeAplicada} migration(s) nova(s) aplicada(s).`)
  } finally {
    await conexao.end()
  }
}

executarMigracoes().catch((erro) => {
  console.error('❌ falha ao rodar migrations:', erro.message)
  process.exit(1)
})
JSON
// package.json — trecho de "scripts"
{
  "scripts": {
    "migrar": "node scripts/migrar.js"
  }
}
Terminal
npm run migrar
# ▶ aplicando 0001_criar_tabela_eventos.sql...
# ✔ 0001_criar_tabela_eventos.sql aplicada
# ▶ aplicando 0002_criar_tabela_inscricoes.sql...
# ✔ 0002_criar_tabela_inscricoes.sql aplicada
# ▶ aplicando 0003_adicionar_indice_categoria.sql...
# ✔ 0003_adicionar_indice_categoria.sql aplicada
#
# Concluído: 3 migration(s) nova(s) aplicada(s).

npm run migrar
# ↷ 0001_criar_tabela_eventos.sql já aplicada, pulando
# ↷ 0002_criar_tabela_inscricoes.sql já aplicada, pulando
# ↷ 0003_adicionar_indice_categoria.sql já aplicada, pulando
#
# Concluído: 0 migration(s) nova(s) aplicada(s).

🔎 Por baixo do capô Ferramentas prontas como node-pg-migrate (Postgres) ou umzug (multi-banco) fazem exatamente isso — tabela de controle + arquivos ordenados — só que com mais recursos (rollback automático, geração de esqueleto de arquivo, migrations em JS além de SQL). Entender o mecanismo manual antes de usar a ferramenta pronta evita tratá-la como caixa-preta.

💻 Mão na massa — refatorando o unieventos-api para arquitetura em camadas

Passo 1 — instale as dependências novas:

Terminal
cd unieventos-api
npm install zod pino pino-http helmet express-rate-limit dotenv
npm install -D vitest supertest pino-pretty

Passo 2 — crie a estrutura de pastas:

Terminal
mkdir -p src/config src/db src/errors src/middlewares src/repositories src/services src/controllers src/routes src/validators src/utils migrations scripts test

Passo 3 — configuração (já mostrada na Seção 3):

Copie o conteúdo de src/config/index.js e .env.example da Seção 3 para o projeto.

Passo 4 — o pool de conexão (Singleton):

JavaScript
// src/db/pool.js
import mysql from 'mysql2/promise'
import { config } from '../config/index.js'

let instanciaDoPool

export function obterPool() {
  if (!instanciaDoPool) {
    instanciaDoPool = mysql.createPool({
      host: config.DB_HOST,
      port: config.DB_PORT,
      user: config.DB_USER,
      password: config.DB_PASSWORD,
      database: config.DB_NAME,
      waitForConnections: true,
      connectionLimit: 10,
    })
  }
  return instanciaDoPool
}

Passo 5 — validador de entrada com zod:

JavaScript
// src/validators/eventoSchema.js
import { z } from 'zod'

export const eventoSchema = z.object({
  titulo: z.string().min(3, 'titulo precisa ter ao menos 3 caracteres').max(150),
  descricao: z.string().max(2000).optional(),
  categoria: z.enum(['palestra', 'minicurso', 'workshop']),
  dataHora: z.string().datetime({ message: 'dataHora precisa ser um ISO 8601 válido' }),
  local: z.string().min(3).max(150),
  vagas: z.coerce.number().int().nonnegative('vagas não pode ser negativo'),
  imagemUrl: z.string().url().optional(),
})

export const eventoAtualizacaoSchema = eventoSchema.partial()
JavaScript
// src/middlewares/validar.js
import { ErroDeValidacao } from '../errors/index.js'

// Middleware genérico: recebe um schema zod e devolve um middleware Express
// que valida req.body antes de deixar a requisição seguir para o controller.
export function validar(schema) {
  return (req, res, next) => {
    const resultado = schema.safeParse(req.body)

    if (!resultado.success) {
      const detalhes = resultado.error.issues.map((problema) => ({
        campo: problema.path.join('.'),
        mensagem: problema.message,
      }))
      throw new ErroDeValidacao('Dados inválidos', detalhes)
    }

    req.body = resultado.data // body validado e com coerções aplicadas (ex.: vagas vira number)
    next()
  }
}

Passo 6 — os erros de domínio:

Use o conteúdo de src/errors/index.js da Seção 4.1.

Passo 7 — o repositório MySQL (com Builder de query):

JavaScript
// src/repositories/queryBuilderDeListagem.js
// Builder: monta incrementalmente a query SQL de listagem, adicionando cláusulas
// WHERE só para os filtros que realmente vieram preenchidos.
export class QueryBuilderDeListagem {
  constructor(tabela) {
    this.tabela = tabela
    this.condicoes = []
    this.parametros = []
    this.limiteValor = 20
    this.deslocamentoValor = 0
  }

  comCategoria(categoria) {
    if (categoria) {
      this.condicoes.push('categoria = ?')
      this.parametros.push(categoria)
    }
    return this // encadeamento fluente — marca registrada do Builder
  }

  comBuscaDeTexto(termo) {
    if (termo) {
      this.condicoes.push('titulo LIKE ?')
      this.parametros.push(`%${termo}%`)
    }
    return this
  }

  comPaginacao(pagina = 1, porPagina = 20) {
    this.limiteValor = porPagina
    this.deslocamentoValor = (pagina - 1) * porPagina
    return this
  }

  construir() {
    const clausulaWhere = this.condicoes.length > 0 ? `WHERE ${this.condicoes.join(' AND ')}` : ''
    const sql = `
      SELECT * FROM ${this.tabela}
      ${clausulaWhere}
      ORDER BY data_hora ASC
      LIMIT ? OFFSET ?
    `
    return {
      sql,
      parametros: [...this.parametros, this.limiteValor, this.deslocamentoValor],
    }
  }
}
JavaScript
// src/repositories/eventosRepository.mysql.js
import { obterPool } from '../db/pool.js'
import { QueryBuilderDeListagem } from './queryBuilderDeListagem.js'

function linhaParaEvento(linha) {
  return {
    id: linha.id,
    titulo: linha.titulo,
    descricao: linha.descricao,
    categoria: linha.categoria,
    dataHora: linha.data_hora,
    local: linha.local,
    vagas: linha.vagas,
    imagemUrl: linha.imagem_url,
  }
}

export function criarRepositorioDeEventosMySQL() {
  const pool = obterPool()

  return {
    async listar({ categoria, busca, pagina, porPagina } = {}) {
      const { sql, parametros } = new QueryBuilderDeListagem('eventos')
        .comCategoria(categoria)
        .comBuscaDeTexto(busca)
        .comPaginacao(pagina, porPagina)
        .construir()

      const [linhas] = await pool.query(sql, parametros)
      return linhas.map(linhaParaEvento)
    },

    async buscarPorId(id) {
      const [linhas] = await pool.query('SELECT * FROM eventos WHERE id = ?', [id])
      return linhas[0] ? linhaParaEvento(linhas[0]) : null
    },

    async criar(dados) {
      const [resultado] = await pool.query(
        `INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
         VALUES (?, ?, ?, ?, ?, ?, ?)`,
        [dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null],
      )
      return { id: resultado.insertId, ...dados }
    },

    async atualizar(id, dados) {
      await pool.query(
        `UPDATE eventos SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
         WHERE id = ?`,
        [dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null, id],
      )
      return this.buscarPorId(id)
    },

    async remover(id) {
      await pool.query('DELETE FROM eventos WHERE id = ?', [id])
    },
  }
}

Passo 8 — o repositório em memória (para testes):

JavaScript
// src/repositories/eventosRepository.memoria.js
// Implementa a MESMA interface do repositório MySQL, sem tocar em banco algum.
// Usado nos testes (Seção 6.3) e como referência didática de Strategy.
export function criarRepositorioDeEventosEmMemoria(eventosIniciais = []) {
  let eventos = [...eventosIniciais]
  let proximoId = eventos.length + 1

  return {
    async listar({ categoria } = {}) {
      if (!categoria) return eventos
      return eventos.filter((evento) => evento.categoria === categoria)
    },
    async buscarPorId(id) {
      return eventos.find((evento) => evento.id === Number(id)) ?? null
    },
    async criar(dados) {
      const novoEvento = { id: proximoId++, ...dados }
      eventos.push(novoEvento)
      return novoEvento
    },
    async atualizar(id, dados) {
      eventos = eventos.map((evento) => (evento.id === Number(id) ? { ...evento, ...dados } : evento))
      return this.buscarPorId(id)
    },
    async remover(id) {
      eventos = eventos.filter((evento) => evento.id !== Number(id))
    },
  }
}

Passo 9 — Strategy: escolha do repositório por ambiente:

JavaScript
// src/repositories/index.js
// Strategy: a interface é sempre a mesma (listar/buscarPorId/criar/atualizar/remover);
// a implementação escolhida depende do ambiente de execução.
import { config } from '../config/index.js'
import { criarRepositorioDeEventosMySQL } from './eventosRepository.mysql.js'
import { criarRepositorioDeEventosEmMemoria } from './eventosRepository.memoria.js'

export function obterRepositorioDeEventos() {
  if (config.NODE_ENV === 'test') {
    return criarRepositorioDeEventosEmMemoria()
  }
  return criarRepositorioDeEventosMySQL()
}

Passo 10 — o service (mostrado completo na Seção 3):

Use src/services/eventosService.js da Seção 3, já com a hierarquia de erros da Seção 4.1.

Passo 11 — o controller:

JavaScript
// src/controllers/eventosController.js
export function criarControllerDeEventos({ eventosService }) {
  return {
    async listar(req, res) {
      const { categoria, busca, pagina, porPagina } = req.query
      const eventos = await eventosService.listarEventos({ categoria, busca, pagina, porPagina })
      res.status(200).json(eventos)
    },

    async buscarPorId(req, res) {
      const evento = await eventosService.buscarEventoPorId(Number(req.params.id))
      res.status(200).json(evento)
    },

    async criar(req, res) {
      const evento = await eventosService.criarEvento(req.body)
      res.status(201).json(evento)
    },

    async atualizar(req, res) {
      const evento = await eventosService.atualizarEvento(Number(req.params.id), req.body)
      res.status(200).json(evento)
    },

    async remover(req, res) {
      await eventosService.removerEvento(Number(req.params.id))
      res.status(204).end()
    },
  }
}

⚠️ Atenção Repare que nenhum método do controller usa try/catch. Em Express 5, um erro lançado dentro de um handler async é capturado automaticamente e encaminhado ao middleware de erro — não precisamos mais de .catch(next) como no Express 4.

Passo 12 — as rotas:

JavaScript
// src/routes/eventos.routes.js
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { eventoSchema, eventoAtualizacaoSchema } from '../validators/eventoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'

export function criarRotasDeEventos({ eventosController }) {
  const router = Router()

  router.get('/', eventosController.listar)
  router.get('/:id', eventosController.buscarPorId)

  // Rotas de escrita exigem autenticação (middleware da Aula 10) e corpo validado.
  router.post('/', verificarToken, validar(eventoSchema), eventosController.criar)
  router.put('/:id', verificarToken, validar(eventoAtualizacaoSchema), eventosController.atualizar)
  router.delete('/:id', verificarToken, eventosController.remover)

  return router
}

Passo 13 — montando a aplicação:

JavaScript
// src/app.js
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'
import { tratadorDeErros } from './middlewares/tratadorDeErros.js'
import { criarRotasDeEventos } from './routes/eventos.routes.js'
import { criarControllerDeEventos } from './controllers/eventosController.js'
import { criarServicoDeEventos } from './services/eventosService.js'
import { obterRepositorioDeEventos } from './repositories/index.js'

export function criarApp({ eventosRepository = obterRepositorioDeEventos() } = {}) {
  const app = express()

  app.use(cabecalhosDeSeguranca)
  app.use(corsConfigurado)
  app.use(limitadorDeTaxa)
  app.use(express.json({ limit: '10kb' }))

  const eventosService = criarServicoDeEventos({ eventosRepository })
  const eventosController = criarControllerDeEventos({ eventosService })

  app.get('/health', (req, res) => {
    res.status(200).json({ status: 'ok' })
  })

  app.use('/api/eventos', criarRotasDeEventos({ eventosController }))

  // O tratador de erros é SEMPRE o último app.use — Express identifica middlewares
  // de erro pela assinatura de 4 parâmetros, não pela posição, mas a convenção
  // de deixá-lo por último evita que ele "capture" middlewares registrados depois.
  app.use(tratadorDeErros)

  return app
}

Passo 14 — o server.js (mostrado completo na Seção 4.3). Rode e confira:

Terminal
npm run migrar
npm run dev
curl http://localhost:3000/health
# {"status":"ok"}

Passo 15 — rode os testes:

Terminal
npm test

Confira que os 7 testes das Seções 6.3 e 6.4 passam.

🧪 Laboratório

1. Refatore seu projeto autoral para a arquitetura em camadas — crie as pastas config/, db/, errors/, middlewares/, repositories/, services/, controllers/, routes/, mova o código existente para os lugares certos.

Resultado esperado: npm run dev continua funcionando, e nenhuma rota importa o pool do banco diretamente.

Dica

Comece de dentro para fora: primeiro extraia o repositório (funções que tocam o banco), depois o service (regra de negócio), depois o controller (o que sobrar do handler antigo).

2. Centralize a configuração com zod — crie src/config/index.js validando pelo menos 4 variáveis do seu .env.

Resultado esperado: remover uma variável obrigatória do .env faz o processo falhar ao iniciar, com mensagem clara.

Dica

Use safeParse, não parse — assim você controla a mensagem de erro antes de chamar process.exit(1).

3. Implemente a hierarquia de erros e o tratador central no seu projeto, substituindo throw new Error(...) genérico por ErroDeValidacao, ErroNaoEncontrado etc.

Resultado esperado: uma requisição a um recurso inexistente devolve 404 com { mensagem: "..." }, sem stack trace em produção.

Dica

Simule produção localmente com NODE_ENV=production npm start e confira que a resposta de erro não tem o campo stack.

4. Escreva 3 testes automatizados — pelo menos um de rota (supertest) e um de service (unitário, repositório falso).

Resultado esperado: npm test mostra os 3 testes passando.

Dica

Copie a estrutura dos testes das Seções 6.3/6.4 e troque eventos pela entidade do seu domínio.

5. Adicione helmet, express-rate-limit e CORS restritivo ao seu app.js.

Resultado esperado: uma requisição de origem diferente da configurada em CORS_ORIGEM_PERMITIDA é bloqueada pelo navegador (verifique no console do DevTools).

Dica

Teste abrindo o front em uma porta e fazendo uma requisição para a API configurada com outra origem em CORS_ORIGEM_PERMITIDA — o erro de CORS aparece no console do navegador, não no Postman (Postman ignora CORS).

🐛 Erros comuns e como resolver

Sintoma Causa Solução
Processo não inicia, imprime lista de variáveis faltando .env incompleto em relação ao schema de config/index.js Copie .env.example para .env e preencha todos os campos
TypeError: eventosRepository.listar is not a function Repositório injetado não implementa a interface esperada pelo service Confira se toda implementação de repositório (MySQL, memória) expõe os mesmos métodos
Teste de rota falha com erro de conexão ao MySQL Testes estão usando o repositório real em vez do de memória Injete eventosRepository: criarRepositorioDeEventosEmMemoria() explicitamente no criarApp dos testes
Erro 500 aparece no navegador com stack trace completo NODE_ENV não está definido como production no deploy Configure NODE_ENV=production nas variáveis de ambiente do serviço de deploy
npm run migrar reaplica uma migration já aplicada Tabela migrations_executadas não foi criada ou o nome do arquivo mudou Confira SELECT * FROM migrations_executadas e não renomeie arquivos de migration já aplicados
CORS bloqueando o front mesmo em desenvolvimento CORS_ORIGEM_PERMITIDA no .env não bate com a porta real do Vite (5173 por padrão) Ajuste a variável para a URL exata mostrada pelo npm run dev do front

🏠 Atividade assíncrona (1 h)

  1. No projeto autoral, garanta que os 5 endpoints principais (listar, buscar por id, criar, atualizar, remover) passam pela arquitetura em camadas completa.
  2. Escreva testes cobrindo pelo menos 40% dos métodos do service principal (liste no README quais foram testados e por quê).
  3. Aplique o checklist de segurança da Seção 5: helmet, rate limit, CORS restritivo, limite de payload — cole no README um trecho de log ou print mostrando o X-RateLimit-Limit no cabeçalho de resposta.
  4. Rode npm test e cole a saída completa no README, em uma seção "Testes".

Critério de pronto: npm test passa localmente, README atualizado com a seção de testes e o checklist de segurança marcado.

✅ Checkpoint do projeto autoral

Ao final desta aula, seu repositório <tema>-api deve ter:

  • [ ] Estrutura routes/ → controllers/ → services/ → repositories/ → db/ completa, com middlewares/, validators/, utils/, config/.
  • [ ] src/config/index.js validando o .env com zod e falhando rápido se algo faltar.
  • [ ] Hierarquia de erros de domínio e tratador central mapeando para status HTTP.
  • [ ] helmet, express-rate-limit e CORS restritivo configurados.
  • [ ] Pelo menos 3 testes automatizados passando (npm test).
  • [ ] Pasta migrations/ com scripts numerados e script npm run migrar funcional.

📚 Para aprofundar


Próxima aula (14, 09/12/2026): documentamos a API inteira com OpenAPI 3 e Swagger UI — cada endpoint que construímos até aqui ganha um contrato formal, testável direto do navegador. Traga o unieventos-api (ou seu projeto autoral) já na arquitetura em camadas desta aula.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EAD

Aula 14 — Documentação com Swagger

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Explicar por que documentar uma API é parte do trabalho de engenharia, não um extra opcional.
  • Diferenciar OpenAPI (a especificação) de Swagger (o conjunto de ferramentas que a implementa).
  • Ler e escrever a anatomia de um documento OpenAPI 3.0: info, servers, paths, components.schemas, components.securitySchemes.
  • Gerar a especificação a partir de anotações @openapi com swagger-jsdoc, usando corretamente a chave definition.
  • Servir e customizar o Swagger UI com swagger-ui-express, incluindo o endpoint com o JSON cru.
  • Documentar todos os endpoints do UniEventos com schemas reutilizáveis e segurança via bearerAuth.
  • Testar endpoints protegidos direto pelo Swagger UI usando o botão "Authorize".
  • Produzir um README de qualidade, um CONTRIBUTING.md mínimo e registrar decisões de arquitetura em formato ADR.

📋 Pré-requisitos desta aula

  • unieventos-api (ou projeto autoral) já refatorado para arquitetura em camadas (Aula 13), com rotas de eventos, inscrições e autenticação funcionando.
  • Node.js 22 LTS e a API rodando localmente com npm run dev.

Checklist antes de começar:

  • [ ] GET /health responde 200 na sua API.
  • [ ] Você consegue autenticar via Firebase e obter um token de ID (Aula 10) para testar rotas protegidas.
  • [ ] Ferramenta para chamadas HTTP manuais disponível (Insomnia, Postman ou curl).

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Por que documentar, OpenAPI vs. Swagger, anatomia do documento
2 50 min swagger-jsdoc + swagger-ui-express: configuração e primeiros endpoints anotados
3 50 min Documentando toda a API do UniEventos, segurança com bearerAuth, README/ADR

Retomando a Aula 13

Na Aula 13 transformamos o unieventos-api em uma arquitetura em camadas testável e segura. O código ficou sólido por dentro — mas de fora, para quem nunca viu o projeto (um colega de equipe, um avaliador, você mesmo em três meses), ele ainda é uma caixa-preta: só descobre o que a API faz lendo o código-fonte inteiro. Hoje resolvemos isso com um contrato formal e navegável: OpenAPI + Swagger UI.

1. Por que documentar uma API

Um endpoint sem documentação obriga quem for consumi-lo a ler o código-fonte do back-end inteiro — ou pior, a adivinhar por tentativa e erro. Em qualquer cenário além do "eu programando sozinho e lembrando de tudo", isso custa tempo real:

  • Contrato entre times. O time de front-end pode começar a construir a tela de "Minhas inscrições" antes do endpoint estar pronto, desde que o contrato (formato de entrada/saída) esteja documentado e estável. Documentação é o que permite front e back trabalharem em paralelo.
  • Onboarding. Um novo integrante do time entende a API lendo uma página, não vasculhando 40 arquivos de rota.
  • Geração de clientes. A partir de um documento OpenAPI, ferramentas geram automaticamente SDKs tipados em várias linguagens — você escreve o contrato uma vez, o cliente sai de graça.
  • Contrato como teste. Ferramentas de teste de contrato conferem se a resposta real da API bate com o que foi documentado — a documentação vira uma fonte de verdade verificável, não um texto que fica defasado.

⚠️ Atenção Documentação que não é gerada a partir do código (ou vinculada a ele por anotação) apodrece rápido: alguém muda um campo na rota e esquece de atualizar o Word/Notion separado. É exatamente esse problema que o swagger-jsdoc resolve — a documentação vive ao lado do código, no mesmo arquivo, na mesma revisão de código.

1.1 OpenAPI vs. Swagger — não são sinônimos

  • OpenAPI é a especificação: um formato (YAML ou JSON) que descreve endpoints, parâmetros, corpos de requisição, respostas e esquemas de segurança de uma API REST, de forma independente de linguagem. A versão usada nesta disciplina é a OpenAPI 3.0.
  • Swagger é o conjunto de ferramentas (hoje mantido pela SmartBear) construído em torno da especificação OpenAPI — o nome "Swagger" é anterior ao nome "OpenAPI" (a especificação se chamava Swagger Specification até a versão 2.0; a partir da 3.0 passou a se chamar OpenAPI, mas o ecossistema de ferramentas manteve o nome Swagger).

Duas ferramentas do ecossistema Swagger que usaremos hoje:

Ferramenta Papel
swagger-jsdoc Lê anotações @openapi em comentários JSDoc no seu código e gera o documento OpenAPI (JSON)
swagger-ui-express Recebe esse documento OpenAPI e renderiza uma interface HTML interativa (o "Swagger UI")

O swagger-ui é a interface visual que você provavelmente já viu em várias APIs públicas — aquela página com os endpoints agrupados por tag, cada um expansível, com botão "Try it out" para testar direto do navegador.

2. Anatomia de um documento OpenAPI 3.0

Um documento OpenAPI é um único objeto JSON (ou YAML) com estas chaves de topo:

YAML
openapi: 3.0.0        # versão da especificação usada
info:                  # metadados da API
  title: UniEventos API
  version: 1.0.0
  description: API de eventos acadêmicos do UniEventos
servers:               # onde a API está hospedada (pode ter vários)
  - url: http://localhost:3000
    description: Ambiente local
tags:                  # agrupamento visual dos endpoints no Swagger UI
  - name: Eventos
  - name: Inscrições
  - name: Autenticação
paths:                 # cada endpoint documentado
  /api/eventos:
    get: { ... }
    post: { ... }
components:            # peças reutilizáveis entre paths
  schemas: { ... }        # formatos de objeto (Evento, EventoInput, Erro...)
  securitySchemes: { ... } # como a API autentica (ex.: bearerAuth)

Explicando cada bloco com o UniEventos:

  • openapi — string fixa 3.0.0, indica a versão da especificação. Não confunda com a versão da sua API (isso é info.version).
  • info — título, versão e descrição da API. É o que aparece no topo do Swagger UI.
  • servers — lista de URLs onde a API responde de verdade. Em desenvolvimento, http://localhost:3000; em produção, a URL pública (Aula 15). O Swagger UI usa isso para montar a URL completa quando você clica em "Try it out".
  • tags — só organiza visualmente os endpoints em grupos colapsáveis (Eventos, Inscrições, Autenticação).
  • paths — o coração do documento: cada rota (/api/eventos, /api/eventos/{id}...) e, dentro dela, cada método HTTP (get, post, put, delete), com parameters, requestBody e responses.
  • components.schemas — formatos de objeto reutilizáveis (o formato de um Evento, de um EventoInput, de um Erro padrão), referenciados de dentro de paths com $ref em vez de repetidos em cada endpoint.
  • components.securitySchemes — descreve como a API autentica (aqui, Bearer Token JWT do Firebase), sem misturar isso com a lógica de cada endpoint individual.
  • parameters — parâmetros de path ({id}), query (?categoria=palestra) ou header, com tipo e descrição.
  • requestBody — o formato esperado do corpo da requisição (POST/PUT), normalmente referenciando um schema via $ref.
  • responses — para cada status HTTP possível (200, 400, 404...), o formato do corpo de resposta.
  • $ref — mecanismo de referência: em vez de repetir a definição de Evento em 5 endpoints diferentes, cada um aponta para #/components/schemas/Evento. Mude uma vez, atualiza em todo lugar.

3. Duas abordagens para gerar o documento

3.1 Abordagem (a): anotações @openapi com swagger-jsdoc — a que vamos implementar

A ideia: você escreve um comentário JSDoc especial, com bloco YAML dentro, logo acima da definição da rota no próprio arquivo de rotas. O swagger-jsdoc varre os arquivos configurados, extrai esses comentários e monta o documento OpenAPI completo em tempo de execução.

Terminal
npm install swagger-jsdoc swagger-ui-express
JavaScript
// src/docs/swaggerSpec.js
import swaggerJsdoc from 'swagger-jsdoc'

// ATENÇÃO: a chave é "definition", NÃO "swaggerDefinition" — swagger-jsdoc 6.x
// renomeou essa chave em relação a versões anteriores. Usar o nome errado faz
// a spec sair vazia, sem erro nenhum no console.
const opcoes = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'UniEventos API',
      version: '1.0.0',
      description:
        'API REST da plataforma UniEventos — divulgação e inscrição em eventos acadêmicos. ' +
        'Desenvolvida na disciplina FACET-SNP-310 (UNEMAT/Sinop).',
      contact: {
        name: 'Prof. Ivan Luiz Pedroso Pires',
        email: 'ivanpires@gmail.com',
      },
      license: {
        name: 'MIT',
      },
    },
    servers: [
      { url: 'http://localhost:3000', description: 'Ambiente local' },
      { url: 'https://unieventos-api.onrender.com', description: 'Produção' },
    ],
    tags: [
      { name: 'Eventos', description: 'Cadastro e consulta de eventos acadêmicos' },
      { name: 'Inscrições', description: 'Inscrição de usuários autenticados em eventos' },
      { name: 'Autenticação', description: 'Fluxo de login com Firebase Auth' },
    ],
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          bearerFormat: 'JWT',
          description: 'Token de ID do Firebase Auth, obtido após o login no front-end.',
        },
      },
    },
  },
  // Arquivos onde o swagger-jsdoc procura comentários @openapi.
  apis: ['./src/routes/*.js', './src/docs/schemas/*.js'],
}

export const swaggerSpec = swaggerJsdoc(opcoes)

⚠️ Atenção Repare na chave definition dentro de opcoes. Em versões antigas do swagger-jsdoc (2.x/3.x) essa chave se chamava swaggerDefinition. Nesta disciplina usamos swagger-jsdoc@6.3.0, que exige definition. Se você copiar um tutorial antigo da internet com swaggerDefinition, a spec gerada fica com paths: {} vazio e nenhum erro é lançado — o bug é silencioso.

3.2 Abordagem (b): openapi.yaml escrito à mão

A alternativa é escrever o documento OpenAPI inteiro em um arquivo .yaml, sem anotação nenhuma no código, e servir esse arquivo estático:

YAML
# openapi.yaml (resumo — não é o que vamos usar hoje, é só para você conhecer a alternativa)
openapi: 3.0.0
info:
  title: UniEventos API
  version: 1.0.0
paths:
  /api/eventos:
    get:
      tags: [Eventos]
      summary: Lista eventos
      responses:
        '200':
          description: Lista de eventos
JavaScript
// server.js — servindo o YAML escrito à mão, em vez de gerado por anotação
import { readFileSync } from 'node:fs'
import yaml from 'yaml' // npm install yaml
import swaggerUi from 'swagger-ui-express'

const documentoOpenApi = yaml.parse(readFileSync('./openapi.yaml', 'utf-8'))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(documentoOpenApi))

Vantagem: controle total do texto, sem depender de comentário no meio do código. Desvantagem: fica fácil o YAML "descolar" do código real, porque nada obriga a atualizá-lo junto com a rota. Por isso, nesta disciplina, a abordagem oficial é a (a) — anotações junto ao código, sempre atualizadas na mesma revisão.

4. Servindo com swagger-ui-express

JavaScript
// src/app.js — trecho adicionado à montagem da aplicação (depois das rotas de negócio)
import swaggerUi from 'swagger-ui-express'
import { swaggerSpec } from './docs/swaggerSpec.js'

// Opções de customização visual do Swagger UI.
const opcoesDoSwaggerUi = {
  customSiteTitle: 'UniEventos API — Documentação',
  customCss: '.swagger-ui .topbar { display: none }', // esconde a barra verde padrão
}

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, opcoesDoSwaggerUi))

// Expõe o JSON cru da spec — útil para importar em Insomnia/Postman
// ou para ferramentas de geração de cliente consumirem diretamente.
app.get('/api-docs.json', (req, res) => {
  res.status(200).json(swaggerSpec)
})
Terminal
npm run dev
# abra no navegador:
# http://localhost:3000/api-docs       → interface interativa
# http://localhost:3000/api-docs.json  → JSON cru da especificação

💡 Dica swaggerUi.serve é um array de middlewares (serve os arquivos estáticos da interface: CSS, JS, HTML); swaggerUi.setup(spec, opcoes) é o middleware que injeta sua spec nessa interface. Os dois sempre andam juntos, nessa ordem, no mesmo app.use.

🧩 Padrão de projeto em uso

🧩 Padrão de projeto em uso — Decorator (documentação como anotação)

O padrão Decorator adiciona comportamento ou informação a um objeto sem alterar sua estrutura original. As anotações @openapi fazem exatamente isso, só que no nível de documentação de código-fonte em vez de tempo de execução: o comentário JSDoc "decora" a rota com metadados (parâmetros, respostas, segurança) sem alterar uma linha da lógica real do router.get(...). Remova o comentário e a rota continua funcionando idêntica — a documentação é uma camada adicionada por cima, não uma dependência funcional.

js /** * @openapi * /api/eventos: * get: * summary: Lista eventos ← "decoração": metadado * tags: [Eventos] ← "decoração": metadado */ router.get('/', eventosController.listar) // ← comportamento real, intocado

É a mesma lógica dos decorators de linguagens como TypeScript/Java (@Component, @Test) — mas aqui implementada via convenção de comentário, lida por uma ferramenta externa (swagger-jsdoc), porque JavaScript puro (sem TypeScript) não tem decorators nativos estáveis no runtime do Node.

5. Documentando os schemas reutilizáveis

Antes de anotar cada rota, definimos os formatos de objeto que se repetem — assim cada endpoint só referencia ($ref) em vez de redigitar os mesmos campos.

JavaScript
// src/docs/schemas/evento.schema.js
/**
 * @openapi
 * components:
 *   schemas:
 *     Evento:
 *       type: object
 *       properties:
 *         id:
 *           type: integer
 *           example: 3
 *         titulo:
 *           type: string
 *           example: Hackathon FACET
 *         descricao:
 *           type: string
 *           example: Maratona de programação de 24 horas aberta a todos os cursos.
 *         categoria:
 *           type: string
 *           enum: [palestra, minicurso, workshop]
 *           example: workshop
 *         dataHora:
 *           type: string
 *           format: date-time
 *           example: 2026-10-05T08:00:00
 *         local:
 *           type: string
 *           example: Bloco A, Auditório
 *         vagas:
 *           type: integer
 *           example: 60
 *         imagemUrl:
 *           type: string
 *           format: uri
 *           example: https://storage.unieventos.dev/eventos/hackathon.jpg
 *
 *     EventoInput:
 *       type: object
 *       required: [titulo, categoria, dataHora, local, vagas]
 *       properties:
 *         titulo:
 *           type: string
 *           minLength: 3
 *           maxLength: 150
 *         descricao:
 *           type: string
 *         categoria:
 *           type: string
 *           enum: [palestra, minicurso, workshop]
 *         dataHora:
 *           type: string
 *           format: date-time
 *         local:
 *           type: string
 *         vagas:
 *           type: integer
 *           minimum: 0
 *         imagemUrl:
 *           type: string
 *           format: uri
 *
 *     Erro:
 *       type: object
 *       properties:
 *         mensagem:
 *           type: string
 *           example: Evento não encontrado
 *         detalhes:
 *           type: array
 *           items:
 *             type: object
 *             properties:
 *               campo:
 *                 type: string
 *               mensagem:
 *                 type: string
 *
 *     Paginacao:
 *       type: object
 *       properties:
 *         pagina:
 *           type: integer
 *           example: 1
 *         porPagina:
 *           type: integer
 *           example: 20
 *         total:
 *           type: integer
 *           example: 47
 */
export {} // arquivo só existe para hospedar o comentário — sem código de fato
JavaScript
// src/docs/schemas/inscricao.schema.js
/**
 * @openapi
 * components:
 *   schemas:
 *     Inscricao:
 *       type: object
 *       properties:
 *         id:
 *           type: integer
 *           example: 12
 *         eventoId:
 *           type: integer
 *           example: 3
 *         usuarioUid:
 *           type: string
 *           example: fY3k9sLp2QaB1cD4eF5gH6iJ7kL8
 *         criadoEm:
 *           type: string
 *           format: date-time
 *
 *     InscricaoInput:
 *       type: object
 *       required: [eventoId]
 *       properties:
 *         eventoId:
 *           type: integer
 *           example: 3
 */
export {}

🔎 Por baixo do capô Esses arquivos *.schema.js não exportam nada útil em termos de código JavaScript — servem só para o swagger-jsdoc encontrar o comentário (por isso estão incluídos em apis: [...] na configuração da Seção 3.1). É uma convenção comum para não poluir arquivos de rota reais com blocos de schema grandes.

6. Documentando todos os endpoints do UniEventos

6.1 Eventos — as 5 operações (CRUD completo)

JavaScript
// src/routes/eventos.routes.js — versão anotada
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { eventoSchema, eventoAtualizacaoSchema } from '../validators/eventoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'

export function criarRotasDeEventos({ eventosController }) {
  const router = Router()

  /**
   * @openapi
   * /api/eventos:
   *   get:
   *     summary: Lista eventos, com filtros opcionais
   *     tags: [Eventos]
   *     parameters:
   *       - in: query
   *         name: categoria
   *         schema:
   *           type: string
   *           enum: [palestra, minicurso, workshop]
   *         description: Filtra por categoria do evento
   *       - in: query
   *         name: busca
   *         schema:
   *           type: string
   *         description: Filtra por trecho do título
   *       - in: query
   *         name: pagina
   *         schema:
   *           type: integer
   *           default: 1
   *       - in: query
   *         name: porPagina
   *         schema:
   *           type: integer
   *           default: 20
   *     responses:
   *       200:
   *         description: Lista de eventos encontrados
   *         content:
   *           application/json:
   *             schema:
   *               type: array
   *               items:
   *                 $ref: '#/components/schemas/Evento'
   */
  router.get('/', eventosController.listar)

  /**
   * @openapi
   * /api/eventos/{id}:
   *   get:
   *     summary: Busca um evento pelo id
   *     tags: [Eventos]
   *     parameters:
   *       - in: path
   *         name: id
   *         required: true
   *         schema:
   *           type: integer
   *         description: Id numérico do evento
   *     responses:
   *       200:
   *         description: Evento encontrado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Evento'
   *       404:
   *         description: Evento não encontrado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.get('/:id', eventosController.buscarPorId)

  /**
   * @openapi
   * /api/eventos:
   *   post:
   *     summary: Cria um novo evento
   *     tags: [Eventos]
   *     security:
   *       - bearerAuth: []
   *     requestBody:
   *       required: true
   *       content:
   *         application/json:
   *           schema:
   *             $ref: '#/components/schemas/EventoInput'
   *     responses:
   *       201:
   *         description: Evento criado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Evento'
   *       400:
   *         description: Dados inválidos
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   *       401:
   *         description: Token ausente ou inválido
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.post('/', verificarToken, validar(eventoSchema), eventosController.criar)

  /**
   * @openapi
   * /api/eventos/{id}:
   *   put:
   *     summary: Atualiza um evento existente
   *     tags: [Eventos]
   *     security:
   *       - bearerAuth: []
   *     parameters:
   *       - in: path
   *         name: id
   *         required: true
   *         schema:
   *           type: integer
   *     requestBody:
   *       required: true
   *       content:
   *         application/json:
   *           schema:
   *             $ref: '#/components/schemas/EventoInput'
   *     responses:
   *       200:
   *         description: Evento atualizado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Evento'
   *       404:
   *         description: Evento não encontrado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.put('/:id', verificarToken, validar(eventoAtualizacaoSchema), eventosController.atualizar)

  /**
   * @openapi
   * /api/eventos/{id}:
   *   delete:
   *     summary: Remove um evento
   *     tags: [Eventos]
   *     security:
   *       - bearerAuth: []
   *     parameters:
   *       - in: path
   *         name: id
   *         required: true
   *         schema:
   *           type: integer
   *     responses:
   *       204:
   *         description: Evento removido com sucesso, sem corpo de resposta
   *       404:
   *         description: Evento não encontrado
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.delete('/:id', verificarToken, eventosController.remover)

  return router
}

6.2 Inscrições

JavaScript
// src/routes/inscricoes.routes.js — versão anotada
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { inscricaoSchema } from '../validators/inscricaoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'

export function criarRotasDeInscricoes({ inscricoesController }) {
  const router = Router()

  /**
   * @openapi
   * /api/inscricoes:
   *   get:
   *     summary: Lista as inscrições do usuário autenticado
   *     tags: [Inscrições]
   *     security:
   *       - bearerAuth: []
   *     responses:
   *       200:
   *         description: Lista de inscrições do usuário logado
   *         content:
   *           application/json:
   *             schema:
   *               type: array
   *               items:
   *                 $ref: '#/components/schemas/Inscricao'
   *       401:
   *         description: Token ausente ou inválido
   */
  router.get('/', verificarToken, inscricoesController.listarMinhas)

  /**
   * @openapi
   * /api/inscricoes:
   *   post:
   *     summary: Inscreve o usuário autenticado em um evento
   *     tags: [Inscrições]
   *     security:
   *       - bearerAuth: []
   *     requestBody:
   *       required: true
   *       content:
   *         application/json:
   *           schema:
   *             $ref: '#/components/schemas/InscricaoInput'
   *     responses:
   *       201:
   *         description: Inscrição criada
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Inscricao'
   *       409:
   *         description: Usuário já está inscrito neste evento
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.post('/', verificarToken, validar(inscricaoSchema), inscricoesController.criar)

  /**
   * @openapi
   * /api/inscricoes/{id}:
   *   delete:
   *     summary: Cancela uma inscrição do próprio usuário
   *     tags: [Inscrições]
   *     security:
   *       - bearerAuth: []
   *     parameters:
   *       - in: path
   *         name: id
   *         required: true
   *         schema:
   *           type: integer
   *     responses:
   *       204:
   *         description: Inscrição cancelada
   *       403:
   *         description: A inscrição pertence a outro usuário
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.delete('/:id', verificarToken, inscricoesController.cancelar)

  return router
}

6.3 Autenticação

O UniEventos não implementa login no back-end — o login acontece no front, direto contra o Firebase Auth (Aula 10). O back-end só verifica o token recebido. Ainda assim, documentamos esse fluxo, porque quem consumir a API precisa saber como obter o token:

JavaScript
// src/routes/autenticacao.routes.js
import { Router } from 'express'

export function criarRotasDeAutenticacao() {
  const router = Router()

  /**
   * @openapi
   * /api/auth/verificar:
   *   get:
   *     summary: Confirma se o token enviado é válido e devolve os dados do usuário
   *     description: >
   *       Não existe endpoint de login nesta API — o login acontece no front-end,
   *       diretamente contra o Firebase Auth (signInWithEmailAndPassword). Este
   *       endpoint serve apenas para confirmar que um token de ID do Firebase é válido.
   *     tags: [Autenticação]
   *     security:
   *       - bearerAuth: []
   *     responses:
   *       200:
   *         description: Token válido
   *         content:
   *           application/json:
   *             schema:
   *               type: object
   *               properties:
   *                 uid:
   *                   type: string
   *                 email:
   *                   type: string
   *       401:
   *         description: Token ausente, expirado ou inválido
   *         content:
   *           application/json:
   *             schema:
   *               $ref: '#/components/schemas/Erro'
   */
  router.get('/verificar', (req, res) => {
    res.status(200).json({ uid: req.usuario.uid, email: req.usuario.email })
  })

  return router
}

7. Segurança com bearerAuth e o botão "Authorize"

O esquema bearerAuth já foi declarado em components.securitySchemes (Seção 3.1):

YAML
securitySchemes:
  bearerAuth:
    type: http
    scheme: bearer
    bearerFormat: JWT

Cada endpoint protegido referencia esse esquema com security: [{ bearerAuth: [] }] (como fizemos em POST /api/eventos, PUT /api/eventos/{id}, DELETE /api/eventos/{id} e todas as rotas de inscrições). O efeito no Swagger UI:

  1. Um cadeado aparece ao lado de cada operação protegida.
  2. Um botão verde "Authorize" aparece no topo da página.
  3. Clicar nele abre um campo para colar o token — só o token puro, sem o prefixo Bearer (o Swagger UI adiciona isso sozinho no cabeçalho Authorization).
  4. Depois de autorizado, todo "Try it out" em endpoint protegido já envia o cabeçalho automaticamente.

💡 Dica Para obter um token de teste rápido, abra o console do navegador na sua aplicação front-end já logada e rode: js import { getAuth } from 'firebase/auth' const token = await getAuth().currentUser.getIdToken() console.log(token) Copie o valor impresso e cole no botão "Authorize" do Swagger UI.

8. Testando pelo Swagger UI ("Try it out")

  1. Abra http://localhost:3000/api-docs.
  2. Expanda GET /api/eventos, clique em "Try it out", depois em "Execute" — a resposta real da API aparece embaixo, com status e corpo formatado.
  3. Para testar POST /api/eventos, clique em "Authorize" primeiro (Seção 7), depois expanda a operação, edite o JSON de exemplo no campo de corpo, e execute.

⚠️ Atenção — CORS e servers O Swagger UI faz a requisição do navegador, então as mesmas regras de CORS da Aula 13 se aplicam: se servers apontar para uma URL diferente da que está rodando o front (ou se a API não liberar a origem da própria página do Swagger UI), o "Try it out" falha com erro de CORS no console — mesmo a API estando no ar. Garanta que CORS_ORIGEM_PERMITIDA inclua a origem de onde o Swagger UI está sendo servido (geralmente a própria API, http://localhost:3000, o que já é liberado por padrão pelo mesmo processo).

9. Além do Swagger: documentação completa do projeto

9.1 README de qualidade

Markdown
<!-- README.md -->
# UniEventos API

![Node.js](https://img.shields.io/badge/node-22.x-green)
![Express](https://img.shields.io/badge/express-5.2.1-blue)
![Licença](https://img.shields.io/badge/licença-MIT-lightgrey)

API REST da plataforma **UniEventos** — divulgação e inscrição em eventos acadêmicos.
Projeto desenvolvido na disciplina FACET-SNP-310 (UNEMAT/Sinop, 2026.2).

## Requisitos

- Node.js 22 LTS
- MySQL 8 (local ou gerenciado)
- Conta de serviço do Firebase (arquivo de credenciais)

## Instalação

\`\`\`bash
git clone https://github.com/seu-usuario/unieventos-api.git
cd unieventos-api
npm install
cp .env.example .env   # preencha com suas credenciais
npm run migrar
npm run dev
\`\`\`

## Variáveis de ambiente

| Variável | Descrição |
|---|---|
| `PORT` | Porta HTTP da API (padrão 3000) |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Credenciais do MySQL |
| `FIREBASE_PROJECT_ID` | Id do projeto Firebase usado na verificação de token |
| `CORS_ORIGEM_PERMITIDA` | Origem do front-end autorizada pelo CORS |

## Scripts disponíveis

| Comando | Efeito |
|---|---|
| `npm run dev` | Sobe a API com recarregamento automático |
| `npm start` | Sobe a API em modo produção |
| `npm test` | Executa a suíte de testes (vitest) |
| `npm run migrar` | Aplica migrations pendentes no banco |

## Endpoints

Documentação interativa completa em `/api-docs` (Swagger UI) com o projeto rodando.
Resumo:

| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/eventos` | Lista eventos, com filtros |
| GET | `/api/eventos/:id` | Detalha um evento |
| POST | `/api/eventos` | Cria evento (autenticado) |
| PUT | `/api/eventos/:id` | Atualiza evento (autenticado) |
| DELETE | `/api/eventos/:id` | Remove evento (autenticado) |
| GET | `/api/inscricoes` | Lista inscrições do usuário logado |
| POST | `/api/inscricoes` | Inscreve o usuário em um evento |
| DELETE | `/api/inscricoes/:id` | Cancela inscrição |

## Licença

MIT — veja o arquivo LICENSE.

9.2 Coleção de API exportada

Além do Swagger UI, exporte uma coleção do Insomnia ou Postman e comite no repositório em docs/insomnia-collection.json — facilita quem prefere testar fora do navegador. No Insomnia: menu Application → Preferences → Data → Export Data, escolha a workspace do projeto, formato Insomnia v4, e salve o arquivo na pasta docs/ do repositório.

9.3 CONTRIBUTING.md mínimo

Markdown
<!-- CONTRIBUTING.md -->
# Como contribuir

1. Crie uma branch a partir de `main`: `git checkout -b feature/nome-da-mudanca`.
2. Rode `npm test` antes de abrir o Pull Request — a suíte precisa passar.
3. Siga o padrão de nomes em português para identificadores de domínio (`eventos`, `criarEvento`).
4. Toda rota nova precisa ter anotação `@openapi` correspondente (Aula 14).
5. Abra o Pull Request descrevendo o que mudou e por quê.

9.4 Documentação do front: JSDoc em composables

JavaScript
// src/composables/useEventos.js
/**
 * Composable que encapsula a busca e o estado de carregamento da lista de eventos.
 *
 * @param {Object} [opcoes] - opções de filtro inicial
 * @param {string} [opcoes.categoria] - categoria para filtrar a busca inicial
 * @returns {{
 *   eventos: import('vue').Ref<Array>,
 *   carregando: import('vue').Ref<boolean>,
 *   erro: import('vue').Ref<string|null>,
 *   buscarEventos: (filtros?: Object) => Promise<void>
 * }}
 */
export function useEventos(opcoes = {}) {
  // implementação já construída na Aula 06/11 — reaproveitada aqui
}

Comentários JSDoc em composables dão autocomplete e checagem de tipo básica no VS Code, mesmo em projetos JavaScript puro (sem TypeScript) — o editor lê o @param/@returns e sugere os campos corretos a quem consome o composable.

9.5 ADR — Architecture Decision Record

Um ADR é um documento curto (10 a 20 linhas) que registra uma decisão técnica, o contexto que levou a ela, e as alternativas consideradas — para que, meses depois, ninguém precise adivinhar "por que fizemos assim?".

Formato em 10 linhas:

Markdown
# ADR 0001: <título curto da decisão>

**Status:** aceito | proposto | substituído por ADR-000X


## Contexto
<qual problema motivou esta decisão>

## Decisão
<o que foi decidido>

## Consequências
<o que fica mais fácil, o que fica mais difícil, o que foi trocado por quê>

Exemplo real do UniEventos:

Markdown
<!-- docs/adr/0001-escolha-do-repository-pattern.md -->
# ADR 0001: Usar o padrão Repository para acesso a dados

**Status:** aceito


## Contexto
O service de eventos precisava consultar o MySQL diretamente, o que impedia
testar as regras de negócio (ex.: "vagas não pode ser negativo") sem subir
um banco de dados real, e acoplava o service à sintaxe SQL do mysql2.

## Decisão
Extrair toda a lógica de acesso a dados para `repositories/`, com uma
interface comum (`listar`, `buscarPorId`, `criar`, `atualizar`, `remover`),
injetada no service por parâmetro (Dependency Injection). O ambiente de
teste usa uma implementação em memória; produção usa a implementação MySQL.

## Consequências
Testes de service ficaram instantâneos e sem dependência externa. Trocar o
banco de dados (como fizemos ao avaliar Supabase na Aula 12) passou a exigir
apenas uma nova implementação de repositório, sem tocar em services ou
controllers. Custo: uma camada de indireção a mais para quem está lendo o
código pela primeira vez.

📌 Na prova Um ADR não documenta código — documenta decisão e motivo. Se a resposta para "por que você fez assim?" está só na sua cabeça, ela vai se perder. Escrever ADRs curtos ao longo do desenvolvimento é mais barato do que reconstruir esse raciocínio depois.

🧪 Laboratório

1. Configure swagger-jsdoc e swagger-ui-express no seu projeto autoral, com definition (não swaggerDefinition), info, pelo menos uma tag e o securityScheme bearerAuth.

Resultado esperado: http://localhost:3000/api-docs abre com o título e a descrição da sua API.

Dica

Copie src/docs/swaggerSpec.js da Seção 3.1 e troque só o title, description e as tags para o domínio do seu projeto.

2. Documente 3 endpoints do seu projeto autoral com anotações @openapi completas (parâmetros, requestBody quando houver, respostas para pelo menos 2 status diferentes).

Resultado esperado: os 3 endpoints aparecem expansíveis no Swagger UI, com exemplos de corpo preenchidos.

Dica

Comece pelo endpoint de listagem (mais simples, sem requestBody) e depois avance para um de criação (com requestBody e security).

3. Crie os schemas reutilizáveis da entidade principal do seu domínio (equivalente a Evento/EventoInput/Erro) e referencie com $ref nos 3 endpoints do exercício anterior.

Resultado esperado: mudar um campo no schema reflete automaticamente em todos os endpoints que o referenciam.

Dica

Coloque os schemas em src/docs/schemas/*.schema.js e inclua o caminho no array apis da configuração do swagger-jsdoc.

4. Teste um endpoint protegido pelo "Authorize" — obtenha um token do Firebase (Seção 7) e confirme que a requisição autenticada funciona pelo Swagger UI.

Resultado esperado: sem token, a rota protegida retorna 401; com token válido, retorna 200/201.

Dica

Se a resposta continuar 401 mesmo com token colado, confira se você colou só o token puro, sem o prefixo Bearer.

5. Escreva um ADR para uma decisão técnica real do seu projeto (ex.: por que escolheu MySQL ou Supabase, por que escolheu determinado padrão de rota).

Resultado esperado: arquivo docs/adr/0001-<slug>.md seguindo o formato de 10 linhas da Seção 9.5.

Dica

Escolha uma decisão que você realmente tomou e hesitou entre alternativas — é mais fácil escrever o "Contexto" quando a dúvida foi real.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
/api-docs abre, mas paths está vazio Usou a chave swaggerDefinition em vez de definition nas opções do swagger-jsdoc Troque para definition — é a chave exigida na versão 6.x
Endpoint documentado não aparece no Swagger UI O arquivo onde está o comentário @openapi não está listado em apis: [...] Adicione o caminho (ou glob) do arquivo à lista apis da configuração
$ref: '#/components/schemas/Evento' gera erro "not found" O schema Evento não foi anotado em nenhum arquivo varrido pelo apis Confira se src/docs/schemas/evento.schema.js está no array apis e se o YAML do comentário está corretamente indentado
Botão "Authorize" não aparece Nenhum endpoint tem security: [{ bearerAuth: [] }], ou securitySchemes não foi declarado em components Declare securitySchemes.bearerAuth em definition.components e adicione security nos endpoints protegidos
"Try it out" falha com erro de CORS A origem da própria página do Swagger UI não está liberada pelo middleware de CORS da API Garanta que a origem da API (onde o /api-docs é servido) está coberta pela configuração de CORS, ou sirva o Swagger UI na mesma origem da API
YAML do comentário @openapi quebra a spec inteira silenciosamente Indentação incorreta no bloco YAML dentro do comentário JSDoc YAML é sensível a espaços — nunca misture tabs, use 2 espaços por nível, consistentemente

🏠 Atividade assíncrona (1 h)

  1. Documente todos os endpoints do seu projeto autoral com anotações @openapi (não só os 3 do laboratório).
  2. Garanta que os schemas Erro e de paginação (se aplicável) estão presentes e referenciados.
  3. Revise o README.md seguindo a estrutura da Seção 9.1: badges, requisitos, instalação, variáveis de ambiente, scripts, endpoints (com link para /api-docs), licença.
  4. Escreva pelo menos 1 ADR adicional sobre uma decisão do seu back-end.

Critério de pronto: /api-docs mostra 100% dos endpoints do projeto autoral documentados; README revisado; ao menos 2 ADRs no repositório.

✅ Checkpoint do projeto autoral

Ao final desta aula, seu repositório <tema>-api deve ter:

  • [ ] swagger-jsdoc configurado com a chave definition e swagger-ui-express servindo em /api-docs.
  • [ ] /api-docs.json expondo a spec crua.
  • [ ] Schemas reutilizáveis ($ref) para a entidade principal, incluindo um schema de Erro.
  • [ ] securityScheme bearerAuth configurado e usado em todos os endpoints protegidos.
  • [ ] README revisado com badges, instalação, variáveis de ambiente, scripts e tabela de endpoints.
  • [ ] Pasta docs/adr/ com pelo menos 2 registros de decisão.

📚 Para aprofundar


Próxima aula (15, 16/12/2026): fechamos o semestre com deploy real (front e back), CI/CD com GitHub Actions, retrospectiva de todos os padrões de projeto usados, guia de estudo para o exame final e as instruções completas da Avaliação 3. Traga a API documentada e pronta para publicar.

Nível 3Unidade 3 · Integração front-end/back-end3 aulas de 50 min + 1 h EADFecha a unidade · Avaliação 3

Aula 15 — Deploy, apresentação e finalização

Nível 3 — Frameworks Modernos · FACET-SNP-310 · WebLab · Prof. Ivan Luiz Pedroso Pires

🎯 Objetivos de aprendizagem

Ao final desta aula você será capaz de:

  • Gerar o build de produção de uma aplicação Vue com Vite e explicar o que existe dentro de dist/.
  • Publicar o front-end em um serviço de hospedagem estática (Vercel, Firebase Hosting ou GitHub Pages), configurando variáveis de ambiente e rewrite de SPA.
  • Publicar o back-end Express em um serviço de nuvem, com PORT dinâmico, health check e variáveis de ambiente seguras.
  • Escrever um Dockerfile simples para a API e um docker-compose.yml com API + MySQL.
  • Diagnosticar e corrigir os erros mais comuns pós-deploy (CORS, mixed content, 404 em rota interna, banco inacessível).
  • Configurar um pipeline básico de CI/CD no GitHub Actions que roda lint e testes a cada push.
  • Relacionar cada padrão de projeto estudado no semestre ao trecho de código onde ele apareceu no UniEventos.
  • Apresentar o projeto autoral em formato de seminário técnico, dentro do tempo e critérios definidos.
  • Entregar a Avaliação 3 seguindo integralmente os requisitos e o prazo estabelecidos.

📋 Pré-requisitos desta aula

  • unieventos-api (ou projeto autoral) com arquitetura em camadas (Aula 13) e documentação Swagger (Aula 14) completas.
  • Front-end (unieventos-web ou equivalente) com build funcionando localmente (npm run build sem erro).
  • Conta gratuita em pelo menos um serviço de hospedagem de front (Vercel, Netlify, Firebase Hosting ou GitHub Pages) e um de back (Render, Railway ou Fly.io).
  • Repositórios do projeto autoral publicados e atualizados no GitHub.

Checklist antes de começar:

  • [ ] npm run build do front gera a pasta dist/ sem erro.
  • [ ] npm test do back passa localmente.
  • [ ] Você tem acesso de administrador aos dois repositórios (front e back) no GitHub.

🗺️ Roteiro

Bloco Tempo Atividade
1 50 min Build de produção, deploy do front e do back, Docker, CORS em produção
2 50 min CI/CD com GitHub Actions, retrospectiva de padrões de projeto, guia de estudo do exame final
3 50 min Instruções da Avaliação 3, formato do seminário, encerramento da disciplina

Retomando a Aula 14

Na Aula 14 documentamos a API inteira com OpenAPI e Swagger UI — qualquer pessoa consegue entender e testar o UniEventos sem ler uma linha de código. Falta uma última etapa: tirar o projeto do localhost e colocá-lo no ar, com URL pública, para qualquer pessoa acessar. Hoje fechamos esse ciclo — e fechamos o semestre.

1. Build de produção do front-end

1.1 O que npm run build faz

Terminal
cd unieventos-web
npm run build

O Vite lê src/, resolve todos os imports, faz tree-shaking (remove código não utilizado), minifica JavaScript e CSS, gera hashes nos nomes de arquivo (para cache eficiente no navegador) e escreve tudo em dist/:

Texto
dist/
├─ assets/
│  ├─ index-BvPPrto3.css     ← todo o CSS do projeto, minificado
│  ├─ index-EL0WAqE7.js      ← todo o JavaScript, empacotado e minificado
│  └─ materialdesignicons-*.woff2  ← fontes de ícone do @mdi/font
├─ favicon.ico
└─ index.html                ← HTML final, já referenciando os assets com hash

🔎 Por baixo do capô O hash no nome do arquivo (index-BvPPrto3.js) muda sempre que o conteúdo muda. Isso permite configurar cache agressivo e "para sempre" nesses arquivos no servidor: o navegador só baixa de novo se o hash (e portanto o conteúdo) mudou. O index.html, em contrapartida, nunca deve ser cacheado agressivamente — ele é o que aponta para os hashes corretos a cada novo deploy.

dist/ é tudo que o servidor de hospedagem precisa: arquivos estáticos, sem Node.js rodando por trás. É por isso que hospedar um front-end Vue construído é barato (ou gratuito) — não é um processo de servidor, é só arquivos.

1.2 Variáveis de ambiente do Vite

O Vite só expõe ao código do navegador variáveis de ambiente prefixadas com VITE_ — qualquer outra fica de fora do bundle final, por segurança (evita vazar segredos de build no JavaScript público).

Terminal
# .env.production — lido automaticamente quando NODE_ENV=production (no build)
VITE_API_URL=https://unieventos-api.onrender.com
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
Terminal
# .env.development — lido em npm run dev
VITE_API_URL=http://localhost:3000
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
JavaScript
// src/services/apiClient.js — uso da variável, como já fazemos desde a Aula 06
import axios from 'axios'

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL, // troca sozinho entre dev e produção
})

⚠️ Atenção import.meta.env.VITE_* só existe em tempo de build — o Vite substitui essas referências por valores literais no JavaScript final. Trocar a variável depois do build (por exemplo, direto no painel do serviço de hospedagem, sem rebuildar) não tem efeito nenhum: você precisa gerar um novo build para que um novo valor de VITE_API_URL entre no bundle. Serviços como Vercel fazem isso automaticamente a cada push, rodando npm run build de novo.

1.3 base no vite.config.js

Se o site for publicado em um subcaminho (comum no GitHub Pages, ex.: usuario.github.io/unieventos-web/), configure base:

JavaScript
// vite.config.js — trecho relevante para deploy em subcaminho
export default defineConfig({
  base: '/unieventos-web/', // necessário só se NÃO estiver na raiz do domínio
  plugins: [
    vue({ template: { transformAssetUrls } }),
    vuetify({ autoImport: true }),
  ],
})

Em Vercel, Netlify e Firebase Hosting, o projeto normalmente fica na raiz do domínio (base: '/', o padrão) — só ajuste isso para GitHub Pages em repositório de projeto (não em usuario.github.io).

1.4 Por que SPA precisa de rewrite para index.html

Uma SPA como o UniEventos tem uma única página real (index.html); rotas como /eventos/3 ou /minhas-inscricoes só existem no navegador, resolvidas pelo Vue Router (Aula 04) via History API — o servidor nunca teve, e nunca terá, um arquivo físico chamado eventos/3.

O problema: se o usuário aperta F5 (recarrega a página) estando em /eventos/3, o navegador faz uma requisição HTTP real, ao servidor, pedindo o caminho /eventos/3. Um servidor de arquivos estáticos comum não encontra esse arquivo e responde 404.

A solução é configurar o servidor para, em qualquer caminho que não seja um arquivo estático real, devolver index.html — o Vue Router então assume o controle no navegador e resolve a rota /eventos/3 normalmente.

JSON
// vercel.json — rewrite de SPA na Vercel
{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}
Texto
# _redirects — Netlify (arquivo dentro de public/, copiado para dist/ no build)
/*  /index.html  200
JSON
// firebase.json — trecho relevante do Firebase Hosting
{
  "hosting": {
    "public": "dist",
    "rewrites": [
      { "source": "**", "destination": "/index.html" }
    ]
  }
}

📌 Na prova "F5 em rota interna dá 404" é o sintoma mais clássico de rewrite de SPA mal configurado. Sempre que alguém relatar esse erro pós-deploy, a primeira pergunta é: "o servidor está configurado para devolver index.html em qualquer caminho desconhecido?"

2. Deploy do front-end

2.1 Vercel (recomendado — passo a passo testado)

  1. Crie conta em vercel.com usando login do GitHub.
  2. No painel, clique "Add New... → Project" e selecione o repositório unieventos-web.
  3. A Vercel detecta automaticamente que é um projeto Vite. Confirme: - Build Command: npm run build - Output Directory: dist
  4. Antes de clicar em "Deploy", adicione as variáveis de ambiente na seção Environment Variables: VITE_API_URL, VITE_FIREBASE_API_KEY, VITE_FIREBASE_AUTH_DOMAIN, VITE_FIREBASE_PROJECT_ID — os mesmos valores do seu .env.production local.
  5. Clique Deploy. Em cerca de 1 minuto, a Vercel devolve uma URL pública (https://unieventos-web.vercel.app).
  6. A cada git push na branch main, a Vercel refaz o deploy automaticamente.

💡 Dica A Vercel também cria um preview deploy automático para cada Pull Request, com URL própria — ótimo para revisar uma feature antes de mesclar em main, mas não obrigatório nesta disciplina.

2.2 Alternativa: Firebase Hosting

Terminal
npm install -g firebase-tools
firebase login
firebase init hosting
# Public directory: dist
# Configure as a single-page app (rewrite all urls to /index.html)? Yes
# Set up automatic builds and deploys with GitHub? (opcional, responda conforme preferir)

npm run build
firebase deploy --only hosting

Como o UniEventos já usa Firebase Auth (Aula 10), hospedar no Firebase Hosting mantém tudo no mesmo painel — vantagem organizacional, sem necessidade técnica adicional.

2.3 Alternativa: GitHub Pages

Terminal
npm install -D gh-pages
JSON
// package.json — trecho de "scripts"
{
  "scripts": {
    "deploy": "npm run build && gh-pages -d dist"
  }
}

Lembre de configurar base: '/unieventos-web/' no vite.config.js (Seção 1.3) antes de publicar, já que o GitHub Pages de repositório de projeto serve em um subcaminho. Depois:

Terminal
npm run deploy

A URL pública fica em https://<seu-usuario>.github.io/unieventos-web/. Habilite em Settings → Pages do repositório, escolhendo a branch gh-pages (criada automaticamente pelo pacote gh-pages) como fonte.

3. Deploy do back-end

3.1 O essencial, independente do serviço escolhido

JavaScript
// src/server.js — PORT precisa vir do ambiente, nunca fixo
import { app } from './app.js'
import { config } from './config/index.js'

// A maioria dos serviços de nuvem injeta a variável PORT automaticamente —
// escutar em uma porta fixa (3000) quebra o deploy nesses ambientes.
const servidor = app.listen(config.PORT, () => {
  console.log(`API rodando na porta ${config.PORT}`)
})
JSON
// package.json — script "start" é o que o serviço de deploy roda em produção
{
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}
JavaScript
// trecho de src/app.js — health check simples, usado pelo serviço de deploy
// para saber se o processo está de pé (e reiniciar automaticamente se não estiver)
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', ambiente: config.NODE_ENV })
})

Checklist mínimo antes de publicar o back:

  • [ ] PORT vem de process.env.PORT (via config, Aula 13), nunca hardcoded.
  • [ ] Script start existe em package.json e sobe a API com node puro (sem --watch, que é só para desenvolvimento).
  • [ ] GET /health responde 200 sem exigir autenticação nem banco de dados obrigatoriamente disponível.
  • [ ] Banco de dados gerenciado (não localhost) — MySQL na nuvem (ex.: Railway, PlanetScale-compatível, ou o banco oferecido pelo próprio Render).
  • [ ] Todas as variáveis de .env configuradas como secrets no painel do serviço, nunca commitadas no Git.

3.2 Opções gratuitas/baratas e o que considerar

Serviço Ponto forte Atenção
Render Free tier simples, banco MySQL/Postgres gerenciado disponível Cold start no plano gratuito — primeira requisição após inatividade demora alguns segundos
Railway Deploy rápido a partir do GitHub, bom suporte a MySQL Free tier limitado por uso mensal, não por tempo
Fly.io Roda containers Docker diretamente, bom controle de infraestrutura Curva de aprendizado maior, exige fly.toml e CLI própria

⚠️ Atenção — cold start Planos gratuitos costumam "dormir" o processo após um período sem tráfego. A primeira requisição depois disso demora vários segundos (o serviço precisa religar o container). Isso é normal e esperado no plano gratuito — não é bug do seu código. Avise sobre isso na apresentação se seu projeto usar plano gratuito.

3.3 Deploy na Render (passo a passo)

  1. Crie conta em render.com com login do GitHub.
  2. New → Web Service, selecione o repositório unieventos-api.
  3. Configure: - Runtime: Node - Build Command: npm install - Start Command: npm start
  4. Na aba Environment, adicione todas as variáveis do seu .env (exceto as que só existem localmente).
  5. Se precisar de MySQL gerenciado, crie um New → MySQL (ou Postgres, se preferir migrar) separado na Render e copie a string de conexão para as variáveis DB_* do Web Service.
  6. Clique Create Web Service. A Render builda, sobe o processo, e devolve uma URL pública (https://unieventos-api.onrender.com).
  7. Rode as migrations manualmente uma vez, via o Shell da Render (aba disponível no painel do serviço) ou como Build Command combinado: npm install && npm run migrar.

3.4 Dockerfile simples para a API

Mesmo usando um serviço que builda direto do GitHub, ter um Dockerfile documenta exatamente o ambiente de execução e permite rodar a API localmente de forma idêntica à produção.

Dockerfile
# Dockerfile
FROM node:22-alpine

WORKDIR /app

# Copiar só os arquivos de manifesto primeiro aproveita o cache de camadas do
# Docker: se package.json não mudou, o npm install não roda de novo no rebuild.
COPY package.json package-lock.json ./
RUN npm install --omit=dev

COPY src ./src
COPY migrations ./migrations
COPY scripts ./scripts

EXPOSE 3000

CMD ["npm", "start"]
Terminal
# .dockerignore
node_modules
.env
.git
test
Terminal
docker build -t unieventos-api .
docker run -p 3000:3000 --env-file .env unieventos-api

3.5 docker-compose.yml com API + MySQL

YAML
# docker-compose.yml
services:
  api:
    build: .
    ports:
      - '3000:3000'
    environment:
      NODE_ENV: development
      PORT: 3000
      DB_HOST: mysql
      DB_PORT: 3306
      DB_USER: root
      DB_PASSWORD: senha_local
      DB_NAME: uni_eventos
      FIREBASE_PROJECT_ID: unieventos
      CORS_ORIGEM_PERMITIDA: http://localhost:5173
    depends_on:
      mysql:
        condition: service_healthy

  mysql:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: senha_local
      MYSQL_DATABASE: uni_eventos
    ports:
      - '3306:3306'
    volumes:
      - dados_mysql:/var/lib/mysql
    healthcheck:
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  dados_mysql:
Terminal
docker compose up --build
# API e MySQL sobem juntos, na mesma rede virtual — a API conversa
# com o banco pelo nome do serviço ("mysql"), não por "localhost"

🔎 Por baixo do capô Dentro da rede criada pelo docker compose, cada serviço enxerga os outros pelo nome do serviço no YAML (mysql), não por localhost — por isso DB_HOST: mysql e não DB_HOST: localhost. O valor de "isso funcionar de primeira" é enorme: qualquer pessoa que clonar o repositório sobe o ambiente completo (API + banco, com schema aplicável via npm run migrar) com um único comando, sem instalar MySQL na própria máquina.

4. CORS em produção e diagnóstico de erros pós-deploy

Em produção, restrinja CORS apenas ao domínio real do front publicado — nunca deixe origin: '*' ou o domínio de localhost esquecido em produção:

Terminal
# .env de produção da API
CORS_ORIGEM_PERMITIDA=https://unieventos-web.vercel.app

4.1 Erros clássicos pós-deploy e como diagnosticar

Sintoma Causa provável Como diagnosticar
Tela em branco após publicar, console mostra "Mixed Content" Front em HTTPS chamando API em HTTP puro Confira VITE_API_URL — precisa começar com https://, todo serviço de deploy moderno já expõe HTTPS por padrão
Requisições falham com erro de CORS no console Domínio do front não está em CORS_ORIGEM_PERMITIDA da API, ou variável não foi atualizada em produção Abra a aba Network do DevTools, confira o cabeçalho Access-Control-Allow-Origin na resposta; ajuste a variável de ambiente e reinicie o serviço
Tela em branco, sem erro óbvio Uma VITE_* esquecida no painel de deploy — o build usa undefined silenciosamente Confira todas as variáveis VITE_* no painel do serviço de hospedagem, comparando com o .env.production local
F5 numa rota interna (/eventos/3) dá 404 Servidor não configurado para rewrite de SPA (Seção 1.4) Adicione vercel.json/_redirects/firebase.json com o rewrite para index.html
API responde, mas toda rota de banco dá erro 500 Banco inacessível: credenciais erradas, banco não migrado, ou IP não liberado no firewall do provedor Acesse /health primeiro (não depende de banco); depois confira logs do serviço e rode npm run migrar no ambiente de produção
Login funciona local, falha em produção Domínio de produção não foi adicionado à lista de domínios autorizados do Firebase Auth No Console do Firebase, Authentication → Settings → Authorized domains, adicione o domínio publicado

💡 Dica Sempre teste /health primeiro depois de um deploy. Se ele responde 200, o processo subiu — o problema está em uma camada específica (banco, CORS, variável de ambiente), não na infraestrutura toda.

5. CI/CD introdutório com GitHub Actions

CI (Integração Contínua) roda verificações automáticas a cada mudança no código — lint e testes, neste caso. CD (Entrega Contínua) automatiza a publicação quando essas verificações passam. Juntos, eliminam o "funciona na minha máquina" e o deploy manual esquecido.

YAML
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: ['**']
  pull_request:
    branches: [main]

jobs:
  lint-e-testes:
    runs-on: ubuntu-latest

    steps:
      - name: Baixar o código do repositório
        uses: actions/checkout@v4

      - name: Configurar Node.js 22
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: Instalar dependências
        run: npm ci

      - name: Rodar lint
        run: npm run lint --if-present

      - name: Rodar testes
        run: npm test

  deploy:
    needs: lint-e-testes
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest

    steps:
      - name: Baixar o código do repositório
        uses: actions/checkout@v4

      - name: Disparar deploy na Render via deploy hook
        run: curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"

Explicando as partes:

  • on.push.branches: ['**'] — roda lint/teste em qualquer push, em qualquer branch, dando feedback rápido antes mesmo de abrir Pull Request.
  • needs: lint-e-testes — o job deploy só roda depois que lint-e-testes termina com sucesso; se um teste falhar, o deploy nunca acontece.
  • if: github.ref == 'refs/heads/main' && github.event_name == 'push' — restringe o deploy a pushes diretos (ou merges) na branch main, nunca em branches de feature.
  • secrets.RENDER_DEPLOY_HOOK_URL — configurado em Settings → Secrets and variables → Actions do repositório GitHub; nunca aparece em texto puro no workflow nem no log de execução.

⚠️ Atenção npm ci (não npm install) dentro de workflows de CI: ele instala exatamente as versões travadas em package-lock.json, de forma determinística, e falha se o lockfile estiver dessincronizado do package.json — evita o "passou no CI, mas com uma versão diferente da que alguém tem local".

Para a Vercel (front-end), normalmente não é preciso workflow de deploy próprio — a integração da Vercel com o GitHub já dispara build e deploy automaticamente a cada push em main, de forma nativa, sem depender do GitHub Actions.

🔎 Por baixo do capô O nome "CI/CD" às vezes confunde por juntar duas ideias distintas. Integração Contínua é sobre confiança: cada push prova, automaticamente, que o código continua íntegro (lint limpo, testes passando) antes de qualquer humano revisar. Entrega/Implantação Contínua é sobre velocidade: reduzir a distância entre "código pronto" e "código no ar" para minutos, não para um ritual manual de deploy que alguém precisa lembrar de fazer. O workflow desta seção faz as duas coisas: garante qualidade antes, entrega depois — e só entrega se a qualidade passou.

💡 Dica Um erro comum de quem está aprendendo CI/CD é tratar o pipeline como "burocracia extra". Na prática, ele é o que permite a um time (ou a você sozinho, meses depois) fazer mudanças com confiança: se o CI ficou verde, você sabe que não quebrou nada que já estava coberto por teste — sem isso, cada mudança pequena vira um momento de ansiedade.

6. Retrospectiva da disciplina

6.1 Mapa mental textual do que foi construído

Texto
UniEventos
│
├─ Front-end (unieventos-web)
│  ├─ Vue 3 (Composition API, <script setup>)          — Aulas 01–03
│  ├─ Vuetify 4 (UI, layout, formulários)               — Aulas 04–05
│  ├─ Vue Router 5 (SPA navegável, guards)              — Aulas 04, 10
│  ├─ Pinia 4 (estado global: usuário, eventos)          — Aula 06
│  ├─ Axios (consumo da API, interceptors)               — Aula 06
│  ├─ Firebase Auth (login, cadastro)                    — Aula 10
│  └─ Deploy (Vercel/Firebase Hosting/GitHub Pages)      — Aula 15
│
├─ Back-end (unieventos-api)
│  ├─ Node.js + Express 5 (rotas, middlewares)           — Aula 07–08
│  ├─ MySQL (mysql2/promise, pool, transações)           — Aula 09
│  ├─ Firebase Admin (verificação de token)               — Aula 10
│  ├─ CRUD completo (front + back integrados)             — Aula 11
│  ├─ Supabase (alternativa: RLS, Storage, Realtime)      — Aula 12
│  ├─ Arquitetura em camadas + testes + segurança         — Aula 13
│  ├─ Documentação OpenAPI/Swagger                        — Aula 14
│  └─ Deploy + CI/CD                                      — Aula 15
│
└─ Padrões de projeto (GoF) — ver tabela consolidada abaixo

🧩 Todos os padrões de projeto usados no semestre

A ementa exige explicitamente padrões criacionais, estruturais e comportamentais. Aqui está a lista completa, com onde cada um apareceu de verdade no UniEventos.

Criacionais

Padrão Onde apareceu Aula
Singleton Store Pinia como instância única do estado global; pool de conexões do MySQL (obterPool() em db/pool.js) 06, 09, 13
Factory createPool do mysql2; funções criarServicoDeEventos/criarRepositorioDeEventosMySQL que fabricam objetos configurados 09, 13
Builder QueryBuilderDeListagem, que monta a query SQL de listagem incrementando condições opcionais (.comCategoria().comBuscaDeTexto().construir()) 13
Object Pool O próprio pool de conexões do mysql2 reutiliza um conjunto fixo de conexões abertas em vez de abrir/fechar uma a cada requisição 09

Estruturais

Padrão Onde apareceu Aula
Composite Árvore de componentes Vue (componentes dentro de componentes); rotas aninhadas do Vue Router 04, 05
Facade Camada services/ no front (Aula 11) e no back (Aula 13) escondendo a complexidade de várias chamadas atrás de uma interface simples 11, 13
Adapter Troca de MySQL por Supabase sem alterar o front — a interface do repositório permanece igual, a implementação muda por baixo 12
Proxy reactive()/ref() do Vue usando Proxy do ES6 por baixo dos panos; middleware de autenticação como "proxy de proteção" antes da rota real 03, 10
Decorator Interceptors do Axios "decorando" toda requisição (token, log) sem alterar o código de quem chama; anotações @openapi decorando rotas com metadados sem mudar o comportamento 06, 14

Comportamentais

Padrão Onde apareceu Aula
Observer Sistema de reatividade do Vue — um ref/reactive muda, tudo que depende dele é notificado e re-renderiza automaticamente 02
Chain of Responsibility Pipeline de middlewares do Express — cada app.use decide processar e passar adiante (next()) ou interromper a cadeia 07, 08
Strategy Middlewares/validadores intercambiáveis; escolha de qual repositório usar por ambiente (obterRepositorioDeEventos, MySQL vs. memória) 08, 13
Template Method Componentes de layout com slots definindo um "esqueleto" fixo e pontos variáveis preenchidos por quem usa o componente 05

📌 Na prova O exame final cobra a definição de cada padrão e um exemplo concreto de onde ele apareceu no semestre — não basta decorar o nome, é preciso saber reconhecer o padrão dentro de um trecho de código real.

7. Guia de estudo para o exame final

O exame final é teórico, presencial e individual, cobrindo as três unidades da disciplina. Ele avalia conceitos, não "rodar código" — estude o porquê de cada decisão técnica, não só a sintaxe.

7.1 Lista de 25 a 30 questões de estudo

Unidade 1 — Fundamentos de Vue.js

  1. O que é programação declarativa e como ela difere de manipulação manual do DOM? (Aula 01)
  2. O que é reatividade no Vue e qual padrão de projeto (GoF) explica seu funcionamento? (Aula 02)
  3. Qual a diferença entre Options API e Composition API? Quando usar <script setup>? (Aula 02)
  4. Como reactive() e ref() diferem na forma como armazenam e expõem o valor? (Aula 03)
  5. Por que v-for sempre precisa de :key, e o que acontece se ela faltar? (Aula 02–03)
  6. O que é uma computed e por que ela é preferível a um método equivalente, em termos de performance? (Aula 03)
  7. Qual é a diferença entre onMounted e o restante do ciclo de vida de um componente? (Aula 03)

Unidade 2 — Vue avançado (Vuetify, Vue Router, Axios, Pinia)

  1. Como o Vue Router resolve navegação sem recarregar a página inteira (SPA)? (Aula 04)
  2. O que é um navigation guard e para que serve beforeEach? (Aula 10)
  3. Por que instanciar axios.create({ baseURL }) com interceptors é melhor do que usar axios global? (Aula 06)
  4. Que padrão de projeto os interceptors do Axios exemplificam? (Aula 06)
  5. Qual é o papel do Pinia como single source of truth do estado da aplicação? (Aula 06)
  6. Por que a store Pinia é um exemplo de Singleton? (Aula 06)
  7. Como slots permitem que um componente de layout seja reutilizável em vários contextos? (Aula 05)
  8. O que muda estruturalmente do Vuetify 3 para o Vuetify 4 (tema padrão, tipografia, breakpoints)? (Aula 04–05)

Unidade 3 — Back-end, autenticação, banco de dados, deploy

  1. Qual a diferença entre autenticação e autorização, e onde cada uma aparece no UniEventos? (Aula 10)
  2. Como o Express 5 muda o tratamento de erros assíncronos em relação ao Express 4? (Aula 07–08, 13)
  3. O que é middleware no Express e que padrão de projeto (GoF) o pipeline de middlewares representa? (Aula 07–08)
  4. Por que usar mysql2/promise com queries parametrizadas (?) em vez de concatenar strings? (Aula 09)
  5. O que é uma transação de banco de dados e quando ela é necessária? (Aula 09)
  6. Como o back-end verifica um token do Firebase, e por que essa verificação precisa acontecer no servidor (nunca só no front)? (Aula 10)
  7. O que é RLS (Row Level Security) no Supabase, e por que uma tabela com RLS habilitado e sem policies retorna lista vazia sem erro? (Aula 12)
  8. Que padrão de projeto permite trocar MySQL por Supabase sem alterar o front-end? (Aula 12)
  9. O que é injeção de dependência, e por que um service que recebe o repositório por parâmetro é mais testável? (Aula 13)
  10. Qual a diferença entre um erro operacional (esperado) e um erro inesperado, e por que essa diferença importa no log? (Aula 13)
  11. Por que nunca se deve vazar stack trace em uma resposta de erro em produção? (Aula 13)
  12. Qual a diferença entre OpenAPI e Swagger? (Aula 14)
  13. Por que a chave correta no swagger-jsdoc 6.x é definition, e o que acontece se usar swaggerDefinition? (Aula 14)
  14. O que é uma migration de banco de dados e por que ela substitui um schema.sql aplicado manualmente? (Aula 13)
  15. Por que uma SPA precisa de configuração de rewrite no servidor de hospedagem para funcionar corretamente com F5 em rotas internas? (Aula 15)

7.2 Questões objetivas de exemplo, com gabarito comentado

1. No Vue 3, o sistema de reatividade (reactive, ref) é implementado, por baixo dos panos, principalmente com:

(A) Object.defineProperty apenas (B) Proxy do ES6 (C) WeakMap apenas (D) Getters e setters manuais escritos pelo desenvolvedor

Gabarito comentado

Resposta: B. O Vue 3 usa Proxy do ES6 para interceptar leitura e escrita de propriedades e disparar a reatividade — diferente do Vue 2, que usava Object.defineProperty (com limitações conhecidas, como não detectar adição de novas propriedades). Ver Aula 03.

2. Em Express 5, qual das alternativas abaixo é a forma correta de responder com status 201 e um corpo JSON?

(A) res.json(objeto, 201) (B) res.status(201).json(objeto) (C) res.send(201, objeto) (D) res.json(201, objeto)

Gabarito comentado

Resposta: B. res.json(obj, status) é sintaxe do Express 4, removida no Express 5. A forma correta e atual é encadear res.status(201).json(objeto). Ver Aula 07/13.

3. Uma tabela no Supabase tem RLS (Row Level Security) habilitado, mas nenhuma policy foi criada. Uma consulta SELECT feita por um cliente autenticado retorna:

(A) Um erro 403 Forbidden (B) Todos os registros da tabela, normalmente (C) data: [], sem nenhum erro (D) Um erro 500 Internal Server Error

Gabarito comentado

Resposta: C. É a "causa nº1 de meu código não funciona" no Supabase (Aula 12): RLS sem policy não gera erro, apenas nega acesso silenciosamente, retornando lista vazia. É essencial sempre criar a policy correspondente à operação (SELECT, INSERT etc.).

4. Qual padrão de projeto GoF melhor descreve o pipeline de middlewares do Express, em que cada função decide processar a requisição e passá-la adiante com next(), ou interrompê-la?

(A) Observer (B) Chain of Responsibility (C) Singleton (D) Facade

Gabarito comentado

Resposta: B. Chain of Responsibility: uma cadeia de handlers, cada um com a chance de tratar a requisição ou repassá-la ao próximo. É exatamente o comportamento de app.use(middleware1, middleware2, ...). Ver Aula 07–08.

5. Sobre swagger-jsdoc na versão 6.x usada nesta disciplina, a chave correta dentro das opções para declarar openapi, info e components é:

(A) swaggerDefinition (B) spec (C) definition (D) openApiDefinition

Gabarito comentado

Resposta: C. swaggerDefinition era usado em versões antigas (2.x/3.x). A versão 6.x exige definition. Usar a chave errada não gera erro — só produz uma spec com paths vazio. Ver Aula 14.

6. Por que uma SPA hospedada em produção pode retornar 404 ao usuário apertar F5 em uma rota interna como /eventos/3?

(A) Porque o Vue Router não suporta navegação direta por URL (B) Porque o servidor de hospedagem, sem configuração de rewrite, procura um arquivo físico eventos/3 que não existe (C) Porque o Vite não gera index.html no build de produção (D) Porque import.meta.env não funciona em produção

Gabarito comentado

Resposta: B. O F5 dispara uma requisição HTTP real ao servidor para aquele caminho. Sem rewrite configurado, o servidor de arquivos estáticos não encontra um arquivo físico correspondente e responde 404. A solução é configurar o rewrite para index.html em qualquer caminho desconhecido. Ver Aula 15, Seção 1.4.

7. No padrão de injeção de dependência aplicado na Aula 13, qual é a principal vantagem de um service receber o repository como parâmetro em vez de importá-lo diretamente?

(A) O código fica mais curto (B) É possível testar o service com um repositório falso, sem depender de um banco de dados real (C) É a única forma de usar async/await no Node.js (D) Reduz o número de arquivos do projeto

Gabarito comentado

Resposta: B. A motivação central de DI aqui é testabilidade: o service passa a depender apenas da interface do repositório, não da implementação concreta — em teste, injeta-se uma implementação em memória; em produção, a implementação real. Ver Aula 13, Seção 3.

8. No Vuetify 4, o comportamento padrão da propriedade theme.defaultTheme, se não for explicitamente definida, é:

(A) 'light', igual ao Vuetify 3 (B) 'dark' (C) 'system' — segue a preferência do sistema operacional do usuário (D) Não existe tema padrão; é obrigatório declarar

Gabarito comentado

Resposta: C. No Vuetify 4 o padrão mudou de 'light' (v3) para 'system'. Por isso a disciplina sempre declara explicitamente defaultTheme: 'light' na criação da instância, para manter consistência visual em sala. Ver especificação da Aula 04/05.

8. Apresentação dos resultados

8.1 Formato do seminário final

Cada estudante apresenta seu projeto autoral individualmente, em 8 minutos, cobrindo obrigatoriamente:

  1. O problema (1 min) — que problema real o projeto resolve, para quem.
  2. Demonstração ao vivo (3 min) — navegar pela aplicação publicada (URL real, não localhost): listagem com filtro, detalhe, fluxo autenticado, CRUD funcionando.
  3. Arquitetura (2 min) — diagrama rápido das camadas (front → API → banco), tecnologias escolhidas, e onde a documentação Swagger vive.
  4. Decisão técnica mais difícil (1 min) — um problema real enfrentado e como foi resolvido (ex.: "por que troquei X por Y", "como resolvi o CORS em produção").
  5. O que faria diferente (1 min) — autoavaliação honesta: o que ficaria melhor com mais tempo ou outra escolha técnica.

8.2 Critérios de avaliação da apresentação

Critério O que é observado
Clareza da comunicação Explica o projeto para alguém que nunca viu, sem depender de jargão não explicado
Demonstração funcional A aplicação publicada realmente funciona ao vivo, sem "deixa eu tentar de novo"
Profundidade técnica Consegue justificar decisões (por que essa arquitetura, por que esse banco)
Gestão do tempo Respeita os 8 minutos, sem cortar abruptamente nem sobrar tempo vazio

⚠️ Atenção A apresentação é sobre o projeto autoral publicado, com URL pública real — não é permitido apresentar rodando em localhost. Se o deploy falhar no dia, tenha um vídeo curto de backup gravado com antecedência mostrando o fluxo funcionando.

8.3 Ordem e cronograma

A ordem de apresentação é definida por sorteio, feito em sala na aula anterior (Aula 14) ou no início desta aula, conforme a quantidade de estudantes matriculados. Com 3 blocos de 50 minutos e 8 minutos por estudante, o tempo permite aproximadamente 15 a 16 apresentações — se a turma for maior, o professor comunica com antecedência um ajuste (ex.: reduzir para 6 minutos ou dividir em dois dias, dentro do que o calendário acadêmico permitir).

💻 Mão na massa — publicando o UniEventos

Passo 1 — configure as variáveis de ambiente de produção do front:

Terminal
# no repositório unieventos-web
touch .env.production
# preencha VITE_API_URL com a URL da API já publicada (Passo 4 abaixo)

Passo 2 — confirme que o build local funciona:

Terminal
npm run build
npm run preview
# abra http://localhost:4173 e navegue pelas rotas internas — confirme que
# recarregar a página (F5) numa rota interna NÃO quebra localmente
# (o "vite preview" já simula o comportamento de servidor de produção)

Passo 3 — publique o front na Vercel seguindo o passo a passo da Seção 2.1.

Passo 4 — publique o back na Render seguindo o passo a passo da Seção 3.3. Anote a URL pública gerada.

Passo 5 — volte ao front e atualize VITE_API_URL com a URL real da API publicada, faça commit e push — a Vercel refaz o build automaticamente.

Passo 6 — atualize CORS_ORIGEM_PERMITIDA na API publicada com a URL real do front publicado (Seção 4), reinicie o serviço.

Passo 7 — teste o fluxo completo em produção: abra a URL do front publicado, faça login, liste eventos, crie uma inscrição, atualize a página em uma rota interna (F5) e confirme que não dá 404.

Passo 8 — crie o workflow de CI:

Terminal
mkdir -p .github/workflows
# cole o conteúdo de .github/workflows/ci.yml da Seção 5
git add .github/workflows/ci.yml
git commit -m "adiciona pipeline de CI com lint e testes"
git push

Confira na aba Actions do GitHub que o workflow rodou e passou.

🧪 Laboratório

1. Gere o build de produção do seu projeto autoral e rode npm run preview — confirme que todas as rotas funcionam, incluindo F5 em rota interna.

Resultado esperado: nenhum erro no console, navegação idêntica ao ambiente de desenvolvimento.

Dica

Se uma rota der 404 até no preview local, o problema é de configuração de rota no Vue Router, não de hospedagem — resolva isso antes de publicar.

2. Publique o front-end em um dos serviços da Seção 2, com as variáveis VITE_* corretas.

Resultado esperado: URL pública funcionando, aplicação carrega sem tela em branco.

Dica

Se a tela ficar em branco sem erro óbvio, abra o Console do DevTools primeiro — normalmente aponta uma variável de ambiente undefined.

3. Publique o back-end em um dos serviços da Seção 3, com /health respondendo publicamente.

Resultado esperado: curl https://sua-api.onrender.com/health retorna {"status":"ok",...}.

Dica

Rode as migrations manualmente pelo shell do serviço antes de testar qualquer rota que dependa de tabela do banco.

4. Configure CORS restritivo em produção, apontando exatamente para a URL do front publicado.

Resultado esperado: requisições do front publicado funcionam; uma requisição feita a partir de uma origem diferente é bloqueada.

Dica

Teste abrindo o Console do navegador em uma aba com origem diferente (ex.: http://localhost:5500) e tentando um fetch contra sua API publicada — deve falhar por CORS.

5. Crie o workflow de CI no seu repositório de back-end, rodando lint e testes a cada push.

Resultado esperado: aba Actions do GitHub mostra o workflow executando e passando em verde.

Dica

Se você não tiver npm run lint configurado, o --if-present do comando na Seção 5 evita que o workflow falhe por esse motivo — mas vale configurar ESLint se ainda não tiver.

🐛 Erros comuns e como resolver

Sintoma Causa Solução
Build local funciona, produção mostra tela em branco Variável VITE_* não configurada no painel do serviço de hospedagem Confira todas as VITE_* no painel, compare com .env.production local, force um novo build
Mixed Content no console em produção API ainda respondendo em http:// enquanto o front está em https:// Troque VITE_API_URL para https://; a maioria dos serviços de deploy de back já expõe HTTPS por padrão
CI falha em "npm ci" com erro de lockfile package-lock.json desatualizado em relação ao package.json Rode npm install localmente, comite o package-lock.json atualizado
Deploy da API funciona, mas toda rota autenticada falha Domínio de produção do front não está na lista de domínios autorizados do Firebase Auth Adicione o domínio em Firebase Console → Authentication → Settings → Authorized domains
docker compose up falha ao conectar no banco API tentando conectar em localhost em vez do nome do serviço mysql Ajuste DB_HOST para mysql (o nome do serviço no docker-compose.yml), não localhost
Workflow de CI nunca dispara o job de deploy Condição if do job deploy não bateu (branch errada ou evento errado) Confira se o push foi feito diretamente em main ou via merge de PR para main

🏠 Atividade assíncrona (1 h)

  1. Finalize o deploy completo (front + back) do projeto autoral, se ainda não tiver feito no laboratório.
  2. Grave um vídeo curto (3 a 5 minutos, pode ser não listado no YouTube ou enviado por link de drive) demonstrando o fluxo publicado, como backup para a apresentação.
  3. Prepare os slides ou roteiro da apresentação de 8 minutos, seguindo a estrutura da Seção 8.1.
  4. Revise, uma última vez, o README, garantindo que o link da aplicação publicada e do /api-docs estejam visíveis.

Critério de pronto: aplicação publicada e acessível publicamente; vídeo de backup gravado; roteiro da apresentação pronto.

✅ Checkpoint do projeto autoral

Ao final desta aula, seu projeto deve ter:

  • [ ] Front-end publicado com URL pública, variáveis VITE_* corretas, rewrite de SPA configurado.
  • [ ] Back-end publicado com URL pública, /health respondendo, banco gerenciado acessível, migrations aplicadas.
  • [ ] CORS restrito ao domínio real do front publicado.
  • [ ] Workflow de CI (.github/workflows/ci.yml) rodando lint e testes a cada push.
  • [ ] README com links da aplicação publicada, da API publicada e de /api-docs.
  • [ ] Roteiro de apresentação pronto, dentro de 8 minutos.

📝 Avaliação 3 — instruções de entrega

Escopo: aplicação full stack completa, construída sobre o projeto autoral definido na Aula 01 e evoluído ao longo de todo o semestre.

Requisitos obrigatórios:

  1. Back-end próprio, em Express ou usando Supabase como back-end gerenciado (ou uma combinação dos dois, desde que a arquitetura em camadas da Aula 13 esteja presente onde houver código Express).
  2. Banco de dados persistente (MySQL ou Supabase/Postgres), com schema versionado (migrations ou scripts SQL organizados).
  3. Autenticação funcional (Firebase Auth ou autenticação nativa do Supabase), protegendo pelo menos as rotas de escrita (criação/edição/remoção).
  4. CRUD completo de pelo menos 2 entidades relacionadas (ex.: "Evento" e "Inscrição"), com relacionamento real no banco (chave estrangeira ou equivalente).
  5. Documentação Swagger (OpenAPI 3) cobrindo todos os endpoints, ou documentação equivalente de todas as políticas/endpoints se o back for majoritariamente Supabase.
  6. Deploy funcionando com URL pública — tanto do front quanto do back (ou só do front, se usando Supabase como back completo).
  7. README completo, seguindo a estrutura da Aula 14 (badges, requisitos, instalação, variáveis de ambiente, scripts, endpoints, licença), com os links de aplicação publicada e repositório.

Rubrica (pesos somam 10 pontos):

Critério Peso O que precisa estar funcionando
CRUD completo de 2+ entidades relacionadas 2,5 Criar, listar, editar e remover funcionando de ponta a ponta, com relacionamento real entre as entidades
Banco de dados persistente e corretamente modelado 1,5 Schema versionado (migrations/scripts), queries corretas, sem duplicação nem inconsistência de dados
Autenticação protegendo rotas sensíveis 1,5 Rotas de escrita exigem usuário autenticado; identidade do usuário usada corretamente (ex.: só o dono edita/remove seu próprio recurso)
Documentação Swagger/OpenAPI completa 1,5 Todos os endpoints documentados, schemas reutilizáveis, segurança declarada, /api-docs acessível
Deploy funcionando com URL pública 2,0 Front e back publicados, acessíveis externamente, sem depender de localhost
README e qualidade geral do código 1,0 README completo conforme estrutura da Aula 14; arquitetura em camadas aplicada; ao menos alguns testes automatizados presentes

Formato de entrega: via SIGAA, até 16/12/2026, 23h59, contendo:

  • Link do repositório do front-end.
  • Link do repositório do back-end (ou anotação de que o back é 100% Supabase, com link do projeto Supabase se aplicável).
  • Link da aplicação publicada (URL pública funcionando).
  • Link de /api-docs (se aplicável) ou da documentação equivalente.

⚠️ Atenção A entrega é considerada incompleta se qualquer um dos quatro links acima estiver ausente ou não funcionar no momento da correção. Teste os links em uma aba anônima do navegador antes de enviar, simulando o que o avaliador vai ver.

Política de atraso: entregas após 16/12/2026, 23h59 têm desconto de 1,0 ponto (sobre a nota final da Avaliação 3) por dia corrido de atraso, até o limite de 3 dias — após esse prazo, a avaliação recebe nota zero, exceto em casos de justificativa formal e documentada junto à coordenação do curso, conforme o regimento da UNEMAT.

Política de plágio e uso de IA: é permitido e esperado o uso de ferramentas de IA (como assistentes de código) como apoio ao desenvolvimento — é exatamente essa prática que a indústria de software usa hoje. O que não é aceito: (1) entregar código que você não é capaz de explicar linha a linha na apresentação; (2) copiar o projeto de outro colega, com ou sem alterações cosméticas; (3) apresentar como próprio um projeto gerado quase integralmente por IA sem compreensão do que foi produzido. A apresentação de 8 minutos (Seção 8) é, entre outras coisas, o mecanismo de verificação de autoria: perguntas técnicas sobre decisões do próprio código fazem parte da avaliação.

9. Encerramento: caminhos depois da disciplina

O que foi construído neste semestre é uma base real de desenvolvimento full stack moderno — mas é só o começo. Caminhos naturais de continuidade:

  • Nuxt — framework full stack sobre o Vue, com SSR (Server-Side Rendering) e SSG (Static Site Generation) nativos, útil quando SEO ou performance de primeira carga importam mais do que em uma SPA pura.
  • TypeScript — adicionar tipagem estática ao que hoje é JavaScript puro; o Vue 3 e o Vuetify 4 têm suporte de primeira classe a TS, e o ganho em projetos maiores (detecção de erro em tempo de escrita, autocomplete mais forte) é significativo.
  • Testes E2E — Cypress ou Playwright, testando a aplicação inteira pela interface, como um usuário real faria — o topo da pirâmide de testes que só citamos na Aula 13.
  • Vue 3.6 — acompanhar o roadmap oficial do Vue (Vapor Mode e otimizações de compilador são a fronteira de pesquisa ativa do framework no momento).
  • Mobile com Capacitor/Ionic — reaproveitar o conhecimento de Vue para publicar o mesmo código (ou uma variação) como app nativo Android/iOS.
  • Back-end com NestJS — um framework Node.js opinativo, construído sobre Express (ou Fastify), que formaliza com decorators e módulos exatamente a arquitetura em camadas que construímos manualmente na Aula 13.

9.1 Como montar um portfólio a partir deste semestre

  • Deixe o projeto autoral publicado e funcionando — um link ao vivo vale mais, para quem recruta, do que um repositório que só roda localmente.
  • Escreva um README que conte a história do projeto: problema, decisões técnicas, dificuldades reais (os ADRs da Aula 14 são ótimo material bruto para isso).
  • Grave um vídeo curto de demonstração e fixe no topo do repositório (ou no README, como GIF).
  • Continue commitando — um projeto "morto" no GitHub (sem commit há meses) comunica menos do que um projeto pequeno e ativo.

9.2 Convite para iniciação científica e extensão

Muitos dos temas tocados de leve neste semestre — arquitetura de software, segurança de aplicações web, engenharia de dados, IA aplicada a desenvolvimento — são linhas de pesquisa ativas na FACET. Se algum tópico desta disciplina despertou curiosidade além do prazo de uma avaliação, procure o professor para conversar sobre projetos de iniciação científica ou extensão relacionados — é o próximo passo natural para quem quer ir além do conteúdo obrigatório da ementa.

📚 Para aprofundar


Fim do semestre. Obrigado pelo empenho nas 15 aulas — do primeiro console.log da Aula 01 até uma aplicação full stack publicada, com autenticação, banco de dados e documentação. O exame final cobre teoria das três unidades; revise o guia de estudo da Seção 7 com antecedência, não na véspera. Bom exame, e bom portfólio.

🎯 Objetivos de aprendizagem📋 Pré-requisitos desta aula🗺️ RoteiroRetomando a Aula 141. Build de produção do front-end1.1 O que npm run build faz1.2 Variáveis de ambiente do Vite1.3 base no vite.config.js1.4 Por que SPA precisa de rewrite para index.html2. Deploy do front-end2.1 Vercel (recomendado — passo a passo testado)2.2 Alternativa: Firebase Hosting2.3 Alternativa: GitHub Pages3. Deploy do back-end3.1 O essencial, independente do serviço escolhido3.2 Opções gratuitas/baratas e o que considerar3.3 Deploy na Render (passo a passo)3.4 Dockerfile simples para a API3.5 docker-compose.yml com API + MySQL4. CORS em produção e diagnóstico de erros pós-deploy4.1 Erros clássicos pós-deploy e como diagnosticar5. CI/CD introdutório com GitHub Actions6. Retrospectiva da disciplina6.1 Mapa mental textual do que foi construído🧩 Todos os padrões de projeto usados no semestre7. Guia de estudo para o exame final7.1 Lista de 25 a 30 questões de estudo7.2 Questões objetivas de exemplo, com gabarito comentado8. Apresentação dos resultados8.1 Formato do seminário final8.2 Critérios de avaliação da apresentação8.3 Ordem e cronograma💻 Mão na massa — publicando o UniEventos🧪 Laboratório🐛 Erros comuns e como resolver🏠 Atividade assíncrona (1 h)✅ Checkpoint do projeto autoral📝 Avaliação 3 — instruções de entrega9. Encerramento: caminhos depois da disciplina9.1 Como montar um portfólio a partir deste semestre9.2 Convite para iniciação científica e extensão📚 Para aprofundar
WebLab — Laboratório de Desenvolvimento Web · UNEMAT — Universidade do Estado de Mato Grosso · Campus Sinop · FACET
Prof. Ivan Luiz Pedroso Pires · Material didático de uso educacional; livre para consulta, estudo e reuso com atribuição.
Início · Banco de Desafios · Links úteis · Fontes no GitHub