Pular para o conteúdo
Challenge 18
Live
ARQUITETURAARQUITETURA DO SISTEMA

Modelagem de Banco de Dados · Frila

O teto de notificações (RN23) não é constraint: mora na função de despacho, que consulta `notificacao` antes de enviar. O resto do documento é, em boa medida, a defesa dessas nove linhas.

Equipe BlendOps
Cauê Carneiro, Fabrício Tosta, João Paulo, Júlia Clovandi, Matheus Silva
Data
Versão
v2.0
Contexto
FrilaChallenge 18 (Apple Developer Academy)

Preenche a Seção 6.2 do Documento de Requisitos, que descreve as dezoito entidades em tabela mas não traz o desenho nem o esquema físico. Aqui estão o diagrama, o DDL com as restrições que transformam regra de negócio em constraint, a máquina de estados que o código vai seguir, e o plano de migração para quando o esquema mudar.

Estado do projeto (30/09/2026)

O esquema está implementado no Supabase (Postgres com PostGIS, B02 e B06), em frila-backend/supabase/migrations/, na branch develop: 75 migrações até 30/09, cobertas por testes pgTAP. O DDL daqui é o desenho de referência: onde ele e uma migração divergirem, vale a migração, e Do desenho às migrações (30/09) lista o que o banco real tem a mais. Ainda não houve validação de campo, e toda premissa de comportamento de usuário carrega [H].

Atualizado em 22/09/2026 (rodada 2 das pendências para codar) e revisado em 30/09/2026

Cada conta tem um perfil só, profissional ou contratante, fixado em usuario.perfil (RN25). A entrada é por código enviado ao e-mail, sem senha e sem SMS. O telefone continua obrigatório, mas sem verificação e sem unicidade. O "disponível agora" saiu: a disponibilidade é só a grade semanal. E as políticas de acesso de cada tabela estão escritas em Políticas de acesso (RLS).


O que o esquema precisa garantir

Um modelo de dados é bom quando as regras que não podem ser violadas moram nele, e não na aplicação que o chama. Aplicação tem bug, tem corrida entre requisições, tem deploy pela metade. O banco é o último lugar onde uma regra ainda vale — e com três clientes nativos diferentes (iOS em Swift, Android em Kotlin e web) chamando o mesmo backend, é também o único lugar onde a regra vale uma vez só.

Nove das vinte e cinco regras de negócio são dessa natureza. Se falharem uma vez, destroem a confiança que o produto existe para construir:

RegraO que exigeOnde vive no esquema
RN19Uma posição nunca confirmada para dois profissionaisUPDATE condicional + CHECK de coerência em posicao
RN21Um profissional nunca com dois turnos confirmados que se sobrepõemEXCLUDE USING gist em posicao
RN18Dinheiro em centavos inteiros, tempo em UTCBIGINT e timestamptz em toda coluna monetária e temporal
RN20Cadastro só para maiores de 18CHECK sobre nascimento em usuario
RN25Cada conta com um perfil só, escolhido no cadastro e sem trocausuario.perfil travado por trigger + chave estrangeira composta em profissional e membro_estabelecimento
RN02Vaga sem função, horário, endereço, valor, o que está incluso ou quem recebe no local não existeNOT NULL nas colunas obrigatórias de vaga
RN24Modo seleção só para vaga que começa em mais de 24 horasCHECK em vaga
RN22Check-in geolocalizado vale até 200 m; manual só conta confirmadoCHECK de coerência entre o registro e a verificacao em turno
RN07Avaliação binária, bidirecional, só depois do fim previsto e com presença verificadaUNIQUE (turno_id, alvo_tipo), um voto por lado do turno, + trigger que confere horário e presença

O teto de notificações (RN23) não é constraint: mora na função de despacho, que consulta notificacao antes de enviar. O resto do documento é, em boa medida, a defesa dessas nove linhas.


O modelo em três vistas

Dezoito entidades num desenho só viram emaranhado. São três recortes do mesmo esquema, cada um respondendo a uma pergunta.

O ciclo de uma vaga

O ciclo de uma vaga, da publicação à avaliação
O ciclo de uma vaga, da publicação à avaliação

O eixo é uma cadeia só — vaga → posicao → turno → avaliacao —, o ciclo de vida de uma unidade de trabalho da publicação à reputação. despacho, notificacao e candidatura penduram-se nela como o registro de quem foi avisado e quem respondeu.

Uma conta, um perfil

Cada conta com um perfil: a de profissional tem o registro de profissional, a de contratante participa do estabelecimento
Cada conta com um perfil: a de profissional tem o registro de profissional, a de contratante participa do estabelecimento

O que decide quem recebe a notificação

Função, disponibilidade, distância e equipe de confiança em torno do profissional
Função, disponibilidade, distância e equipe de confiança em torno do profissional


As tabelas

Identidade e perfis

usuario é a conta de acesso, e cada conta tem um perfil só (RN25, 22/09): profissional ou contratante, escolhido no cadastro e sem troca depois. A conta de profissional ganha uma linha em profissional; a de contratante opera um ou mais estabelecimentos por membro_estabelecimento. Quem é garçom num fim de semana e opera o cadastro do buffet do cunhado no outro tem duas contas, com dois e-mails — o telefone pode ser o mesmo. A conta continua separada do papel porque o histórico do estabelecimento precisa sobreviver à troca de responsável (RF21).

CREATE TYPE estado_conta AS ENUM ('ativa', 'suspensa', 'anonimizada');
CREATE TYPE perfil_conta AS ENUM ('profissional', 'contratante');

CREATE TABLE usuario (
  -- usuario.id é o id da conta no Supabase Auth, gravado por criar_conta.
  -- Sem chave estrangeira para auth.users: ver a exclusão, abaixo.
  id              uuid PRIMARY KEY,
  perfil          perfil_conta NOT NULL,  -- RN25: escolhido no cadastro, nunca muda
  nome            text        NOT NULL,
  telefone        text,                   -- contato do turno (RN10); nulo só depois de anonimizado
  email           citext,                 -- copiado de auth.users; nulo só depois de anonimizado
  nascimento      date,                   -- nulo só depois de anonimizado (limpeza dos 15 dias)
  estado          estado_conta NOT NULL DEFAULT 'ativa',
  criado_em       timestamptz NOT NULL DEFAULT now(),
  anonimizado_em  timestamptz,

  -- RN20: maioridade verificada na escrita, não na tela.
  CONSTRAINT maior_de_idade
    CHECK (nascimento <= (CURRENT_DATE - INTERVAL '18 years')),
  CONSTRAINT nascimento_ate_anonimizar
    CHECK (estado = 'anonimizada' OR nascimento IS NOT NULL),
  CONSTRAINT anonimizacao_coerente
    CHECK ((estado = 'anonimizada') = (anonimizado_em IS NOT NULL)),
  CONSTRAINT email_ate_anonimizar
    CHECK (estado = 'anonimizada' OR email IS NOT NULL),
  -- Telefone obrigatório enquanto a conta existe; só o formato é conferido (E.164).
  CONSTRAINT telefone_ate_anonimizar
    CHECK (estado = 'anonimizada' OR telefone IS NOT NULL),
  CONSTRAINT telefone_e164
    CHECK (telefone ~ '^\+[1-9][0-9]{7,14}$'),
  -- Alvo das chaves estrangeiras de profissional e membro_estabelecimento (RN25).
  CONSTRAINT usuario_id_perfil UNIQUE (id, perfil)
);

CREATE UNIQUE INDEX usuario_email_ativo
  ON usuario (email) WHERE estado <> 'anonimizada';

-- RN25: o perfil da conta não muda depois do cadastro, nem por dentro de uma função.
CREATE FUNCTION perfil_imutavel() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'o perfil da conta não muda (RN25)' USING ERRCODE = 'check_violation';
END $$;

CREATE TRIGGER usuario_perfil_imutavel
  BEFORE UPDATE OF perfil ON usuario
  FOR EACH ROW WHEN (NEW.perfil IS DISTINCT FROM OLD.perfil)
  EXECUTE FUNCTION perfil_imutavel();

O índice único é parcial de propósito. RF25 manda anonimizar em vez de apagar, para preservar o histórico da contraparte; se a unicidade fosse total, o e-mail de uma conta encerrada bloquearia para sempre quem quisesse voltar com o mesmo endereço.

nascimento substitui o maioridade_confirmada: boolean da tabela original do Documento de Requisitos. Um booleano que o cliente envia não é verificação de nada — é a tela dizendo ao banco aquilo que a tela quis. Com a data, a restrição é verificável e o CHECK faz o trabalho.

perfil é RN25 escrita em SQL, em três peças. criar_conta grava o perfil escolhido no cadastro, e o trigger usuario_perfil_imutavel recusa qualquer mudança depois. UNIQUE (id, perfil) existe para servir de alvo: profissional e membro_estabelecimento repetem o perfil numa coluna constante e apontam para o par (id, perfil). Com isso, uma conta de contratante não consegue ter linha em profissional, nem uma conta de profissional entrar num estabelecimento — nem por bug de função. As funções conferem antes e respondem 422 perfil_incompativel: criar_perfil_profissional com conta de contratante e cadastrar_estabelecimento com conta de profissional; o aceite de convite de membro fará o mesmo quando o convite entrar, na v1.2 (RF21). A chave estrangeira é a última linha de defesa.

O telefone é obrigatório porque é o contato que as partes veem depois da confirmação (RN10), e o app não tem chat. Mas não é verificado, porque não há SMS no Frila, e não é único: a mesma pessoa pode ter as duas contas com o mesmo número (RN25). O CHECK telefone_e164 confere só o formato. O e-mail continua único entre as contas ativas, porque é ele que identifica a conta na entrada.

