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.
usuariosArmazena 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
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
entregasSimilar 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
corridas/{corridaId}/mensagensChat entre passageiro e motorista durante a corrida.
corridas/{corridaId}/mensagens/{msgId}
├─ autorId: string (uid)
├─ texto: string
└─ enviadaEm: timestamp
pagamentosHistó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
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)
presenca/motoristas/{uid}Controla disponibilidade online/offline.
/presenca/motoristas/{uid}
├─ online: boolean
├─ disponivel: boolean (true se não estiver em corrida)
└─ ultimaAtividade: number
corridasAtivas/{corridaId}Espelho leve da corrida para leitura rápida em tempo real.
/corridasAtivas/{corridaId}
├─ motoristaId: string
├─ passageiroId: string
├─ status: string
└─ atualizadoEm: number
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.
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 |
O FCM é usado para:
O token FCM de cada usuário é salvo no documento usuarios/{uid}.fcmToken no Firestore.
O Firebase Auth gerencia:
O uid retornado é usado como chave em usuarios/{uid} no Firestore.
O Google Maps SDK (não é Firebase) é usado para:
O App Check do Firebase pode proteger as chamadas ao Maps SDK contra abuso.
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].
usuariosrules_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
}
}
}
corridasrules_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;
}
}
}
}
entregasrules_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))
);
}
}
}
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].
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 }
}
}
}
}
}
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 }
}
}
}
}
}
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'])"
}
}
}
}
.validate para garantir que os dados têm a estrutura correta. Qualquer campo não listado com $other é automaticamente rejeitado.
Cloud Functions não usa "regras" no mesmo formato que Firestore/RTDB. A segurança é feita por:
context.auth nas funções onCall.onCall seguraconst 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: [] };
});
context.auth em funções onCall.enforceAppCheck: true para bloquear tráfego não autorizado.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 |
| 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] |
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.
npm install geofire-common firebase-admin firebase-functions
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
};
});
// 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;
// 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;
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);
}
});
| Necessidade | Serviço Recomendado |
|---|---|
| Cadastro de usuários | Firestore + Firebase Auth |
| Corridas/pedidos | Firestore |
| Histórico | Firestore |
| Pagamentos | Firestore |
| Avaliações | Firestore |
| Chat | Firestore (subcoleção) ou Realtime DB |
| Localização em tempo real | Realtime Database |
| Presença online | Realtime Database |
| Match de motoristas | Cloud Functions + GeoFire |
| Notificações push | FCM |
| Mapas e rotas | Google Maps SDK |
| Proteção contra abuso | App Check |