Modelo de Dados Completo (Firestore + Realtime Database)

Abaixo está um modelo de dados pensado para um app de entregas e transporte de passageiros. A ideia é usar o Firestore para dados persistentes e consultáveis, e o Realtime Database para dados efêmeros e de alta frequência (localização e presença). A estrutura foi inspirada em projetos reais de ride-sharing com Firebase.

1. Firestore — Dados Persistentes

Coleção: usuarios

Armazena passageiros e motoristas/entregadores. O campo tipo diferencia os perfis.

usuarios/{uid}
  ├─ nome: string
  ├─ email: string
  ├─ telefone: string
  ├─ tipo: "passageiro" | "motorista"
  ├─ fotoUrl: string
  ├─ criadoEm: timestamp
  ├─ fcmToken: string
  ├─ status: "ativo" | "bloqueado"
  └─ motoristaDetalhes: map (apenas se tipo == "motorista")
       ├─ cnh: string
       ├─ veiculo: map
       │    ├─ modelo: string
       │    ├─ placa: string
       │    ├─ cor: string
       │    └─ ano: number
       ├─ verificado: boolean
       └─ avaliacaoMedia: number

Coleção: corridas (transporte de passageiros)

Cada documento representa uma corrida.

corridas/{corridaId}
  ├─ passageiroId: string (ref usuarios)
  ├─ motoristaId: string | null (ref usuarios)
  ├─ status: "solicitada" | "aceita" | "em_andamento" | "finalizada" | "cancelada"
  ├─ origem: map
  │    ├─ lat: number
  │    ├─ lng: number
  │    └─ endereco: string
  ├─ destino: map
  │    ├─ lat: number
  │    ├─ lng: number
  │    └─ endereco: string
  ├─ valor: number
  ├─ distanciaKm: number
  ├─ criadaEm: timestamp
  ├─ aceitaEm: timestamp | null
  ├─ finalizadaEm: timestamp | null
  └─ avaliacao: map | null
       ├─ nota: number (1-5)
       └─ comentario: string

Coleção: entregas

Similar a corridas, mas com campos específicos para entregas.

entregas/{entregaId}
  ├─ remetenteId: string (ref usuarios)
  ├─ entregadorId: string | null (ref usuarios)
  ├─ status: "solicitada" | "aceita" | "coletada" | "em_rota" | "entregue" | "cancelada"
  ├─ pontoColeta: map
  │    ├─ lat: number
  │    ├─ lng: number
  │    └─ endereco: string
  ├─ pontoEntrega: map
  │    ├─ lat: number
  │    ├─ lng: number
  │    └─ endereco: string
  ├─ descricaoPacote: string
  ├─ pesoKg: number
  ├─ valor: number
  ├─ criadaEm: timestamp
  ├─ coletadaEm: timestamp | null
  ├─ entregueEm: timestamp | null
  └─ codigoConfirmacao: string

Subcoleção: corridas/{corridaId}/mensagens

Chat entre passageiro e motorista durante a corrida.

corridas/{corridaId}/mensagens/{msgId}
  ├─ autorId: string (uid)
  ├─ texto: string
  └─ enviadaEm: timestamp

Coleção: pagamentos

Histórico de transações.

pagamentos/{pagamentoId}
  ├─ usuarioId: string (ref usuarios)
  ├─ referenciaId: string (corridaId ou entregaId)
  ├─ valor: number
  ├─ metodo: "credito" | "debito" | "pix" | "dinheiro"
  ├─ status: "pendente" | "pago" | "estornado"
  └─ criadoEm: timestamp

2. Realtime Database — Dados em Tempo Real

Caminho: localizacoes/motoristas/{uid}

Atualizado a cada poucos segundos pelo app do motorista.

/localizacoes/motoristas/{uid}
  ├─ lat: number
  ├─ lng: number
  ├─ heading: number (direção em graus)
  ├─ speed: number (m/s)
  ├─ accuracy: number
  └─ updatedAt: number (timestamp Unix)

Caminho: presenca/motoristas/{uid}

Controla disponibilidade online/offline.

/presenca/motoristas/{uid}
  ├─ online: boolean
  ├─ disponivel: boolean (true se não estiver em corrida)
  └─ ultimaAtividade: number

Caminho: corridasAtivas/{corridaId}

Espelho leve da corrida para leitura rápida em tempo real.

/corridasAtivas/{corridaId}
  ├─ motoristaId: string
  ├─ passageiroId: string
  ├─ status: string
  └─ atualizadoEm: number