Não há senha_hash porque não há senha. A entrada é pelo Supabase Auth, com um código de uso único enviado ao e-mail (22/09), e nenhuma credencial passa por tabela do produto. usuario.id é o id da conta de autenticação, gravado por criar_conta a partir de auth.uid(), mas sem chave estrangeira para auth.users, e isso é de propósito: a exclusão de conta apaga o registro de autenticação e mantém a linha de usuario anonimizada (ver LGPD no esquema). Com a chave, o Supabase recusaria apagar o registro de uma conta que ainda tem histórico.

CREATE TABLE profissional (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  usuario_id          uuid NOT NULL UNIQUE,
  perfil              perfil_conta NOT NULL DEFAULT 'profissional'
                        CHECK (perfil = 'profissional'),       -- RN25, com a chave abaixo
  ponto_base          geography(Point, 4326) NOT NULL,
  -- Desnormalizações de leitura. Verdade em turno/avaliacao; ver §Reputação.
  taxa_comparecimento numeric(4,3) CHECK (taxa_comparecimento BETWEEN 0 AND 1),
  turnos_realizados   integer NOT NULL DEFAULT 0,
  aval_positivas      integer NOT NULL DEFAULT 0,
  aval_total          integer NOT NULL DEFAULT 0,
  CONSTRAINT aval_coerente CHECK (aval_positivas <= aval_total),
  CONSTRAINT so_conta_de_profissional
    FOREIGN KEY (usuario_id, perfil) REFERENCES usuario (id, perfil)
);

Não existe raio_km. Desde 21/09 o profissional não declara raio (B07): a distância que decide a notificação é do sistema — 15 km do ponto_base até o local da vaga —, e a lista mostra todas as vagas do DF, das mais próximas para as mais distantes.

taxa_comparecimento é nula até existir histórico, e isso é deliberado. RF16 exige que perfil sem histórico apareça como sem histórico, e não como nota zero — 0.0 e NULL contam histórias opostas sobre alguém que acabou de chegar. O NULL obriga quem lê a decidir o que exibir.

aval_positivas e aval_total contam as avaliações binárias recebidas no Frila ("chamaria de novo?") e existem para atender RN08, que manda sempre mostrar o denominador. Guardar só o percentual tornaria "7 de 7" irrecuperável, e é exatamente o denominador que separa confiança real de amostra pequena. Não há aval de fora da plataforma: ele saiu do produto em 21/09 (A12).

Estabelecimento e equipe

CREATE TYPE tipo_estabelecimento AS ENUM
  ('food_service', 'evento', 'varejo', 'logistica', 'servico_domestico', 'outro');
CREATE TYPE papel_membro        AS ENUM ('administrador', 'operador');

CREATE TABLE estabelecimento (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  nome       text NOT NULL,
  documento  text NOT NULL UNIQUE,          -- CNPJ ou CPF, só dígitos
  tipo       tipo_estabelecimento NOT NULL,
  endereco   text NOT NULL,
  ponto      geography(Point, 4326) NOT NULL,
  criado_em  timestamptz NOT NULL DEFAULT now(),
  aval_positivas integer NOT NULL DEFAULT 0,
  aval_total     integer NOT NULL DEFAULT 0
);

CREATE TABLE membro_estabelecimento (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  usuario_id         uuid NOT NULL,
  perfil             perfil_conta NOT NULL DEFAULT 'contratante'
                       CHECK (perfil = 'contratante'),        -- RN25, com a chave abaixo
  estabelecimento_id uuid NOT NULL REFERENCES estabelecimento(id),
  papel              papel_membro NOT NULL,
  criado_em          timestamptz NOT NULL DEFAULT now(),
  UNIQUE (usuario_id, estabelecimento_id),
  CONSTRAINT so_conta_de_contratante
    FOREIGN KEY (usuario_id, perfil) REFERENCES usuario (id, perfil)
);

CREATE TABLE equipe_confianca (
  estabelecimento_id uuid NOT NULL REFERENCES estabelecimento(id),
  profissional_id    uuid NOT NULL REFERENCES profissional(id),
  adicionado_em      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (estabelecimento_id, profissional_id)
);

A plataforma é horizontal (A01, A15): qualquer setor publica turno avulso, e tipo serve para leitura e filtro, não para barrar ninguém — 'outro' existe para que nenhum negócio fique de fora por falta de categoria. documento aceita CPF porque serviço doméstico também contrata pelo Frila.

O estabelecimento tem reputação própria porque RN07 manda avaliar nos dois sentidos. Essa é a correção de assimetria que o produto usa contra o setor inteiro: em todo concorrente pesquisado só o contratante avalia, e as piores notas vêm de quem trabalha.

equipe_confianca não muda a ordem de ninguém — não existe ordem. Ela muda quem recebe: o profissional da equipe é notificado das vagas daquele estabelecimento mesmo além dos 15 km, desde que tenha a função e esteja disponível (RF18).

Catálogo de funções

CREATE TABLE funcao (
  id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  nome      text NOT NULL UNIQUE,
  categoria text NOT NULL,
  ativo     boolean NOT NULL DEFAULT true
);

CREATE TABLE profissional_funcao (
  profissional_id uuid NOT NULL REFERENCES profissional(id) ON DELETE CASCADE,
  funcao_id       uuid NOT NULL REFERENCES funcao(id),
  PRIMARY KEY (profissional_id, funcao_id)
);

Função é catálogo fechado, não texto livre. Se o profissional digita "garçom", "garcom" e "Garçonete", a elegibilidade de RN05 vira busca por aproximação — e notificar quem não é elegível é o erro que mata o canal de notificação, que é o produto.

O catálogo inicial sai dos setores mapeados no Documento de Visão e no 02-O-NEGOCIO. É dado de partida, não esquema, e precisa ser conferido em campo [H]:

CategoriaFunções
Salãogarçom · garçonete · maître · hostess · runner · cumim
Barbartender · barista · auxiliar de bar
Cozinhachapeiro · pizzaiolo · auxiliar de cozinha · copeiro · confeiteiro
Eventomontador · desmontador · recepcionista · credenciamento · segurança de sala
Apoiolimpeza pós-evento · carregador · manobrista · estoquista
Varejovendedor extra · promotor de degustação · empacotador · repositor de gôndola · inventariante
Logísticachapa (carga e descarga) · separador de pedidos · etiquetador · conferente auxiliar

Disponibilidade

CREATE TABLE disponibilidade (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  profissional_id uuid NOT NULL REFERENCES profissional(id) ON DELETE CASCADE,
  dia_semana      smallint NOT NULL CHECK (dia_semana BETWEEN 0 AND 6),
  hora_inicio     time NOT NULL,
  hora_fim        time NOT NULL,
  CONSTRAINT janela_nao_vazia CHECK (hora_inicio <> hora_fim),
  UNIQUE (profissional_id, dia_semana, hora_inicio, hora_fim)
);

A restrição não exige hora_fim > hora_inicio. Um bar fecha às 2h; a janela 18:00–02:00 é a mais comum do setor, não uma exceção. Quem escreve CHECK (hora_fim > hora_inicio) por reflexo exclui do produto justamente o turno que ele existe para preencher.

A grade semanal é a única fonte de disponibilidade: o profissional recebe notificação da vaga quando uma das janelas dele cobre o turno inteiro, do início ao fim (decisão de 28/09; antes bastava o início cair na janela). O botão "disponível agora", que abria uma exceção por algumas horas, saiu do produto em 22/09, e com ele a coluna disponivel_agora_ate.

Vaga, posição e evento

CREATE TYPE modo_preenchimento AS ENUM ('urgencia', 'selecao');
CREATE TYPE estado_vaga        AS ENUM ('publicada', 'preenchida', 'encerrada', 'cancelada');
CREATE TYPE estado_posicao     AS ENUM ('aberta', 'confirmada', 'cumprida', 'cancelada');

CREATE TABLE evento (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  estabelecimento_id uuid NOT NULL REFERENCES estabelecimento(id),
  nome               text NOT NULL,
  data               date NOT NULL,
  local              text NOT NULL
);

CREATE TABLE vaga (
  id                     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  estabelecimento_id     uuid NOT NULL REFERENCES estabelecimento(id),
  evento_id              uuid REFERENCES evento(id),
  funcao_id              uuid NOT NULL REFERENCES funcao(id),
  inicio_em              timestamptz NOT NULL,
  fim_em                 timestamptz NOT NULL,
  local                  text NOT NULL,                    -- endereço
  ponto                  geography(Point, 4326) NOT NULL,
  valor_centavos         bigint NOT NULL CHECK (valor_centavos > 0),
  posicoes               smallint NOT NULL CHECK (posicoes > 0),
  -- O que está incluso (RN02): sim ou não, sem "não informado".
  inclui_refeicao        boolean NOT NULL,
  inclui_transporte      boolean NOT NULL,
  exige_material_proprio boolean NOT NULL,                 -- sim = o profissional leva o material
  responsavel_local      text NOT NULL,                    -- quem recebe o profissional no local
  -- Opcionais.
  traje                  text,
  participa_rateio       boolean,                          -- 10% da taxa de serviço (Lei 13.419/2017)
  observacoes            text,
  modo                   modo_preenchimento NOT NULL,
  alerta_antecedencia    interval NOT NULL DEFAULT '3 hours',  -- janela crítica (RF20)
  estado                 estado_vaga NOT NULL DEFAULT 'publicada',
  publicado_em           timestamptz NOT NULL DEFAULT now(),
  chave_cliente          uuid NOT NULL,                    -- idempotência da publicação

  CONSTRAINT publicacao_unica UNIQUE (estabelecimento_id, chave_cliente),

  CONSTRAINT turno_tem_duracao CHECK (fim_em > inicio_em),
  CONSTRAINT alerta_positivo   CHECK (alerta_antecedencia > INTERVAL '0'),
  -- RN24: modo seleção só para vaga que começa em mais de 24 horas.
  CONSTRAINT selecao_com_antecedencia CHECK (
    modo <> 'selecao' OR inicio_em > publicado_em + INTERVAL '24 hours')
);

CREATE TABLE posicao (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  vaga_id         uuid NOT NULL REFERENCES vaga(id) ON DELETE CASCADE,
  estado          estado_posicao NOT NULL DEFAULT 'aberta',
  profissional_id uuid REFERENCES profissional(id),
  confirmado_em   timestamptz,
  falta           boolean NOT NULL DEFAULT false,   -- ver §Reputação
  reaberta_por_atraso_de uuid REFERENCES posicao(id), -- a posição que faltou (contrato 0.2.19)
  -- Desnormalizado de vaga para a constraint de sobreposição abaixo.
  inicio_em       timestamptz NOT NULL,
  fim_em          timestamptz NOT NULL,

  CONSTRAINT confirmacao_coerente CHECK (
    estado NOT IN ('confirmada','cumprida')
      OR (profissional_id IS NOT NULL AND confirmado_em IS NOT NULL)
  ),
  CONSTRAINT aberta_sem_profissional CHECK (
    estado <> 'aberta' OR profissional_id IS NULL
  ),
  CONSTRAINT falta_so_em_cancelada CHECK (NOT falta OR estado = 'cancelada')
);

As colunas NOT NULL de vaga — função, início, fim, endereço, valor, número de posições, o que está incluso, quem recebe no local, e o estabelecimento que publica — são RN02 escrita em SQL. posicoes é o número pedido na publicação; a função que publica cria uma linha de posicao por unidade. O Documento de Requisitos justifica a regra com avaliações de concorrentes em que o profissional chega ao local "sem muita informação"; aqui, uma vaga incompleta simplesmente não entra. Traje, rateio e observações são opcionais, e a lista pode ser ajustada depois.

Os três campos do que está incluso são boolean NOT NULL, e não texto: marcar sim ou não é um toque cada, mantém a publicação curta e deixa duas vagas do mesmo valor comparáveis — com e sem refeição não são a mesma diária.

alerta_antecedencia é a janela crítica de cada vaga: quanto tempo antes do início, com a posição ainda vaga, o contratante recebe o alerta por notificação. O padrão é 3 horas, e o contratante muda na publicação (B18).

selecao_com_antecedencia é RN24. Como o modo seleção fecha sozinho 24 horas antes do início, uma vaga de seleção que começa em menos de 24 horas nasceria fechada — então ela nem entra. O fechamento em si é trabalho do agendador, não de constraint: a 24 horas do início, a vaga de seleção sem escolha passa a encerrada e os candidatos são avisados e liberados.

valor_centavos bigint é RN18. Centavo inteiro não acumula erro de arredondamento, e bigint não estoura em escala de evento — quarenta posições de uma formatura somadas ainda cabem com folga.

evento_id é nulo porque a maioria das vagas é avulsa. Só a escala em lote de RF19 agrupa, e forçar todo turno urgente de sexta-feira a inventar um evento seria burocracia inventada pelo esquema.

posicao mantém o profissional_id depois do cancelamento: é ele que diz de quem foi a falta e quem cancelou, o que RN12 exige registrar e a taxa de comparecimento precisa ler.


A confirmação, que é onde o produto quebra se errar

RN19 diz que uma posição não pode ser confirmada para mais de um profissional, "mesmo sob candidaturas simultâneas". O CHECK acima garante coerência de estado, mas não resolve a corrida: duas requisições podem ler estado = 'aberta' no mesmo instante e escrever uma por cima da outra.

A defesa é fazer a própria escrita ser a verificação:

UPDATE posicao p
   SET estado = 'confirmada',
       profissional_id = $1,
       confirmado_em = now()
 WHERE p.id = (SELECT x.id FROM posicao x
                WHERE x.vaga_id = $2 AND x.estado = 'aberta'
                ORDER BY x.id
                FOR UPDATE SKIP LOCKED   -- cada candidato tenta uma linha livre
                LIMIT 1)
RETURNING p.id;                          -- nenhuma linha: todas já foram ocupadas

Se nenhuma linha volta, outros profissionais chegaram antes — e o último recebe "posição já preenchida" (409), não uma confirmação falsa. O SKIP LOCKED separa vinte candidatos disputando a mesma linha de vinte candidatos pegando linhas diferentes: sem ele, dezenove esperariam o commit do primeiro só para descobrir que perderam. É uma escrita só, e é ela que impede o modo de falha descrito na justificativa da regra: alguém que se desloca até o local sem ter trabalho. No Supabase, ela vive na função RPC candidatar (e em escolher_candidato, no modo seleção), que os clientes chamam: a regra é escrita uma vez, não três.

Turnos sobrepostos

A regra entrou nos requisitos como RN21 em 21/09 (D1, aprovada na B04). Sem ela, o mesmo profissional seria confirmado para dois turnos que se cruzam: aceitaria os dois de boa-fé e faltaria a um — desabando a própria taxa de comparecimento por um buraco do sistema, não por comportamento. Com inicio_em e fim_em desnormalizados em posicao, a proibição é declarativa:

CREATE EXTENSION IF NOT EXISTS btree_gist;   -- igualdade de uuid dentro do índice GIST

ALTER TABLE posicao ADD CONSTRAINT sem_turno_sobreposto
  EXCLUDE USING gist (
    profissional_id WITH =,
    tstzrange(inicio_em, fim_em) WITH &&
  ) WHERE (estado IN ('confirmada','cumprida'));

A desnormalização tem um custo: quando o horário da vaga muda, as posições precisam acompanhar. Isso só acontece antes de haver confirmação, e um trigger de duas linhas resolve.


Os estados de uma posição

Máquina de estados: aberta, confirmada, cumprida e cancelada
Máquina de estados: aberta, confirmada, cumprida e cancelada

Quatro estados, e nenhum a mais. A tentação de criar um estado reservada — entre a candidatura e a confirmação — aparece em quase todo marketplace, e aqui seria um erro: no modo urgência o primeiro aprovado leva, e no modo seleção a posição fica aberta até a escolha do contratante. Nenhum dos dois comportamentos precisa de estado intermediário, e cada estado a mais é um caminho a mais pelo qual RN19 pode falhar.

A presença não é estado da posição: é atributo do turno (verificacao, abaixo). Um turno "não verificado" é uma posição cumprida cujo turno terminou com check-in manual não confirmado, com verificacao = 'nao_verificado' — ele existe, mas não conta a favor nem contra ninguém. O turno que termina sem check-in nenhum não entra aqui: desde a decisão de 28/09, a posição é cancelada com falta.

Transições válidas, e só elas:

DeParaQuandoEfeito
abertaconfirmadaUPDATE condicional bem-sucedidoLibera contato (RN10), cria turno
confirmadacumpridaO turno termina com check-in registradoLibera avaliação se a presença foi verificada (RN07, RN22)
confirmadacanceladaCancelamento com motivo (RN12), inclusive a reabertura por atraso, a partir de 15 minutos sem check-inAntes do início, a vaga ganha uma posição nova e notifica de novo. Na reabertura por atraso, a posição nova aceita candidatura até 1 hora antes do fim (contrato 0.2.19). Depois do início, fora disso, não há reabertura. Com menos de 24 horas ou por não comparecimento, marca falta
confirmadacanceladaO turno termina sem check-in nenhum (agendador, decisão de 28/09)Marca falta e o turno como nao_verificado; não reabre
abertacanceladaVaga cancelada inteira; modo seleção fechado 24 horas antes com a posição sem escolha (RN24); posição reaberta por atraso que ninguém pegou até 1 hora antes do fimEncerra o despacho e libera os candidatos

O cancelamento não volta a posição para aberta: cria uma nova. Preservar a linha cancelada é o que mantém a auditoria de RN12 — autor, momento e motivo — e o que permite distinguir a posição que ninguém quis da que alguém aceitou e largou.


O caminho quente: quem recebe a notificação

RNF03 exige a notificação enviada ao provedor em até 30 segundos após a publicação, e RN05 proíbe notificar inelegível. Toda a tese do produto passa por esta consulta:

-- Elegíveis para uma vaga (RN05). O teto e o agrupamento (RN23) vêm
-- depois, na função que decide quando cada notificação sai. No banco, é
-- privado.elegiveis (20260929160100_elegiveis_ordem_de_filtro.sql), que
-- parte da função e do raio, os filtros mais seletivos, e só então aplica
-- os caros. janelas_da_grade(p, $inicio) resume as janelas da grade semanal
-- de p convertidas em intervalos reais no fuso America/Sao_Paulo, inclusive
-- a que começou na véspera e vira a noite.
SELECT p.id
  FROM profissional p
  JOIN usuario u ON u.id = p.usuario_id AND u.estado = 'ativa'
  JOIN profissional_funcao pf
    ON pf.profissional_id = p.id AND pf.funcao_id = $funcao        -- 1. a função
 WHERE (
         ST_DWithin(p.ponto_base, $ponto_vaga, 15000)              -- 2. até 15 km
      OR EXISTS (SELECT 1 FROM equipe_confianca e                  --    ou a equipe, mesmo além
                  WHERE e.estabelecimento_id = $estab AND e.profissional_id = p.id)
       )
   AND EXISTS (SELECT 1 FROM janelas_da_grade(p.id, $inicio) j     -- 3. a grade cobre
                WHERE j @> tstzrange($inicio, $fim))               --    o turno inteiro
   AND NOT privado.bloqueado_com_estabelecimento(p.usuario_id, $estab)   -- 4. RF26
   AND NOT EXISTS (SELECT 1 FROM posicao x                         -- 5. RN21
                    WHERE x.profissional_id = p.id
                      AND x.estado IN ('confirmada','cumprida')
                      AND tstzrange(x.inicio_em, x.fim_em) && tstzrange($inicio, $fim))
   AND NOT EXISTS (SELECT 1 FROM posicao x                         -- 6. RN12: quem faltou nesta
                    WHERE x.vaga_id = $vaga AND x.profissional_id = p.id       -- vaga, ou já
                      AND (x.falta OR x.estado IN ('confirmada','cumprida')))  -- trabalha nela
   AND NOT EXISTS (SELECT 1 FROM despacho x                        -- 7. um despacho por rodada
                    WHERE x.vaga_id = $vaga AND x.profissional_id = p.id
                      AND x.rodada = $rodada);