3. Geoqueries no Firestore (busca de motoristas próximos)

O Firestore não tem consulta geográfica nativa. A solução oficial é usar Geohash com a biblioteca GeoFire.

No documento do motorista no Firestore, adicione:

motoristas/{uid}
  ├─ geohash: string (ex: "6gkzwgjz")
  ├─ lat: number
  └─ lng: number

A consulta por proximidade é feita com:

query.where('geohash', '>=', lowerHash)
     .where('geohash', '<=', upperHash)

Isso permite encontrar motoristas em um raio de alguns quilômetros com apenas um campo indexado.

4. Cloud Functions — Lógica de Match e Eventos

As funções serverless cuidam da lógica que não deve rodar no cliente.

Gatilho Função
onCreate em corridas/{id} Notifica motoristas próximos via FCM
onUpdate em corridas/{id} Se status = "aceita", grava em /corridasAtivas/{id}
onDelete em corridas/{id} Remove de /corridasAtivas/{id}
onWrite em localizacoes/motoristas/{uid} Atualiza geohash no Firestore periodicamente
HTTP Callable Busca motoristas próximos usando geohash

5. FCM — Notificações

O FCM é usado para:

O token FCM de cada usuário é salvo no documento usuarios/{uid}.fcmToken no Firestore.

6. Firebase Auth — Login

O Firebase Auth gerencia:

O uid retornado é usado como chave em usuarios/{uid} no Firestore.

7. Maps SDK — Mapas e Rotas

O Google Maps SDK (não é Firebase) é usado para:

O App Check do Firebase pode proteger as chamadas ao Maps SDK contra abuso.

Resumo Visual da Arquitetura

┌─────────────────────────────────────────────────────────────┐ │ CLIENTE (App) │ │ Flutter / React Native / Android / iOS │ └────────────┬──────────────────────────────┬─────────────────┘ │ │ ▼ ▼ ┌────────────────────────┐ ┌─────────────────────────────┐ │ Firebase Auth │ │ Google Maps SDK │ │ (login, uid) │ │ (mapas, rotas) │ └────────────┬───────────┘ └─────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ FIREBASE BACKEND │ │ │ │ ┌──────────────────────┐ ┌────────────────────────────┐ │ │ │ Cloud Firestore │ │ Realtime Database │ │ │ │ (dados principais) │ │ (localização + presença) │ │ │ └──────────┬───────────┘ └──────────┬─────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Cloud Functions │ │ │ │ (match, notificações, eventos, geohash) │ │ │ └──────────────────────┬───────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ FCM (push notifications) │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘

8. Regras de Segurança — Firestore

As regras de segurança do Firestore controlam quem pode ler e escrever em cada documento. O princípio fundamental é negar por padrão (allow read, write: if false;) e liberar apenas o necessário[reference:0].

8.1 Regras para usuarios

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Usuário só pode ler/escrever seu próprio perfil
    match /usuarios/{userId} {
      allow read: if request.auth != null;
      allow create: if request.auth != null
                    && request.auth.uid == userId
                    && request.resource.data.tipo in ['passageiro', 'motorista'];
      allow update: if request.auth != null
                    && request.auth.uid == userId
                    && request.resource.data.tipo == resource.data.tipo;
      allow delete: if false; // ninguém deleta perfil
    }
  }
}

8.2 Regras para corridas

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /corridas/{corridaId} {
      // Passageiro cria a corrida
      allow create: if request.auth != null
                    && request.resource.data.passageiroId == request.auth.uid
                    && request.resource.data.status == 'solicitada';

      // Passageiro e motorista podem ler
      allow read: if request.auth != null
                  && (resource.data.passageiroId == request.auth.uid
                      || resource.data.motoristaId == request.auth.uid);

      // Apenas o motorista designado pode atualizar para 'aceita' ou 'em_andamento'
      allow update: if request.auth != null
                    && (
                      // Motorista aceita a corrida
                      (resource.data.status == 'solicitada'
                       && request.resource.data.status == 'aceita'
                       && request.resource.data.motoristaId == request.auth.uid)
                      ||
                      // Passageiro ou motorista cancela
                      (request.resource.data.status == 'cancelada'
                       && (resource.data.passageiroId == request.auth.uid
                           || resource.data.motoristaId == request.auth.uid))
                    );

      // Subcoleção de mensagens
      match /mensagens/{msgId} {
        allow read: if request.auth != null
                    && (get(/databases/$(database)/documents/corridas/$(corridaId)).data.passageiroId == request.auth.uid
                        || get(/databases/$(database)/documents/corridas/$(corridaId)).data.motoristaId == request.auth.uid);
        allow create: if request.auth != null
                      && request.resource.data.autorId == request.auth.uid;
      }
    }
  }
}
Importante: As regras do Firestore não são filtros. Uma query só é permitida se todos os documentos que ela poderia retornar estiverem dentro das permissões do usuário[reference:1]. Por isso, queries devem incluir as mesmas restrições das regras.