A grade precisa cobrir o turno inteiro, e não só o início (decisão de 28/09): quem marcou sexta das 18:00 às 02:00 recebe a vaga das 19h à 1h, mas não a das 23h às 4h, que passa do horário em que ele disse que pode trabalhar. A janela que vira a noite entra pelos dois lados: a vaga da 0h30 à 1h30 de sábado também cabe na janela que começou na sexta. Um BETWEEN simples sobre a hora perderia esse caso, que é o mais comum do setor.

Três observações decidem se isso responde em 30 segundos:

A distância é do sistema, não do profissional. Com 15 km fixos, ST_DWithin(p.ponto_base, $ponto, 15000) usa o índice GIST direto — o pré-filtro de 100 km, que existia porque o raio variava por linha, deixou de ser necessário. Os 15 km são parâmetro do sistema, valor inicial para ajustar com o dado do piloto [H].

Não há ORDER BY, e isso é RN06 inteira. A notificação sai de uma vez para todos os elegíveis; a taxa de comparecimento aparece no perfil e não altera quem recebe. Não há coluna de patrocínio, impulsionamento ou prioridade paga em lugar nenhum do esquema — a regra diz que notificação e posição na lista não podem ser compradas, e o jeito de garantir isso é não existir onde guardar o que foi comprado.

Quem acaba de chegar recebe junto com quem tem cem turnos. Sem ordem, profissional sem histórico (taxa_comparecimento IS NULL) não fica para trás por acidente de implementação: se tem a função, está disponível e está perto, é notificado ao mesmo tempo que os outros.

Depois da consulta vem o teto (RN23). Para cada elegível, a função de despacho olha a última notificacao dele: se saiu há menos de 30 minutos, a vaga espera e entra na próxima, agrupada com as outras ("4 vagas novas perto de você"); a vaga do modo urgência que começa em menos de 2 horas fura o agrupamento — sai na hora, sozinha — e conta no teto.

CREATE INDEX profissional_ponto     ON profissional USING gist (ponto_base);
CREATE INDEX disponibilidade_busca  ON disponibilidade (profissional_id, dia_semana);
CREATE INDEX despacho_vaga          ON despacho (vaga_id);
CREATE INDEX notificacao_teto       ON notificacao (profissional_id, enviada_em DESC)
  WHERE tipo IN ('vaga', 'vagas_agrupadas');
CREATE INDEX posicao_abertas        ON posicao (vaga_id) WHERE estado = 'aberta';
CREATE INDEX vaga_janela_critica    ON vaga (inicio_em) WHERE estado = 'publicada';

notificacao_teto responde à pergunta que o teto faz a cada envio: quando foi a última notificação de vaga desta pessoa? Desde o cartão #146 a tabela guarda todo aviso do produto, para qualquer conta; profissional_id só existe nas de vaga (CHECK notificacao_profissional_so_de_vaga), e é por isso que um lembrete ou uma confirmação nunca entram na conta do teto. Os tipos agendados (lembretes, atraso, fim sem check-out, vaga vazia, avaliação disponível) têm um índice único parcial em (tipo, referencia_id, usuario_id), que é a marca de envio: o agendador pode rodar de novo sem avisar duas vezes. vaga_janela_critica é o índice do alerta de vaga vazia (RF20): o agendador varre as vagas ainda publicadas cujo início, menos a alerta_antecedencia, já chegou, e avisa o contratante. O índice parcial mantém pequeno o conjunto que interessa.


Despacho, notificação, candidatura, turno e ocorrência

CREATE TYPE estado_entrega     AS ENUM ('pendente','enviada','entregue','falhou');
CREATE TYPE estado_candidatura AS ENUM ('pendente','aceita','recusada','retirada','expirada');
CREATE TYPE tipo_registro      AS ENUM ('geolocalizado','manual');
CREATE TYPE verificacao_turno  AS ENUM ('pendente','verificado','nao_verificado');
CREATE TYPE tipo_ocorrencia    AS ENUM
  ('cancelamento','suspensao','contestacao','suporte','denuncia','revisao_despacho');
CREATE TYPE plataforma         AS ENUM ('ios','android','web');