8.3 Regras para entregas

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /entregas/{entregaId} {
      allow create: if request.auth != null
                    && request.resource.data.remetenteId == request.auth.uid
                    && request.resource.data.status == 'solicitada';

      allow read: if request.auth != null
                  && (resource.data.remetenteId == request.auth.uid
                      || resource.data.entregadorId == request.auth.uid);

      allow update: if request.auth != null
                    && (
                      (resource.data.status == 'solicitada'
                       && request.resource.data.status == 'aceita'
                       && request.resource.data.entregadorId == request.auth.uid)
                      ||
                      (request.resource.data.status == 'cancelada'
                       && (resource.data.remetenteId == request.auth.uid
                           || resource.data.entregadorId == request.auth.uid))
                    );
    }
  }
}

9. Regras de Segurança — Realtime Database

As regras do Realtime Database usam uma sintaxe similar a JSON com JavaScript. O princípio é o mesmo: negar por padrão e liberar apenas o necessário[reference:2].

9.1 Regras para localizacoes

{
  "rules": {
    "localizacoes": {
      "motoristas": {
        "$uid": {
          // Apenas o próprio motorista pode ler/escrever sua localização
          ".read": "auth != null && auth.uid == $uid",
          ".write": "auth != null && auth.uid == $uid",
          ".validate": "newData.hasChildren(['lat', 'lng', 'updatedAt'])",
          "lat": { ".validate": "newData.isNumber()" },
          "lng": { ".validate": "newData.isNumber()" },
          "heading": { ".validate": "newData.isNumber()" },
          "speed": { ".validate": "newData.isNumber()" },
          "accuracy": { ".validate": "newData.isNumber()" },
          "updatedAt": { ".validate": "newData.isNumber()" },
          "$other": { ".validate": false }
        }
      }
    }
  }
}

9.2 Regras para presenca

{
  "rules": {
    "presenca": {
      "motoristas": {
        "$uid": {
          ".read": "auth != null",
          ".write": "auth != null && auth.uid == $uid",
          ".validate": "newData.hasChildren(['online', 'disponivel', 'ultimaAtividade'])",
          "online": { ".validate": "newData.isBoolean()" },
          "disponivel": { ".validate": "newData.isBoolean()" },
          "ultimaAtividade": { ".validate": "newData.isNumber()" },
          "$other": { ".validate": false }
        }
      }
    }
  }
}

9.3 Regras para corridasAtivas

{
  "rules": {
    "corridasAtivas": {
      "$corridaId": {
        // Passageiro e motorista da corrida podem ler
        ".read": "auth != null && (
          data.child('passageiroId').val() == auth.uid ||
          data.child('motoristaId').val() == auth.uid
        )",
        // Apenas Cloud Functions (com service account) escrevem
        ".write": "auth != null && auth.token.email.matches('.*@YOUR-PROJECT-ID.iam.gserviceaccount.com')",
        ".validate": "newData.hasChildren(['motoristaId', 'passageiroId', 'status'])"
      }
    }
  }
}
Dica: Use .validate para garantir que os dados têm a estrutura correta. Qualquer campo não listado com $other é automaticamente rejeitado.

10. Regras de Segurança — Cloud Functions

Cloud Functions não usa "regras" no mesmo formato que Firestore/RTDB. A segurança é feita por:

10.1 Exemplo de função onCall segura

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.buscarMotoristas = functions.https.onCall(async (data, context) => {
  // 1. Verificar autenticação
  if (!context.auth) {
    throw new functions.https.HttpsError(
      'unauthenticated',
      'Você precisa estar logado.'
    );
  }

  // 2. Validar entrada
  const { lat, lng, raioKm } = data;
  if (typeof lat !== 'number' || typeof lng !== 'number') {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'lat e lng são obrigatórios.'
    );
  }

  // 3. Usar App Check (configurado no deploy)
  // enforceAppCheck: true nas opções da função

  // 4. Lógica de busca com geohash...
  // ...

  return { motoristas: [] };
});
Boas práticas:

11. Plano do Firebase — Spark vs Blaze

O Firebase oferece dois planos de preços: Spark (gratuito) e Blaze (pague conforme o uso)[reference:6].

Característica Spark (Gratuito) Blaze (Pago)
Preço base $0 Pague pelo uso
Cartão de crédito Não precisa Obrigatório
Cloud Functions Não incluso Incluso
Firestore Quotas fixas (serviço para ao exceder) Quotas + pague pelo excedente
Realtime Database Quotas fixas (serviço para ao exceder) Quotas + pague pelo excedente
Cloud Storage Quotas fixas Quotas + pague pelo excedente
Hosting Quotas fixas Quotas + pague pelo excedente
Authentication Até 50K MAU Até 50K MAU grátis, depois pago
FCM / Crashlytics Ilimitado Ilimitado

11.1 Quotas gratuitas do Spark (e do Blaze, no tier gratuito)

Serviço Quota Gratuita
Cloud Firestore 1 GiB de armazenamento
50.000 leituras/dia
20.000 escritas/dia
20.000 exclusões/dia[reference:7]
Realtime Database 1 GB de armazenamento
10 GB/mês de download
100 conexões simultâneas
Cloud Functions 2M invocações/mês
400K GB-s
200K CPU-s
5 GB de tráfego de saída/mês
Cloud Storage 5 GB de armazenamento
1 GB/dia de download
20K uploads/dia
50K downloads/dia[reference:8]
Authentication 50.000 MAU (exceto login por telefone)[reference:9]
Hosting 10 GB de armazenamento
360 MB/dia de transferência[reference:10]

11.2 Qual plano escolher para este projeto?

Recomendação: Para um app de entregas e transporte de passageiros, o Blaze é praticamente obrigatório por causa do Cloud Functions. A boa notícia é que o Blaze inclui as mesmas quotas gratuitas do Spark — você só paga pelo que exceder. Um projeto pequeno ou em fase de testes pode ficar em $0 no Blaze[reference:11].

12. Exemplo de Cloud Function — Match de Motoristas

Abaixo está um exemplo completo de Cloud Function para encontrar motoristas próximos usando geohash e geofire-common. Esta função é chamada quando um passageiro solicita uma corrida.

12.1 Dependências

npm install geofire-common firebase-admin firebase-functions

12.2 Código da função

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { geohashQueryBounds, distanceBetween } = require('geofire-common');

admin.initializeApp();
const db = admin.firestore();

/**
 * Busca motoristas disponíveis em um raio de X km.
 * Chamada via HTTPS Callable pelo app do passageiro.
 */
exports.buscarMotoristasProximos = functions.https.onCall(async (data, context) => {
  // ============================================================
  // 1. VERIFICAÇÕES DE SEGURANÇA
  // ============================================================

  // Verificar autenticação
  if (!context.auth) {
    throw new functions.https.HttpsError(
      'unauthenticated',
      'Você precisa estar logado para buscar motoristas.'
    );
  }

  // Validar entrada
  const { lat, lng, raioKm = 5 } = data;

  if (typeof lat !== 'number' || typeof lng !== 'number') {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'lat e lng são obrigatórios e devem ser números.'
    );
  }

  if (raioKm > 20) {
    throw new functions.https.HttpsError(
      'invalid-argument',
      'O raio máximo de busca é 20 km.'
    );
  }

  // ============================================================
  // 2. GEOHASH QUERY BOUNDS
  // ============================================================

  const center = [lat, lng];
  const radiusInM = raioKm * 1000;

  // Obter bounds do geohash para o raio especificado
  const bounds = geohashQueryBounds(center, radiusInM);

  // ============================================================
  // 3. CONSULTAR MOTORISTAS NO FIRESTORE
  // ============================================================

  const promises = [];

  for (const b of bounds) {
    const q = db.collection('motoristas')
      .orderBy('geohash')
      .startAt(b[0])
      .endAt(b[1])
      .where('disponivel', '==', true)   // apenas disponíveis
      .where('verificado', '==', true);  // apenas verificados

    promises.push(q.get());
  }

  const snapshots = await Promise.all(promises);

  // ============================================================
  // 4. FILTRAR POR DISTÂNCIA REAL
  // ============================================================

  const motoristasEncontrados = [];

  for (const snap of snapshots) {
    for (const doc of snap.docs) {
      const motorista = doc.data();
      const motoristaLat = motorista.lat;
      const motoristaLng = motorista.lng;

      // Calcular distância real usando Haversine
      const distanciaKm = distanceBetween(
        [motoristaLat, motoristaLng],
        center
      );

      // Filtrar apenas os que estão dentro do raio
      if (distanciaKm <= raioKm) {
        motoristasEncontrados.push({
          uid: doc.id,
          nome: motorista.nome,
          lat: motoristaLat,
          lng: motoristaLng,
          distanciaKm: Math.round(distanciaKm * 10) / 10,
          avaliacaoMedia: motorista.avaliacaoMedia || 0,
          veiculo: motorista.veiculo || null
        });
      }
    }
  }

  // ============================================================
  // 5. ORDENAR POR DISTÂNCIA
  // ============================================================

  motoristasEncontrados.sort((a, b) => a.distanciaKm - b.distanciaKm);

  // ============================================================
  // 6. RETORNAR RESULTADO
  // ============================================================

  return {
    sucesso: true,
    total: motoristasEncontrados.length,
    motoristas: motoristasEncontrados.slice(0, 10) // top 10
  };
});

12.3 Como usar no cliente (Flutter)

// Flutter
final callable = FirebaseFunctions.instance.httpsCallable('buscarMotoristasProximos');

final result = await callable.call({
  'lat': -23.5505,
  'lng': -46.6333,
  'raioKm': 5,
});

final motoristas = result.data['motoristas'] as List;

12.4 Como usar no cliente (JavaScript/React Native)

// JavaScript
import { getFunctions, httpsCallable } from 'firebase/functions';

const functions = getFunctions();
const buscarMotoristas = httpsCallable(functions, 'buscarMotoristasProximos');

const result = await buscarMotoristas({
  lat: -23.5505,
  lng: -46.6333,
  raioKm: 5
});

const motoristas = result.data.motoristas;
Observação importante sobre transações: Em um cenário real de matchmaking, é essencial usar transações do Firestore para evitar que dois passageiros recebam o mesmo motorista ao mesmo tempo. A função acima apenas busca; o aceite da corrida deve usar uma transação para garantir atomicidade[reference:12].

12.5 Função auxiliar: aceitar corrida com transação

exports.aceitarCorrida = functions.https.onCall(async (data, context) => {
  if (!context.auth) {
    throw new functions.https.HttpsError('unauthenticated', 'Faça login.');
  }

  const { corridaId } = data;
  const motoristaId = context.auth.uid;

  const corridaRef = db.collection('corridas').doc(corridaId);

  try {
    await db.runTransaction(async (transaction) => {
      const corridaDoc = await transaction.get(corridaRef);

      if (!corridaDoc.exists) {
        throw new Error('Corrida não encontrada.');
      }

      const corrida = corridaDoc.data();

      // Verificar se ainda está disponível
      if (corrida.status !== 'solicitada') {
        throw new Error('Esta corrida já foi aceita por outro motorista.');
      }

      if (corrida.motoristaId) {
        throw new Error('Esta corrida já tem motorista.');
      }

      // Aceitar a corrida atomicamente
      transaction.update(corridaRef, {
        motoristaId: motoristaId,
        status: 'aceita',
        aceitaEm: admin.firestore.FieldValue.serverTimestamp()
      });

      // Atualizar disponibilidade do motorista
      const motoristaRef = db.collection('motoristas').doc(motoristaId);
      transaction.update(motoristaRef, {
        disponivel: false
      });
    });

    return { sucesso: true, mensagem: 'Corrida aceita com sucesso!' };

  } catch (error) {
    throw new functions.https.HttpsError('failed-precondition', error.message);
  }
});

13. Resumo Final

Necessidade Serviço Recomendado
Cadastro de usuáriosFirestore + Firebase Auth
Corridas/pedidosFirestore
HistóricoFirestore
PagamentosFirestore
AvaliaçõesFirestore
ChatFirestore (subcoleção) ou Realtime DB
Localização em tempo realRealtime Database
Presença onlineRealtime Database
Match de motoristasCloud Functions + GeoFire
Notificações pushFCM
Mapas e rotasGoogle Maps SDK
Proteção contra abusoApp Check
Recomendação final: Comece com o plano Blaze, configure alertas de orçamento, use App Check desde o início e implemente regras de segurança restritivas antes de colocar o app em produção.