CREATE TABLE dispositivo (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  usuario_id      uuid NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
  token_fcm       text NOT NULL UNIQUE,             -- um aparelho, um token
  plataforma      plataforma NOT NULL,
  atualizado_em   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE notificacao (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  usuario_id      uuid NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,  -- o destinatário
  tipo            tipo_notificacao NOT NULL,   -- os 18 avisos do produto
  referencia_id   uuid NOT NULL,               -- a vaga, a posição ou o turno do aviso
  payload         jsonb NOT NULL DEFAULT '{}', -- o destino do toque: tipo e ids (RN15)
  profissional_id uuid REFERENCES profissional(id),  -- só nas de vaga, para o teto
  tentativas      int NOT NULL DEFAULT 0 CHECK (tentativas >= 0),
  aceita_em       timestamptz,
  enviada_em      timestamptz NOT NULL DEFAULT now(),
  urgente         boolean NOT NULL DEFAULT false,   -- furou o agrupamento (RN23)
  estado_entrega  estado_entrega NOT NULL DEFAULT 'pendente',
  entregue_em     timestamptz,
  motivo_falha    text
);

CREATE TABLE despacho (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  vaga_id         uuid NOT NULL REFERENCES vaga(id) ON DELETE CASCADE,
  profissional_id uuid NOT NULL REFERENCES profissional(id),
  notificacao_id  uuid REFERENCES notificacao(id),  -- nulo enquanto espera o teto
  rodada          integer NOT NULL DEFAULT 1 CHECK (rodada >= 1),  -- sobe na reabertura por atraso
  criado_em       timestamptz NOT NULL DEFAULT now(),
  UNIQUE (vaga_id, profissional_id, rodada)  -- RN05: ninguém recebe a mesma vaga duas vezes na mesma rodada
);

CREATE TABLE candidatura (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  posicao_id      uuid NOT NULL REFERENCES posicao(id) ON DELETE CASCADE,
  profissional_id uuid NOT NULL REFERENCES profissional(id),
  criada_em       timestamptz NOT NULL DEFAULT now(),
  estado          estado_candidatura NOT NULL DEFAULT 'pendente',
  UNIQUE (posicao_id, profissional_id)
);

CREATE TABLE turno (
  id                      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  posicao_id              uuid NOT NULL UNIQUE REFERENCES posicao(id),
  checkin_em              timestamptz,
  checkin_tipo            tipo_registro,
  checkin_distancia_m     integer CHECK (checkin_distancia_m >= 0),        -- medida no toque
  checkin_confirmado_em   timestamptz,                                     -- contratante, só no manual
  checkout_em             timestamptz,
  checkout_distancia_m    integer CHECK (checkout_distancia_m >= 0),  -- sem limite (28/09); nulo sem localização
  verificacao             verificacao_turno NOT NULL DEFAULT 'pendente',
  valor_acordado_centavos bigint NOT NULL CHECK (valor_acordado_centavos > 0),

  -- RN22: o geolocalizado só vale a até 200 m do endereço da vaga.
  CONSTRAINT checkin_no_raio CHECK (
    checkin_tipo IS DISTINCT FROM 'geolocalizado'
      OR (checkin_distancia_m IS NOT NULL AND checkin_distancia_m <= 200)),
  CONSTRAINT confirmacao_so_no_manual CHECK (
    checkin_confirmado_em IS NULL OR checkin_tipo = 'manual'),
  -- 'verificado' só com prova: geolocalizado, ou manual confirmado pelo contratante.
  CONSTRAINT verificacao_coerente CHECK (
    (verificacao = 'verificado') = COALESCE(
      checkin_tipo = 'geolocalizado'
      OR (checkin_tipo = 'manual' AND checkin_confirmado_em IS NOT NULL), false))
);

CREATE TABLE avaliacao (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  turno_id   uuid NOT NULL REFERENCES turno(id) ON DELETE CASCADE,
  autor_id   uuid NOT NULL REFERENCES usuario(id),
  alvo_tipo  text NOT NULL CHECK (alvo_tipo IN ('profissional','estabelecimento')),
  alvo_id    uuid NOT NULL,
  resposta   boolean NOT NULL,          -- RN07: binária. Nunca 1 a 5.
  criada_em  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT um_voto_por_lado UNIQUE (turno_id, alvo_tipo)   -- contrato 0.2.12
);

CREATE TABLE bloqueio (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  autor_id     uuid NOT NULL REFERENCES usuario(id),     -- quem bloqueou, de qualquer lado
  bloqueado_id uuid NOT NULL REFERENCES usuario(id),
  criado_em    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (autor_id, bloqueado_id),
  CONSTRAINT nao_bloqueia_a_si CHECK (autor_id <> bloqueado_id)
);

CREATE TABLE ocorrencia (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tipo         tipo_ocorrencia NOT NULL,
  posicao_id   uuid REFERENCES posicao(id),
  turno_id     uuid REFERENCES turno(id),
  usuario_id   uuid REFERENCES usuario(id),        -- alvo, quando houver
  autor_id     uuid NOT NULL REFERENCES usuario(id),
  motivo       text NOT NULL,                      -- RN12 e RN13: nunca vazio
  criada_em    timestamptz NOT NULL DEFAULT now(),
  resultado    text,
  resolvido_em timestamptz,
  chave_cliente uuid,                              -- idempotência da denúncia
  UNIQUE (autor_id, chave_cliente)
);

dispositivo guarda o token de push de cada aparelho, que o app registra ao abrir e sempre que o token muda. Uma pessoa pode ter mais de um aparelho; o token é único. Quem negou a permissão de notificação não tem linha aqui e, para o despacho, não é alcançável. Na exclusão de conta, os dispositivos vão junto (RN15).

chave_cliente torna seguras as duas escritas que não têm chave natural: publicar uma vaga e denunciar. O app gera a chave uma vez por ação; se a rede cair e ele reenviar, o UNIQUE devolve a vaga ou a denúncia já gravada em vez de criar outra. As demais escritas já são idempotentes pela chave natural — candidatura por vaga e profissional, check-in por turno, avaliação por turno e lado, bloqueio por par. O contrato está em api/openapi.yaml, no repositório frila-docs (versão 0.2.27, de 30/09/2026).

despacho e notificacao são duas coisas, e separar as duas é o que torna o teto possível. despacho diz quem foi considerado para qual vaga; notificacao diz qual push saiu, quando, e se chegou. Um push agrupado ("4 vagas novas perto de você") é uma notificacao com quatro despacho apontando para ela. O UNIQUE (vaga_id, profissional_id, rodada) garante RN05 contra o modo de falha mais banal: a mesma vaga reenviada, na mesma rodada, para quem já recebeu. A reabertura por atraso abre uma rodada nova (decisão de 28/09): a vaga volta a chegar a quem já a tinha recebido, menos a quem faltou nela. E o estado de entrega mora em notificacao, que é a unidade que RNF02 mede.

O turno guarda a distância medida no toque, não a coordenada do profissional (RN22). O app lê a localização só no momento do check-in ou do check-out, nunca em segundo plano, calcula a distância até o endereço da vaga e envia só ela. É o que basta para saber se o check-in vale — até 200 m — e é o mínimo de dado pessoal que resolve o problema.

verificacao começa pendente e só vira verificado com prova: o CHECK verificacao_coerente recusa verificado sem check-in geolocalizado a até 200 m ou manual confirmado pelo contratante, e recusa deixar de marcar quando a prova existe. Se o turno termina com o check-in manual sem confirmação, o agendador marca nao_verificado. Se termina sem check-in nenhum, a posição é cancelada com falta e o turno também fica nao_verificado (decisão de 28/09). É essa coluna que libera a avaliação e que entra na taxa de comparecimento, e nenhuma tela consegue marcá-la sem a prova.

Não existe coluna de divergência. O registro geolocalizado é o que vale (RN11): o Frila não arbitra, e o contratante que discordar do horário registra isso na avaliação (A08).

A avaliação só é aceita depois do fim previsto de um turno com verificacao = 'verificado' (RN07), e vale um voto por lado do turno, não por pessoa: pelo estabelecimento, conta a primeira resposta de qualquer membro (contrato 0.2.12). Isso atravessa tabelas — o fim previsto está em posicao e a presença em turno —, então não cabe num CHECK: vive num trigger de BEFORE INSERT em avaliacao e na função RPC que o app chama para avaliar. Quem faltou não é avaliado: a falta já pesa na taxa, e não deve pesar duas vezes.

resposta boolean é a aposta central do produto codificada no tipo. Não existe caminho no esquema que aceite uma nota de 1 a 5 — RN07 proíbe, e um smallint "para o caso de mudarmos de ideia" é exatamente como a regra se perde.

valor_acordado_centavos é copiado da vaga no momento da confirmação, não lido por junção. Se o estabelecimento republicar a vaga com outro valor, o turno já executado precisa continuar dizendo quanto foi combinado — RN11 exige que o registro valha como registro, e registro que muda sozinho não vale nada.

bloqueio liga duas contas, autor_id e bloqueado_id, e vale nos dois sentidos: quem bloqueou não recebe mais nada de quem foi bloqueado, e vice-versa. Quando uma das contas é membro de estabelecimento, a consulta junta o bloqueio com membro_estabelecimento, e o bloqueio passa a valer para as vagas daquele estabelecimento inteiro. A elegibilidade, a lista de vagas e a candidatura leem a mesma tabela (RF26). O bloqueio é imediato e não depende de ninguém da Equipe Frila.

Uma tabela só (ocorrencia) para seis coisas diferentes é escolha: todas são o mesmo ato do ponto de vista do registro — alguém saiu do curso normal, num momento, por um motivo declarado. RN12 e RN13 pedem a mesma tripla para cancelamento e para suspensão, e motivo text NOT NULL é o que impede a suspensão silenciosa apontada como queixa recorrente nos concorrentes. Suporte, denúncia, contestação e pedido de revisão do despacho chegam à Equipe Frila por e-mail; a ocorrencia guarda o registro e o prazo de resposta, de até 5 dias úteis. Suspensão só nasce de denúncia grave confirmada — assédio, fraude ou documento falso, e os dois últimos chegam pelo motivo outro do enum motivo_denuncia —, nunca de cancelamento (RN13).


Reputação: o que é derivado e o que é guardado

Taxa de comparecimento e contagem de avaliações são deriváveis de posicao, turno e avaliacao. Guardá-las em profissional é desnormalização consciente: elas aparecem em todo card de perfil e na lista de candidatos do modo seleção, e recalcular o agregado a cada leitura custaria caro.

O preço é sincronização. Recalcular por trigger na escrita de avaliacao, no fechamento de turno e no cancelamento de posicao, com um job de reconciliação periódico — e tratar a coluna como cache, nunca como fonte. Quando a coluna e o histórico divergirem, o histórico ganha.

-- Definição canônica (A11), para o job de reconciliação e para os testes.
-- Presença = check-in geolocalizado ou manual confirmado (RN22).
-- Falta    = não aparecer, ou cancelar com menos de 24 h do início.
-- Fora da conta: candidatura não escolhida, cancelamento com mais de
-- 24 h e turno não verificado.
SELECT
  count(*) FILTER (WHERE t.verificacao = 'verificado')::numeric
    / NULLIF(count(*) FILTER (WHERE t.verificacao = 'verificado' OR p.falta), 0)
    AS taxa_comparecimento
FROM posicao p
LEFT JOIN turno t ON t.posicao_id = p.id
WHERE p.profissional_id = $1;

falta é gravada em posicao pela função que cancela: quando o próprio profissional cancela com menos de 24 horas do início, quando o contratante reabre a vaga depois do alerta de atraso, aos 15 minutos sem check-in, ou quando o turno termina sem check-in nenhum (decisão de 28/09). Cancelamento com mais de 24 horas não marca nada e sai da conta.

A definição precisa estar escrita porque RN16 a torna consequente: recusar vaga não pode gerar penalidade, então recusa e candidatura não escolhida não entram no denominador. Só conta o que a pessoa aceitou e foi confirmado. Confundir os dois transformaria o produto naquilo que RN16 existe para evitar — um sistema que pune quem escolhe. E a taxa só aparece no perfil: não muda quem recebe notificação (RN06), e cancelamento nenhum suspende ninguém (RN13).


LGPD no esquema

RNF08 e RN15 têm consequência estrutural, não apenas de política.

Exclusão é anonimização (RF25). O perfil sai do despacho e da busca na hora; os dados pessoais são apagados em até 15 dias. Turno e avaliação sobrevivem porque pertencem também à contraparte — apagar o histórico de um lado corromperia a reputação do outro, que não pediu nada:

UPDATE usuario
   SET nome = 'Conta encerrada', telefone = NULL, email = NULL,
       estado = 'anonimizada', anonimizado_em = now()
 WHERE id = $1;
-- e o registro em auth.users é apagado no Supabase Auth.

Dado pessoal não entra em log (RN15). Isso proíbe o trigger de auditoria que copia a linha inteira de usuario para uma tabela de histórico — padrão automático de quem implementa auditoria sem pensar. ocorrencia guarda usuario_id e motivo: referência e razão, não cópia do titular.

Finalidade por campo. ponto_base existe para calcular a distância de elegibilidade (RN05) e nada mais, e nenhuma leitura o mostra a outra pessoa. O check-in guarda a distância medida no toque, não a coordenada. O esquema não tem — e não deve ganhar — tabela de posições sucessivas do profissional: rastreamento contínuo está no escopo não contemplado com a marca mais dura do documento, "nunca na forma contínua", e tensiona RN16.

O contato tem prazo. RN10 libera telefone e WhatsApp das partes só depois da confirmação e só até 7 dias depois do fim do turno. A base legal é a execução do contrato (LGPD, art. 7º, V). No banco, isso é regra de acesso, e não de tela: nenhuma política deixa ler a linha de usuario de outra pessoa, e o telefone da contraparte só sai pela função contato_do_turno, que confere a confirmação, o prazo e o bloqueio (Políticas de acesso (RLS)).


Migração e versionamento

O esquema vai mudar antes da primeira linha de produção, e vai mudar mais depois. Três decisões evitam que isso vire trabalho manual:

Migração é arquivo versionado, nunca alteração pelo console. No Supabase, a pasta é supabase/migrations/, com arquivos datados e imutáveis gerados pela CLI (20260922150400_vaga_e_posicao.sql, 20260929110000_rodada_de_despacho.sql), aplicados em ordem. O que já foi aplicado nunca é editado: corrige-se com uma migração nova. Mudança feita pelo painel do Supabase e não trazida para um arquivo é mudança que o próximo ambiente não tem.

Migração compatível para frente. RNF12 proíbe manutenção no horário de pico — quinta a domingo, das 16h às 2h. Isso obriga o padrão de duas fases para toda mudança destrutiva: primeiro adiciona a coluna nova e passa a escrever nas duas, depois (noutro deploy) para de ler a antiga e só então a remove. Renomear coluna num único passo derruba o cliente antigo que ainda está no aparelho de alguém — e com Android e web no ar, sempre há cliente antigo.

O cliente nunca é atualizado junto com o servidor. Um app na App Store demora dias para chegar a todo mundo; a web atualiza no refresh. O backend precisa aceitar a versão anterior do contrato durante essa janela, o que empurra a compatibilidade para o contrato entre cliente e servidor.

Semente inicial (seed): o catálogo de funções acima, a lista de termos bloqueados e as duas contas de revisão da App Store, isoladas das reais por usuario.demonstracao. Dado de teste não entra em migração — vive em fixtures de teste, para que um seed acidental em produção não crie estabelecimento fantasma.


O que não existe neste esquema, e por quê

Tão importante quanto as dezoito entidades é a ausência das quatro que um marketplace normalmente teria. Aqui a ausência é decisão, não lacuna:

AusentePor quêQuando volta
pagamento, carteira, repasseRN09: o sistema registra o valor, não custodia dinheiro. Pagamento retido é a queixa recorrente em Switch, Closeer e eFreelav2.0, condicionado à validação
mensagem, conversaRN10 libera WhatsApp ou e-mail após a confirmação. Chat paralelo é superfície sem problema resolvidov2.0, se a validação indicar
nota, comentarioRN07 proíbe média de 1 a 5 e comentário abertoNunca
comissao, descontoRN01: o Frila nunca desconta comissão ou taxa do valor do turno; o valor anunciado é o valor integral que o profissional recebeNunca

A última linha merece ênfase. A forma de honrar RN01 no banco é não haver lugar onde descontar do turno seja representável: o que o profissional recebe é o valor_acordado_centavos, inteiro. Serviços opcionais pagos ao profissional podem existir no futuro, mas como produto à parte, que não toca no valor do turno — e não entram na v1.

Também não existe aval_externo. O atestado de quem trabalhou com o profissional fora da plataforma saiu do produto em 21/09 (A12): só avalia quem trabalhou junto pelo Frila, e um aval de fora é fácil de forjar — bastaria alguém cadastrar um estabelecimento e avalizar a si mesmo.


v1 e v2

Tabelav1 (MVP)Observação
usuario, profissional, estabelecimento, membro_estabelecimentoSimRF01, RF02, RF21
funcao, profissional_funcao, disponibilidadeSimRF03, base da elegibilidade
vaga, posicaoSimRF04, RF09, RF10, RN24
despacho, notificacaoSimRF06 e RN23, o coração do produto
dispositivoSimSem o token do aparelho, o push não tem destino (RF06, RNF02)
candidaturaSimRF08
turnoSimRF13, RN11, RN22
avaliacaoSimRF15, RF16
ocorrenciaSimRF14, RF23, RF24, RF26, RF27
bloqueioSimRF26 — a App Store exige denunciar e bloquear (diretriz 1.2)
equipe_confiancaSimRF18: recebe a notificação mesmo além de 15 km
eventoDepoisRF19 é "evite por ora" na matriz de impacto × esforço
entrada_demonstracao, pedido_de_exclusaoSimOperação: a conta de demonstração da revisão da App Store e o pedido de exclusão de conta (RF25)

O corte não é por gosto: é o conjunto mínimo que fecha o ciclo publicar → notificar → candidatar → confirmar → executar → avaliar, mais o bloqueio, sem o qual o app não passa pela revisão da App Store.


Do desenho às migrações (30/09)

O DDL acima é o desenho. As migrações de frila-backend (branch develop, 75 até 30/09) seguem esse desenho e acrescentam o que a implementação pediu. Onde os dois divergirem, vale a migração.

Três regras que mudaram em 28/09

Decididas no cartão de decisões de produto do Sprint 0 e aplicadas no banco:

RegraAntesAgoraMigração
Turno sem check-inO agendador marcava o turno como "não verificado", fora da taxaA posição é cancelada com falta, que conta contra a taxa; o turno fica nao_verificado20260928160000_fechar_turnos_noshow.sql
Elegibilidade pela gradeBastava o início da vaga cair numa janelaA janela precisa cobrir o turno inteiro20260929160100_elegiveis_ordem_de_filtro.sql
Despacho únicoUNIQUE (vaga_id, profissional_id)UNIQUE (vaga_id, profissional_id, rodada): a reabertura por atraso abre rodada nova, e a posição reaberta aceita candidatura até 1 hora antes do fim (contrato 0.2.19)20260929110000_rodada_de_despacho.sql

As outras decisões do mesmo cartão também valem aqui: o check-out é aceito a qualquer distância (checkout_distancia_m >= 0), a suspensão cancela em cascata os turnos futuros da conta, com ocorrência, aviso à contraparte e reabertura das posições, e a maioridade é conferida só pela data de nascimento.

Colunas que o banco tem e o desenho não mostra

TabelaColunas a maisPara quê
usuariotermos_versao, termos_aceite_em, demonstracaoAceite versionado dos termos no cadastro; separar as contas de demonstração das reais
estabelecimentoregiao_administrativaRegião administrativa do DF, obrigatória (contrato 0.2.20)
vagapublicado_por, regiao_administrativa, rodada_despachoQuem publicou; a região do local, que o lembrete mostra no lugar do endereço; a rodada atual do despacho
posicaoreaberta_por_atraso_deA posição que faltou, na reabertura por atraso (0.2.19)
turnocheckin_recebido_em, checkout_recebido_em, a_caminho_emA hora em que o servidor recebeu cada registro feito sem rede; o aviso "estou a caminho" (0.2.25, na v1.1)
notificacaorodada, esperou_teto, proxima_tentativa_emMarca de envio por rodada; o reenvio com recuo exponencial
ocorrenciarelato, estabelecimento_id, email_*O texto da denúncia e o controle do e-mail enviado à Equipe Frila e ao autor

Também há restrições a mais: posicoes BETWEEN 1 AND 200 em vaga, o formato do documento em estabelecimento, e triggers que tornam despacho e ocorrencia imutáveis. A vaga pode ser ocultada pela Equipe Frila, sem cancelar nada, quando o conteúdo é impróprio (contrato 0.2.23).

Tabelas que o desenho não mostra

Em public, entrada_demonstracao e pedido_de_exclusao, as duas com RLS ligada e nenhuma política. Num schema que a API não expõe, privado, dez tabelas de operação: ambiente, termo_bloqueado, auditoria_ciclo, divergencia_reputacao, configuracao_app, parametro_notificacao, tipo_no_teto, escrita_por_conta, vaga_ocultada e conta_equipe. Há ainda as filas pgmq despacho e email, as visões de funil do schema metrica e os enums motivo_denuncia e tipo_notificacao, este com 18 avisos.


No Supabase

O backend foi decidido em 21/09 (B02): Supabase, que é Postgres de verdade. Por isso quase tudo daqui vale como está, e o que muda é de lugar, não de sintaxe.

Continua igual: ENUM, citext, CHECK, coluna gerada, trigger, EXCLUDE USING gist para turnos sobrepostos, PostGIS e ST_DWithin. A confirmação por escrita condicional continua sendo uma linha de SQL, e RN19 continua sendo garantia do banco — o risco que pesava contra backend gerenciado era banco de documentos sem transação, e o Supabase não é isso.

Muda de lugar:

  • Acesso por política. Os clientes falam com o banco pela API do Supabase, sob Row Level Security. Escrita só por função; leitura, cada um do que é seu; e o que é da outra parte, como o telefone de RN10, sai por função que confere a regra. As políticas de cada tabela estão em Políticas de acesso (RLS).
  • Regra crítica em função RPC. Tudo o que precisa valer igual para iOS, Android e web vira função no banco, chamada pelos três: criar a conta e o perfil certo (RN25), confirmar (RN19), candidatar e retirar candidatura (RN24), check-in e check-out (RN22), cancelar e marcar falta, avaliar (RN07), denunciar e bloquear (RF26). A regra é escrita uma vez, e não três.
  • Despacho e agendamento fora da requisição. Publicar só grava e responde. Uma Edge Function, puxada pela fila pgmq e pelo pg_cron, faz o resto: elegíveis e teto de notificações (RN05, RN23), push pelo FCM, lembretes 24 h e 3 h antes, alerta de atraso aos 15 minutos, alerta de vaga vazia na janela crítica, fechamento do modo seleção 24 h antes (RN24) e aviso de fim de turno.
  • Entrada pelo Supabase Auth. Código de uso único no e-mail, sem senha e sem SMS; nenhuma credencial passa por tabela do produto. usuario.id é o id de auth.users, gravado por criar_conta.

Políticas de acesso (RLS)

Os clientes falam com o banco pela API do Supabase, com o token de quem está logado. Toda tabela do produto tem Row Level Security ligado, e a regra cabe em três frases:

  1. Escrita só por função. Nenhuma tabela tem política de insert, update ou delete para authenticated. Toda escrita passa por uma função RPC security definer, que confere quem chama, o perfil e a regra de negócio antes de gravar. Sem política de escrita, o RLS recusa a escrita direta pela API; o revoke abaixo deixa isso explícito.
  2. Cada um lê o que é seu. As políticas de select abrem a linha para quem tem direito a ela: a própria conta, o próprio perfil, as próprias candidaturas, turnos, aparelhos, notificações e ocorrências. O membro de um estabelecimento lê o que é do estabelecimento.
  3. O que é da outra parte sai por função. RLS filtra linha, não coluna. usuario mistura o que a outra parte pode ver (o nome), o que só o dono vê (e-mail e nascimento) e o que tem prazo (o telefone, RN10); profissional guarda o ponto_base, que é quase o endereço de alguém. Por isso nenhuma política abre a linha de usuario ou de profissional para outra pessoa: o que a contraparte vê sai por funções security definer que devolvem só as colunas permitidas (O que a outra parte vê).

anon não lê nem escreve nada: toda rota exige login, com uma exceção. public.configuracao_do_app é a única função que anon executa, porque o app abaixo da versão mínima precisa descobrir isso antes de conseguir entrar (contrato 0.2.16, cartão #201). Ela lê privado.configuracao_app, que tem RLS ligada, nenhuma política, não é exposta pelo PostgREST e só muda por migração. O agendador (Edge Function) e a Equipe Frila usam a chave de serviço, que fica fora do RLS e nunca vai para um app.

As funções auxiliares

As políticas fazem sempre as mesmas perguntas: qual é o perfil da conta, qual é o meu profissional, sou membro deste estabelecimento, há bloqueio entre nós. Cada pergunta vira uma função, num schema que a API não expõe:

-- Fora da API: o PostgREST só expõe public, então ninguém chama estas
-- funções por /rpc. São security definer para ler as tabelas sem cair em
-- recursão de política, e stable para o planejador reaproveitar o resultado.
CREATE SCHEMA privado;

CREATE FUNCTION privado.perfil_da_conta() RETURNS perfil_conta
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT u.perfil FROM public.usuario u WHERE u.id = (SELECT auth.uid())
$$;

CREATE FUNCTION privado.meu_profissional_id() RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT p.id FROM public.profissional p WHERE p.usuario_id = (SELECT auth.uid())
$$;

CREATE FUNCTION privado.eh_membro(estab uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.membro_estabelecimento m
                  WHERE m.estabelecimento_id = estab
                    AND m.usuario_id = (SELECT auth.uid()))
$$;

CREATE FUNCTION privado.eh_administrador(estab uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.membro_estabelecimento m
                  WHERE m.estabelecimento_id = estab
                    AND m.usuario_id = (SELECT auth.uid())
                    AND m.papel = 'administrador')
$$;

-- RF26: bloqueio nos dois sentidos, entre uma conta e qualquer membro do estabelecimento.
CREATE FUNCTION privado.bloqueado_com_estabelecimento(conta uuid, estab uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.bloqueio b
                   JOIN public.membro_estabelecimento m
                     ON m.usuario_id IN (b.autor_id, b.bloqueado_id)
                  WHERE m.estabelecimento_id = estab
                    AND conta IN (b.autor_id, b.bloqueado_id)
                    AND m.usuario_id <> conta)   -- o membro não se bloqueia consigo mesmo
$$;

CREATE FUNCTION privado.estabelecimento_da_vaga(v uuid) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT estabelecimento_id FROM public.vaga WHERE id = v
$$;

CREATE FUNCTION privado.estabelecimento_da_posicao(pos uuid) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT v.estabelecimento_id
    FROM public.posicao p JOIN public.vaga v ON v.id = p.vaga_id
   WHERE p.id = pos
$$;

CREATE FUNCTION privado.usuario_do_profissional(prof uuid) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT usuario_id FROM public.profissional WHERE id = prof
$$;

-- O profissional ocupa ou ocupou uma posição desta vaga: é o turno dele.
CREATE FUNCTION privado.ocupa_posicao_na_vaga(v uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.posicao p
                  WHERE p.vaga_id = v
                    AND p.profissional_id = privado.meu_profissional_id())
$$;

-- O profissional se candidatou a uma posição desta vaga.
CREATE FUNCTION privado.candidatou_na_vaga(v uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.candidatura c
                   JOIN public.posicao p ON p.id = c.posicao_id
                  WHERE p.vaga_id = v
                    AND c.profissional_id = privado.meu_profissional_id())
$$;

-- Um dos dois lados da posição: quem a ocupa, ou um membro do estabelecimento.
CREATE FUNCTION privado.lado_da_posicao(pos uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
  SELECT EXISTS (SELECT 1 FROM public.posicao p
                  WHERE p.id = pos
                    AND p.profissional_id = privado.meu_profissional_id())
      OR privado.eh_membro(privado.estabelecimento_da_posicao(pos))
$$;

-- Primeira linha de toda função RPC que é de um perfil só (RN25).
CREATE FUNCTION privado.exigir_perfil(esperado perfil_conta) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
BEGIN
  IF privado.perfil_da_conta() IS DISTINCT FROM esperado THEN
    PERFORM public.erro(422, 'perfil_incompativel');
  END IF;
END $$;

REVOKE ALL ON SCHEMA privado FROM PUBLIC;
GRANT USAGE ON SCHEMA privado TO authenticated;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA privado FROM PUBLIC, anon, authenticated;
-- Só as doze auxiliares que as políticas de RLS chamam, porque a política roda com a
-- identidade de quem lê (20260930141532_privado_sem_execucao_de_quem_nao_precisa.sql).
-- As RPCs de public são security definer e dispensam a concessão.
GRANT EXECUTE ON FUNCTION privado.perfil_da_conta(), privado.meu_profissional_id(),
  privado.eh_membro(uuid), privado.bloqueado_com_estabelecimento(uuid, uuid),
  privado.candidatou_na_vaga(uuid), privado.ocupa_posicao_na_vaga(uuid),
  privado.estabelecimento_da_vaga(uuid), privado.estabelecimento_da_posicao(uuid),
  privado.lado_da_posicao(uuid), privado.usuario_do_profissional(uuid),
  privado.mesma_populacao(uuid), privado.vaga_oculta(uuid)
  TO authenticated;

Nas políticas, auth.uid() e as auxiliares sem argumento vão dentro de (SELECT …): assim o Postgres calcula o valor uma vez por consulta, e não uma vez por linha. eh_administrador não aparece nas políticas de leitura; serve às funções que só o administrador chama, como convidar membro e remover o estabelecimento. exigir_perfil usa o auxiliar erro() do contrato (api/openapi.yaml, no repositório frila-docs).

Ligar o RLS e fechar a escrita

ALTER TABLE usuario                ENABLE ROW LEVEL SECURITY;
ALTER TABLE profissional           ENABLE ROW LEVEL SECURITY;
ALTER TABLE estabelecimento        ENABLE ROW LEVEL SECURITY;
ALTER TABLE membro_estabelecimento ENABLE ROW LEVEL SECURITY;
ALTER TABLE equipe_confianca       ENABLE ROW LEVEL SECURITY;
ALTER TABLE funcao                 ENABLE ROW LEVEL SECURITY;
ALTER TABLE profissional_funcao    ENABLE ROW LEVEL SECURITY;
ALTER TABLE disponibilidade        ENABLE ROW LEVEL SECURITY;
ALTER TABLE evento                 ENABLE ROW LEVEL SECURITY;
ALTER TABLE vaga                   ENABLE ROW LEVEL SECURITY;
ALTER TABLE posicao                ENABLE ROW LEVEL SECURITY;
ALTER TABLE dispositivo            ENABLE ROW LEVEL SECURITY;
ALTER TABLE notificacao            ENABLE ROW LEVEL SECURITY;
ALTER TABLE despacho               ENABLE ROW LEVEL SECURITY;
ALTER TABLE candidatura            ENABLE ROW LEVEL SECURITY;
ALTER TABLE turno                  ENABLE ROW LEVEL SECURITY;
ALTER TABLE avaliacao              ENABLE ROW LEVEL SECURITY;
ALTER TABLE bloqueio               ENABLE ROW LEVEL SECURITY;
ALTER TABLE ocorrencia             ENABLE ROW LEVEL SECURITY;

-- Escrita só por função (frase 1). Sem política de escrita o RLS já recusa;
-- o revoke deixa isso explícito, e o default privileges cobre a tabela nova.
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM authenticated;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO authenticated;
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON TABLES FROM authenticated;

-- Função nova nasce sem execute para anon; authenticated continua chamando as RPCs.
-- Exceção única, concedida por nome na própria migração: public.configuracao_do_app.
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC, anon;

As políticas de leitura

Uma política por tabela, todas de select e todas para authenticated. São dezenove tabelas do produto e dezenove políticas; as duas tabelas de operação de public, entrada_demonstracao e pedido_de_exclusao, têm RLS ligada e nenhuma política, de propósito:

-- Identidade: cada um lê a própria linha, e só ela.
CREATE POLICY usuario_leitura ON usuario FOR SELECT TO authenticated
  USING (id = (SELECT auth.uid()));

CREATE POLICY profissional_leitura ON profissional FOR SELECT TO authenticated
  USING (usuario_id = (SELECT auth.uid()));

-- Estabelecimento: o membro lê o seu. A equipe de confiança, os dois lados.
CREATE POLICY estabelecimento_leitura ON estabelecimento FOR SELECT TO authenticated
  USING (privado.eh_membro(id));

CREATE POLICY membro_estabelecimento_leitura ON membro_estabelecimento
  FOR SELECT TO authenticated
  USING (privado.eh_membro(estabelecimento_id));

CREATE POLICY equipe_confianca_leitura ON equipe_confianca FOR SELECT TO authenticated
  USING (privado.eh_membro(estabelecimento_id)
         OR profissional_id = (SELECT privado.meu_profissional_id()));

-- Catálogo aberto a quem está logado. Funções declaradas e grade, só o dono.
CREATE POLICY funcao_leitura ON funcao FOR SELECT TO authenticated
  USING (true);

CREATE POLICY profissional_funcao_leitura ON profissional_funcao
  FOR SELECT TO authenticated
  USING (profissional_id = (SELECT privado.meu_profissional_id()));

CREATE POLICY disponibilidade_leitura ON disponibilidade FOR SELECT TO authenticated
  USING (profissional_id = (SELECT privado.meu_profissional_id()));

-- Vaga: o membro vê todas as do estabelecimento. O profissional vê as publicadas
-- e as que tem candidatura, menos as de quem tem bloqueio com ele (RF26), e vê
-- sempre as que ocupou, porque o turno é histórico dele.
CREATE POLICY evento_leitura ON evento FOR SELECT TO authenticated
  USING (privado.eh_membro(estabelecimento_id));

-- mesma_populacao separa as contas de demonstração das reais; a vaga ocultada
-- pela Equipe Frila (contrato 0.2.23) sai da lista, mas não de quem se candidatou.
CREATE POLICY vaga_leitura ON vaga FOR SELECT TO authenticated
  USING (
    privado.mesma_populacao(publicado_por)
    AND (
          privado.eh_membro(estabelecimento_id)
       OR ((SELECT privado.perfil_da_conta()) = 'profissional'
           AND (   privado.ocupa_posicao_na_vaga(id)
                OR (NOT privado.bloqueado_com_estabelecimento(
                          (SELECT auth.uid()), estabelecimento_id)
                    AND ((estado = 'publicada' AND NOT privado.vaga_oculta(id))
                         OR privado.candidatou_na_vaga(id)))))));

CREATE POLICY posicao_leitura ON posicao FOR SELECT TO authenticated
  USING (profissional_id = (SELECT privado.meu_profissional_id())
         OR privado.eh_membro(privado.estabelecimento_da_vaga(vaga_id)));

-- Candidatura: a própria, ou as das vagas do estabelecimento, menos as de quem
-- tem bloqueio com ele, mesmo que o bloqueio tenha vindo depois.
CREATE POLICY candidatura_leitura ON candidatura FOR SELECT TO authenticated
  USING (
        profissional_id = (SELECT privado.meu_profissional_id())
     OR (privado.eh_membro(privado.estabelecimento_da_posicao(posicao_id))
         AND NOT privado.bloqueado_com_estabelecimento(
                   privado.usuario_do_profissional(profissional_id),
                   privado.estabelecimento_da_posicao(posicao_id))));

-- Turno: os dois lados da posição.
CREATE POLICY turno_leitura ON turno FOR SELECT TO authenticated
  USING (privado.lado_da_posicao(posicao_id));

-- Avaliação e bloqueio: só quem escreveu.
CREATE POLICY avaliacao_leitura ON avaliacao FOR SELECT TO authenticated
  USING (autor_id = (SELECT auth.uid()));

CREATE POLICY bloqueio_leitura ON bloqueio FOR SELECT TO authenticated
  USING (autor_id = (SELECT auth.uid()));

-- Só do dono: o que foi despachado para ele, os pushes e os aparelhos.
CREATE POLICY despacho_leitura ON despacho FOR SELECT TO authenticated
  USING (profissional_id = (SELECT privado.meu_profissional_id()));

CREATE POLICY notificacao_leitura ON notificacao FOR SELECT TO authenticated
  USING (usuario_id = (SELECT auth.uid()));

CREATE POLICY dispositivo_leitura ON dispositivo FOR SELECT TO authenticated
  USING (usuario_id = (SELECT auth.uid()));

-- Ocorrência: o que a pessoa abriu, e o que foi decidido sobre ela.
CREATE POLICY ocorrencia_leitura ON ocorrencia FOR SELECT TO authenticated
  USING (autor_id = (SELECT auth.uid())
         OR (usuario_id = (SELECT auth.uid())
             AND tipo IN ('suspensao', 'cancelamento')));

Algumas escolhas pedem explicação:

  • A avaliação só o autor lê. Quem foi avaliado vê o agregado — "7 de 7 chamariam de novo" — pela função de perfil, com o denominador (RN08), e não quem respondeu o quê. A resposta individual à vista convidaria à retaliação, e a pergunta binária só funciona se a pessoa responde sem medo.
  • O bloqueio só quem bloqueou vê. Quem foi bloqueado apenas deixa de cruzar com a outra parte (RF26). Saber quem o bloqueou é informação que pode pôr alguém em risco.
  • A denúncia não aparece para o denunciado. A ocorrência de denúncia é lida só por quem denunciou. O denunciado vê a suspensão, se houver, com o motivo e o caminho para contestar (RN13).
  • O estabelecimento não vê o despacho. Saber quem foi notificado de uma vaga revelaria quem está perto e disponível naquele horário. O contratante vê quem se candidatou, e só.
  • O turno fica para os dois lados, mesmo depois de um bloqueio. É histórico de cada um e entra na exportação (RF22). O bloqueio corta o contato e tudo o que vem depois.
  • A exclusão tira a pessoa das leituras sozinha (RF25). A conta anonimizada não tem mais registro em auth.users, então nenhum token casa com ela; o despacho já filtra estado = 'ativa'; e, no histórico da contraparte, as funções de perfil mostram "Conta encerrada".

O que a outra parte vê

As leituras que só tocam no que é do próprio usuário podem rodar com a identidade de quem chama, sob as políticas acima. As que juntam dado da outra parte — o nome do estabelecimento no turno, o perfil do candidato, o contato — são security definer e devolvem só isto:

FunçãoO que devolveRegra
perfil_publicoNome, funções, turnos realizados, taxa de comparecimento e o par aval_positivas / aval_totalRN08: sempre com o denominador, nunca só o percentual. ponto_base, telefone, e-mail e nascimento não saem. Hoje não filtra bloqueio; a decisão de 30/09 é passar a esconder o perfil entre partes bloqueadas (RF26), numa próxima versão do contrato
vagas_abertas, detalhe_vagaA vaga e, do estabelecimento, nome, tipo e reputaçãoRF26: some a vaga de quem tem bloqueio com o profissional. O documento (CNPJ ou CPF) não sai
candidatos_da_vagaPara o membro do estabelecimento, quem se candidatou, com o perfil público de cada umRF26: quem tem bloqueio some da lista
contato_do_turnoNome, telefone e link do WhatsApp da outra parteRN10: só com a posição confirmada ou cumprida, até 7 dias depois do fim, e sem bloqueio
painel_estabelecimentoVagas, posições, candidatos e turnos do estabelecimentoSó para membro

Toda função de escrita segue o mesmo molde: security definer, set search_path = '' com nomes qualificados, auth.uid() conferido antes de tudo e, quando a função é de um perfil só, privado.exigir_perfil(…) na primeira linha. criar_perfil_profissional exige 'profissional'; cadastrar_estabelecimento exige 'contratante', e o aceite de convite de membro vai exigir, quando entrar, na v1.2 (RN25, RF21).

Cada política ganha teste antes de ir para produção: entrar como profissional, como contratante de outro estabelecimento e como conta bloqueada, e conferir o que cada um lê (supabase test db, com pgTAP).

Resumo

TabelaQuem lêQuem escreve, sempre por função
usuarioA própria contacriar_conta; excluir-conta anonimiza
profissionalO próprio profissionalcriar_perfil_profissional, atualizar_perfil_profissional
estabelecimentoOs membroscadastrar_estabelecimento
membro_estabelecimentoOs membros do mesmo estabelecimentocadastrar_estabelecimento (primeiro administrador); o convite de membro (RF21) entra na v1.2
equipe_confiancaOs membros e o profissional incluídoincluir_na_equipe, remover_da_equipe
funcaoQualquer conta logadaSó migração: é catálogo
profissional_funcaoO próprio profissionalcriar_perfil_profissional, atualizar_perfil_profissional
disponibilidadeO próprio profissionalcriar_perfil_profissional, atualizar_perfil_profissional
eventoOs membrosA escala em lote (RF19), depois do MVP
vagaOs membros; o profissional, as publicadas e as que tem candidatura, sem bloqueio, e as que ocupoupublicar_vaga, republicar_vaga, cancelar_vaga; o agendador fecha o modo seleção
posicaoOs membros e quem ocupapublicar_vaga, candidatar (no modo urgência), escolher_candidato, cancelar_posicao, reabrir_por_atraso
candidaturaO próprio profissional; os membros, menos quem tem bloqueiocandidatar, retirar_candidatura, escolher_candidato; o agendador expira
turnoOs dois lados da posiçãoA confirmação cria; fazer_checkin, confirmar_checkin_manual, fazer_checkout; o agendador marca "não verificado"
avaliacaoSó o autoravaliar
bloqueioSó o autorbloquear
despachoO próprio profissionalO agendador
notificacaoA conta destinatáriaprivado.notificar, pelas RPCs e pelo agendador
dispositivoO donoregistrar_dispositivo; excluir-conta apaga
ocorrenciaO autor; o alvo, na suspensão e no cancelamentodenunciar, contestar_suspensao, pedir_revisao_despacho e os cancelamentos; a Equipe Frila, com a chave de serviço

anon não aparece na tabela porque não tem nada: nenhuma leitura de tabela e nenhuma escrita. A única coisa que ele alcança é a função configuracao_do_app, que lê privado.configuracao_app por dentro; a tabela em si continua fora do alcance dele.


Decisões respondidas em 21/09/2026

#DecisãoResposta
D1Adotar a regra de turnos não sobrepostos?Sim (B04): virou RN21, garantida pelo EXCLUDE USING gist em posicao
D2Tamanho da leva e intervalo entre levasNão há levas (D07): a notificação sai de uma vez para quem tem a função, está disponível e a até 15 km, e a equipe de confiança recebe mesmo além; teto de uma notificação a cada 30 minutos, com agrupamento (RN23)
D3Prazo até a candidatura expirarVale até a vaga fechar (D05). Modo seleção só para vaga com mais de 24 horas; sem escolha até 24 horas antes, a vaga fecha e os candidatos são liberados (RN24)
D4Janela crítica fixa ou por tipo de vagaUm padrão igual para todas — 3 horas antes do início —, que o contratante ajusta ao publicar; o alerta chega por notificação (B18)
D5PostgreSQL com PostGIS, ou backend gerenciadoSupabase, que é Postgres com PostGIS; é a mesma decisão da D9 (B02, B06)

As decisões de produto de 28/09 que mexeram no esquema estão em Três regras que mudaram em 28/09.